From 2c72e5bb1a3e7288ef56c79ca31aa46832e1a35c Mon Sep 17 00:00:00 2001 From: Jason Gao Date: Wed, 17 May 2023 15:13:45 -0400 Subject: [PATCH 1/2] support optional model api identifier in react hooks --- package.json | 2 +- .../api-client-core/src/GadgetFunctions.ts | 3 + packages/react/spec/testWrapper.tsx | 23 ++- .../{useAction.spec.ts => useAction.spec.tsx} | 136 +++++++++++++++++- packages/react/src/useAction.ts | 36 ++++- yarn.lock | 29 +--- 6 files changed, 202 insertions(+), 27 deletions(-) rename packages/react/spec/{useAction.spec.ts => useAction.spec.tsx} (67%) diff --git a/package.json b/package.json index 61b8a5bdf..6fcf31de6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/api-client-core/src/GadgetFunctions.ts b/packages/api-client-core/src/GadgetFunctions.ts index 720e52cb4..e83d91543 100644 --- a/packages/api-client-core/src/GadgetFunctions.ts +++ b/packages/api-client-core/src/GadgetFunctions.ts @@ -101,6 +101,9 @@ interface ActionFunctionMetadata = ActionFunctionMetadata< diff --git a/packages/react/spec/testWrapper.tsx b/packages/react/spec/testWrapper.tsx index cb0f10b80..503a60654 100644 --- a/packages/react/spec/testWrapper.tsx +++ b/packages/react/spec/testWrapper.tsx @@ -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> = {}; - const fn = jest.fn(({ query }: GraphQLRequest, options?: Partial) => { + const fn = jest.fn((request: GraphQLRequest, options?: Partial) => { + const { query } = request; const fetchOptions = options?.fetchOptions; const key = graphqlDocumentName(query) ?? "unknown"; subjects[key] ??= makeSubject(); @@ -66,6 +67,10 @@ const newMockOperationFn = () => { } } + if (assertions) { + assertions(request); + } + return subjects[key].source; }) as unknown as MockOperationFn; @@ -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 {props.children}; }; diff --git a/packages/react/spec/useAction.spec.ts b/packages/react/spec/useAction.spec.tsx similarity index 67% rename from packages/react/spec/useAction.spec.ts rename to packages/react/spec/useAction.spec.tsx index ea2a0a28b..a11ae98ed 100644 --- a/packages/react/spec/useAction.spec.ts +++ b/packages/react/spec/useAction.spec.tsx @@ -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 @@ -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 }, @@ -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 }) => {props.children}; + + 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: { ... } })?]` + ); + }); }); diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts index 59254a5e4..4ace373f9 100644 --- a/packages/react/src/useAction.ts +++ b/packages/react/src/useAction.ts @@ -79,9 +79,43 @@ 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; + 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; + for (const [key, value] of Object.entries(variables)) { + if (action.paramOnlyVariables?.includes(key)) { + newVariables[key] = value; + } else { + if (key === "id") { + 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 ?? []), capitalize(action.modelApiIdentifier)], }); diff --git a/yarn.lock b/yarn.lock index 90a6bfddc..1f578bf2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" From 715664abb3edadd0283b38d80ad21c0a01fc9965 Mon Sep 17 00:00:00 2001 From: Jason Gao Date: Wed, 24 May 2023 09:55:18 -0400 Subject: [PATCH 2/2] update docs to reflect optional model api identifier --- packages/react/README.md | 10 +++------- packages/react/src/useAction.ts | 10 +++++----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 8ae260371..fb0349960 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -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", }); }} > @@ -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, }); }} > @@ -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. diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts index 4ace373f9..562baa855 100644 --- a/packages/react/src/useAction.ts +++ b/packages/react/src/useAction.ts @@ -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 ( @@ -86,6 +84,8 @@ export const useAction = < } let newVariables: Exclude; + const idVariable = Object.entries(action.variables).find(([key, value]) => key === "id" && value.type === "GadgetID"); + if (action.hasCreateOrUpdateEffect) { if ( action.modelApiIdentifier in variables && @@ -101,7 +101,7 @@ export const useAction = < if (action.paramOnlyVariables?.includes(key)) { newVariables[key] = value; } else { - if (key === "id") { + if (idVariable && key === idVariable[0]) { newVariables.id = value; } else { newVariables[action.modelApiIdentifier][key] = value;