Skip to content

Commit

Permalink
Merge pull request #201 from gadget-inc/react-optional-api
Browse files Browse the repository at this point in the history
support optional model api identifier in react hooks
  • Loading branch information
jasong689 authored Jun 2, 2023
2 parents 7263ea9 + 715664a commit fb77ff2
Show file tree
Hide file tree
Showing 7 changed files with 209 additions and 38 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"devDependencies": {
"@gadget-client/bulk-actions-test": "^1.101.0",
"@gadget-client/related-products-example": "^1.783.0",
"@gadget-client/related-products-example": "^1.844.0",
"@gadgetinc/eslint-config": "^0.6.1",
"@gadgetinc/prettier-config": "^0.4.0",
"@swc/core": "^1.3.42",
Expand Down
3 changes: 3 additions & 0 deletions packages/api-client-core/src/GadgetFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ interface ActionFunctionMetadata<OptionsT, VariablesT, SelectionT, SchemaT, Defa
variables: VariableOptions;
variablesType: VariablesT;
isBulk: IsBulk;
hasAmbiguousIdentifier?: boolean;
hasCreateOrUpdateEffect?: boolean;
paramOnlyVariables?: readonly string[];
}

export type ActionFunction<OptionsT, VariablesT, SelectionT, SchemaT, DefaultsT> = ActionFunctionMetadata<
Expand Down
10 changes: 3 additions & 7 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,7 @@ export const CreatePost = () => {
// run the action when the button is clicked
// the action runner function accepts action inputs in the same format as api.blogPost.create, and the GraphQL API
createBlogPost({
blogPost: {
title: "New post created from React",
},
title: "New post created from React",
});
}}
>
Expand Down Expand Up @@ -549,9 +547,7 @@ export const UpdatePost = (props: { id: string }) => {
// pass the id of the blog post we're updating as one parameter, and the new post attributes as another
updateBlogPost({
id: props.id,
post: {
title,
},
title,
});
}}
>
Expand All @@ -574,7 +570,7 @@ const [{ data, fetching, error }, _refetch] = useFindBy(api.blogPost.findBySlug,
### `useGlobalAction(actionFunction: GlobalActionFunction, options: UseGlobalActionOptions = {}): [{data, fetching, error}, refetch]`
`useGlobalAction` is a hook for running a backend Global Action. `useGlobalAction(api.widget.create)` is the React equivalent of `await api.someGlobalAction({...})`. `useGlobalAction` doesn't immediately dispatch a request to run an action server side, but instead returns a result object and a function which runs the action, similar to [`urql`'s `useMutation` hook](https://formidable.com/open-source/urql/docs/api/urql/#usemutation). `useGlobalAction` must be passed one of the global action functions from an instance of your application's generated API client. Options:
`useGlobalAction` is a hook for running a backend Global Action. `useGlobalAction(api.someGlobalAction)` is the React equivalent of `await api.someGlobalAction({...})`. `useGlobalAction` doesn't immediately dispatch a request to run an action server side, but instead returns a result object and a function which runs the action, similar to [`urql`'s `useMutation` hook](https://formidable.com/open-source/urql/docs/api/urql/#usemutation). `useGlobalAction` must be passed one of the global action functions from an instance of your application's generated API client. Options:
- `globalActionFunction`: The action function from your application's API client. Gadget generates these global action functions for each global action defined in your Gadget backend. Required. Example: `api.runSync`, or `api.purgeData` (corresponding to Global Actions named `Run Sync` or `Purge Data`).
- `options`: Options for making the call to the backend. Not required and all keys are optional.
Expand Down
23 changes: 21 additions & 2 deletions packages/react/spec/testWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ export const graphqlDocumentName = (doc: DocumentNode) => {
/**
* Create a new function for reading/writing to a mock graphql backend
*/
const newMockOperationFn = () => {
const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => {
const subjects: Record<string, Subject<OperationResult>> = {};

const fn = jest.fn(({ query }: GraphQLRequest, options?: Partial<OperationContext>) => {
const fn = jest.fn((request: GraphQLRequest, options?: Partial<OperationContext>) => {
const { query } = request;
const fetchOptions = options?.fetchOptions;
const key = graphqlDocumentName(query) ?? "unknown";
subjects[key] ??= makeSubject();
Expand All @@ -66,6 +67,10 @@ const newMockOperationFn = () => {
}
}

if (assertions) {
assertions(request);
}

return subjects[key].source;
}) as unknown as MockOperationFn;

Expand Down Expand Up @@ -127,6 +132,20 @@ beforeEach(() => {
};
});

export const createMockCLient = (assertions?: {
mutationAssertions?: (request: GraphQLRequest) => void;
queryAssertions?: (request: GraphQLRequest) => void;
}) => {
return {
executeQuery: newMockOperationFn(assertions?.queryAssertions),
executeMutation: newMockOperationFn(assertions?.mutationAssertions),
executeSubscription: newMockOperationFn(),
[$gadgetConnection]: {
fetch: newMockFetchFn(),
},
} as MockUrqlClient;
};

export const TestWrapper = (props: { children: ReactNode }) => {
return <Provider value={mockClient}>{props.children}</Provider>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { act, renderHook } from "@testing-library/react";

import type { IsExact } from "conditional-type-checks";
import { assert } from "conditional-type-checks";
import React from "react";
import type { AnyVariables } from "urql";
import { useAction } from "../src";
import { GadgetProvider as Provider } from "../src/GadgetProvider";
import type { ErrorWrapper } from "../src/utils";
import { relatedProductsApi } from "./apis";
import { mockClient, TestWrapper } from "./testWrapper";
import { TestWrapper, createMockCLient, mockClient } from "./testWrapper";

describe("useAction", () => {
// these functions are typechecked but never run to avoid actually making API calls
Expand All @@ -29,6 +32,38 @@ describe("useAction", () => {
void mutate({ foo: "123" });
};

const TestUseActionCanRunWithoutModelApiIdentifier = () => {
const [_, mutate] = useAction(relatedProductsApi.unambiguous.update);

// can call using flat style
void mutate({ id: "123", numberField: 654, stringField: "foo" });

// can call using old style
void mutate({ id: "123", unambiguous: { numberField: 321, stringField: "bar" } });

// @ts-expect-error can't call with no arguments
void mutate();

// @ts-expect-error can't call with no id
void mutate({});
};

const TestUseActionCannotRunWithoutModelApiIdentifier = () => {
const [_, mutate] = useAction(relatedProductsApi.ambiguous.update);

// @ts-expect-error models with ambigous identifiers can't be called with flat style signature
void mutate({ id: "123", booleanField: true });

// old style signature is always valid
void mutate({ id: "123", ambiguous: { booleanField: true } });

// @ts-expect-error can't call with no arguments
void mutate();

// @ts-expect-error can't call with no id
void mutate({});
};

const TestUseActionReturnsTypedDataWithExplicitSelection = () => {
const [{ data, fetching, error }, mutate] = useAction(relatedProductsApi.user.update, {
select: { id: true, email: true },
Expand Down Expand Up @@ -269,4 +304,103 @@ describe("useAction", () => {
expect(result.current[0].fetching).toBe(false);
expect(result.current[0].error).toBeFalsy();
});

test("generates correct mutation and variables for a mutation without model api identifier", async () => {
let variables: AnyVariables;

const client = createMockCLient({
mutationAssertions: (request) => {
variables = request.variables;
},
});

const wrapper = (props: { children: React.ReactNode }) => <Provider value={client}>{props.children}</Provider>;

const { result } = renderHook(() => useAction(relatedProductsApi.unambiguous.update), {
wrapper,
});

let mutationPromise: any;
act(() => {
mutationPromise = result.current[1]({ id: "123", stringField: "hello world", numberField: 21 });
});

client.executeMutation.pushResponse("updateUnambiguous", {
data: {
updateUnambiguous: {
success: true,
unambiguous: {
id: "123",
stringField: "hello world",
numberField: 21,
},
},
},
});

await act(async () => {
const promiseResult = await mutationPromise;
expect(promiseResult.data!.id).toEqual("123");
expect(promiseResult.fetching).toBe(false);
expect(promiseResult.error).toBeFalsy();
});

const flatVariables = {
...variables,
};

act(() => {
mutationPromise = result.current[1]({ id: "123", unambiguous: { stringField: "hello world", numberField: 21 } });
});

client.executeMutation.pushResponse("updateUnambiguous", {
data: {
updateUnambiguous: {
success: true,
unambiguous: {
id: "123",
stringField: "hello world",
numberField: 21,
},
},
},
});

await act(async () => {
const promiseResult = await mutationPromise;
expect(promiseResult.data!.id).toEqual("123");
expect(promiseResult.fetching).toBe(false);
expect(promiseResult.error).toBeFalsy();
});

expect(flatVariables).toEqual(variables);
expect(variables).toMatchInlineSnapshot(`
{
"id": "123",
"unambiguous": {
"numberField": 21,
"stringField": "hello world",
},
}
`);
});

test("should throw if called without a model api identifier and there is an ambiguous field", async () => {
let caughtError = null;

const { result } = renderHook(() => useAction(relatedProductsApi.ambiguous.update), {
wrapper: TestWrapper,
});

try {
// @ts-expect-error intentionally calling with wrong type
await result.current[1]({ id: "123", booleanField: true });
} catch (e) {
caughtError = e;
}

expect(caughtError).toMatchInlineSnapshot(
`[Error: Invalid arguments found in variables. Did you mean to use ({ ambiguous: { ... } })?]`
);
});
});
44 changes: 39 additions & 5 deletions packages/react/src/useAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ import { ErrorWrapper, noProviderErrorMessage } from "./utils";
* });
*
* const onClick = () => createUser({
* user: {
* name: props.name,
* email: props.email,
* }
* name: props.name,
* email: props.email,
* });
*
* return (
Expand Down Expand Up @@ -79,9 +77,45 @@ export const useAction = <
transformedResult,
useCallback(
async (variables, context) => {
if (action.hasAmbiguousIdentifier) {
if (Object.keys(variables).some((key) => !action.paramOnlyVariables?.includes(key) && key !== action.modelApiIdentifier)) {
throw Error(`Invalid arguments found in variables. Did you mean to use ({ ${action.modelApiIdentifier}: { ... } })?`);
}
}

let newVariables: Exclude<F["variablesType"], null | undefined>;
const idVariable = Object.entries(action.variables).find(([key, value]) => key === "id" && value.type === "GadgetID");

if (action.hasCreateOrUpdateEffect) {
if (
action.modelApiIdentifier in variables &&
typeof variables[action.modelApiIdentifier] === "object" &&
variables[action.modelApiIdentifier] !== null
) {
newVariables = variables;
} else {
newVariables = {
[action.modelApiIdentifier]: {},
} as Exclude<F["variablesType"], null | undefined>;
for (const [key, value] of Object.entries(variables)) {
if (action.paramOnlyVariables?.includes(key)) {
newVariables[key] = value;
} else {
if (idVariable && key === idVariable[0]) {
newVariables.id = value;
} else {
newVariables[action.modelApiIdentifier][key] = value;
}
}
}
}
} else {
newVariables = variables;
}

// Adding the model's additional typename ensures document cache will properly refresh, regardless of whether __typename was
// selected (and sometimes we can't even select it, like delete actions!)
const result = await runMutation(variables, {
const result = await runMutation(newVariables, {
...context,
additionalTypenames: [...(context?.additionalTypenames ?? []), capitalizeIdentifier(action.modelApiIdentifier)],
});
Expand Down
29 changes: 7 additions & 22 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,12 @@
dependencies:
"@gadgetinc/api-client-core" "0.11.0"

"@gadget-client/related-products-example@^1.783.0":
version "1.783.0"
resolved "https://registry.gadget.dev/npm/_/tarball/1268/1361/1486#fddfba4e201f4d30478c7a03de30ca14185d687d"
integrity sha1-/d+6TiAfTTBHjHoD3jDKFBhdaH0=
"@gadget-client/related-products-example@^1.844.0":
version "1.844.0"
resolved "https://registry.gadget.dev/npm/_/tarball/1268/1361/4716#7110b19ac473252b3f3966be74454b08226caa04"
integrity sha1-cRCxmsRzJSs/OWa+dEVLCCJsqgQ=
dependencies:
"@gadgetinc/api-client-core" "0.9.0"
"@gadgetinc/api-client-core" "0.13.9"

"@gadget-client/simple-blog@^1.130.0":
version "1.130.0"
Expand All @@ -676,7 +676,7 @@
"@gadgetinc/api-client-core" "0.13.3"

"@gadgetinc/api-client-core@0.11.0":
version "0.13.6"
version "0.13.9"
dependencies:
"@opentelemetry/api" "^1.4.0"
"@urql/core" "^3.0.1"
Expand All @@ -691,22 +691,7 @@
ws "^8.11.0"

"@gadgetinc/api-client-core@0.13.3":
version "0.13.6"
dependencies:
"@opentelemetry/api" "^1.4.0"
"@urql/core" "^3.0.1"
"@urql/exchange-multipart-fetch" "^1.0.1"
cross-fetch "^3.0.6"
gql-query-builder "^3.7.2"
graphql "^16.5.0"
graphql-ws "^5.5.5"
isomorphic-ws "^4.0.1"
lodash.clonedeep "^4.5.0"
lodash.isequal "^4.5.0"
ws "^8.11.0"

"@gadgetinc/api-client-core@0.9.0":
version "0.13.6"
version "0.13.9"
dependencies:
"@opentelemetry/api" "^1.4.0"
"@urql/core" "^3.0.1"
Expand Down

0 comments on commit fb77ff2

Please sign in to comment.