Skip to content

Commit 5734eef

Browse files
authored
Merge pull request #845 from gadget-inc/inline-views
Add support for inline views and fix operation name on model namespaced views
2 parents 1134708 + 7f0bd94 commit 5734eef

File tree

10 files changed

+273
-13
lines changed

10 files changed

+273
-13
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"@gadget-client/app-with-no-user-model": "^1.10.0",
2828
"@gadget-client/bulk-actions-test": "^1.113.0",
2929
"@gadget-client/full-auth": "^1.9.0",
30-
"@gadget-client/js-clients-test": "1.512.0-development.2591",
30+
"@gadget-client/js-clients-test": "1.512.0-development.2601",
3131
"@gadget-client/kitchen-sink": "1.9.0-development.206",
3232
"@gadget-client/related-products-example": "^1.865.0",
3333
"@gadget-client/zxcv-deeply-nested": "^1.212.0",

packages/api-client-core/src/GadgetFunctions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ export interface ViewFunctionWithoutVariables<ResultT> {
8787
(): Promise<ResultT>;
8888
type: "computedView";
8989
operationName: string;
90+
gqlFieldName: string;
9091
namespace?: string | string[] | null;
92+
referencedTypenames?: string[];
9193
resultType: ResultT;
9294
plan(): GQLBuilderResult;
9395
}
@@ -96,7 +98,9 @@ export interface ViewFunctionWithVariables<VariablesT, ResultT> {
9698
(variables: VariablesT): Promise<ResultT>;
9799
type: "computedView";
98100
operationName: string;
101+
gqlFieldName: string;
99102
namespace?: string | string[] | null;
103+
referencedTypenames?: string[];
100104
variables: VariablesOptions;
101105
variablesType: VariablesT;
102106
resultType: ResultT;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@gadgetinc/react": minor
3+
"@gadgetinc/api-client-core": patch
4+
---
5+
6+
Add `useView` hook for executing computed views
7+
8+
This adds a new React hook for invoking computed views defined using backend `.gelly` files.

packages/react/spec/auto/shadcn-defaults/__snapshots__/shadcnClassnameSafelist.spec.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,10 @@ body {
773773
display: block;
774774
}
775775
776+
.inline {
777+
display: inline;
778+
}
779+
776780
.flex {
777781
display: flex;
778782
}

packages/react/spec/auto/shadcn-defaults/shadcnClassnameSafelist.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe("Tailwind CSS Output Snapshot", () => {
8585
"h-9",
8686
"h-fit",
8787
"hidden",
88+
"inline",
8889
"inline-flex",
8990
"inset-0",
9091
"items-center",

packages/react/spec/useView.spec.tsx

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { renderHook } from "@testing-library/react";
22
import type { IsExact } from "conditional-type-checks";
33
import { assert } from "conditional-type-checks";
4+
import type { AnyVariables } from "urql";
45
import { useView } from "../src/useView.js";
56
import type { ErrorWrapper } from "../src/utils.js";
67
import { testApi } from "./apis.js";
@@ -60,6 +61,46 @@ describe("useView", () => {
6061
}
6162
};
6263

64+
const _TestUseModelNamespacedView = () => {
65+
const [{ data }] = useView(testApi.widget.stats, { inStockOnly: false });
66+
67+
assert<IsExact<typeof data, undefined | { count: number | null }>>(true);
68+
69+
if (data) {
70+
data.count;
71+
}
72+
};
73+
74+
const _TestUseInlineViewNoVariables = () => {
75+
const [{ data, fetching, error }, refresh] = useView("{ count(todos) }");
76+
77+
assert<IsExact<typeof fetching, boolean>>(true);
78+
assert<IsExact<typeof data, undefined | unknown>>(true);
79+
assert<IsExact<typeof error, ErrorWrapper | undefined>>(true);
80+
81+
refresh();
82+
};
83+
84+
const _TestUseInlineViewWithVariables = () => {
85+
const [{ data, fetching, error }, refresh] = useView("($first: Int){ count(todos) }", { first: 10 });
86+
87+
assert<IsExact<typeof fetching, boolean>>(true);
88+
assert<IsExact<typeof data, undefined | unknown>>(true);
89+
assert<IsExact<typeof error, ErrorWrapper | undefined>>(true);
90+
91+
refresh();
92+
};
93+
94+
const _TestUseInlineViewWithVariablesAndOptions = () => {
95+
const [{ data, fetching, error }, refresh] = useView("($first: Int){ count(todos) }", { first: 10 }, { pause: true });
96+
97+
assert<IsExact<typeof fetching, boolean>>(true);
98+
assert<IsExact<typeof data, undefined | unknown>>(true);
99+
assert<IsExact<typeof error, ErrorWrapper | undefined>>(true);
100+
101+
refresh();
102+
};
103+
63104
test("can fetch a view with no variables", async () => {
64105
let query: string | undefined;
65106
const client = createMockUrqlClient({
@@ -75,6 +116,7 @@ describe("useView", () => {
75116
expect(result.current[0].error).toBeFalsy();
76117

77118
expect(client.executeQuery).toHaveBeenCalledTimes(1);
119+
expect(client.executeQuery.mock.calls[0][1].additionalTypenames).toEqual(["Widget"]);
78120

79121
expect(query).toMatchInlineSnapshot(`
80122
"query totalInStock {
@@ -173,6 +215,137 @@ describe("useView", () => {
173215
expect(result.current[0].error).toBeFalsy();
174216
});
175217

218+
test("can find namespaced view on a model", async () => {
219+
let query: string | undefined;
220+
const client = createMockUrqlClient({
221+
queryAssertions: (request) => {
222+
query = request.query.loc?.source.body;
223+
},
224+
});
225+
226+
const { result } = renderHook(() => useView(testApi.widget.stats, { inStockOnly: false }), {
227+
wrapper: MockClientWrapper(testApi, client),
228+
});
229+
230+
expect(result.current[0].data).toBeFalsy();
231+
expect(result.current[0].fetching).toBe(true);
232+
expect(result.current[0].error).toBeFalsy();
233+
234+
expect(client.executeQuery).toHaveBeenCalledTimes(1);
235+
236+
expect(query).toMatchInlineSnapshot(`
237+
"query widgetStats($inStockOnly: undefined) {
238+
widgetStats(inStockOnly: $inStockOnly)
239+
}"
240+
`);
241+
242+
client.executeQuery.pushResponse("widgetStats", {
243+
data: {
244+
widgetStats: {
245+
count: 123,
246+
},
247+
},
248+
stale: false,
249+
hasNext: false,
250+
});
251+
252+
expect(result.current[0].data!.count).toEqual(123);
253+
expect(result.current[0].fetching).toBe(false);
254+
expect(result.current[0].error).toBeFalsy();
255+
});
256+
257+
test("can fetch an inline view with no variables", async () => {
258+
let query: string | undefined;
259+
let variables: AnyVariables | undefined;
260+
261+
const client = createMockUrqlClient({
262+
queryAssertions: (request) => {
263+
query = request.query.loc?.source.body;
264+
variables = request.variables;
265+
},
266+
});
267+
268+
const { result } = renderHook(() => useView("{ count(todos) }"), { wrapper: MockClientWrapper(testApi, client) });
269+
270+
expect(result.current[0].data).toBeFalsy();
271+
expect(result.current[0].fetching).toBe(true);
272+
expect(result.current[0].error).toBeFalsy();
273+
274+
expect(client.executeQuery).toHaveBeenCalledTimes(1);
275+
276+
expect(query).toMatchInlineSnapshot(`
277+
"query InlineView($query: String!, $variables: JSONObject) {
278+
gellyView(query: $query, variables: $variables)
279+
}"
280+
`);
281+
282+
expect(variables).toEqual({
283+
query: "{ count(todos) }",
284+
variables: undefined,
285+
});
286+
287+
client.executeQuery.pushResponse("InlineView", {
288+
data: {
289+
gellyView: {
290+
count: 100,
291+
},
292+
},
293+
stale: false,
294+
hasNext: false,
295+
});
296+
297+
expect(result.current[0].data).toEqual({ count: 100 });
298+
expect(result.current[0].fetching).toBe(false);
299+
expect(result.current[0].error).toBeFalsy();
300+
});
301+
302+
test("can fetch an inline view with variables", async () => {
303+
let query: string | undefined;
304+
let variables: AnyVariables | undefined;
305+
306+
const client = createMockUrqlClient({
307+
queryAssertions: (request) => {
308+
query = request.query.loc?.source.body;
309+
variables = request.variables;
310+
},
311+
});
312+
313+
const { result } = renderHook(() => useView("($first: Int){ count(todos) }", { first: 10 }), {
314+
wrapper: MockClientWrapper(testApi, client),
315+
});
316+
317+
expect(result.current[0].data).toBeFalsy();
318+
expect(result.current[0].fetching).toBe(true);
319+
expect(result.current[0].error).toBeFalsy();
320+
321+
expect(client.executeQuery).toHaveBeenCalledTimes(1);
322+
323+
expect(query).toMatchInlineSnapshot(`
324+
"query InlineView($query: String!, $variables: JSONObject) {
325+
gellyView(query: $query, variables: $variables)
326+
}"
327+
`);
328+
329+
expect(variables).toEqual({
330+
query: "($first: Int){ count(todos) }",
331+
variables: { first: 10 },
332+
});
333+
334+
client.executeQuery.pushResponse("InlineView", {
335+
data: {
336+
gellyView: {
337+
count: 100,
338+
},
339+
},
340+
stale: false,
341+
hasNext: false,
342+
});
343+
344+
expect(result.current[0].data).toEqual({ count: 100 });
345+
expect(result.current[0].fetching).toBe(false);
346+
expect(result.current[0].error).toBeFalsy();
347+
});
348+
176349
test("returns the same data object on rerender if nothing changes about the result", async () => {
177350
const { result, rerender } = renderHook(() => useView(testApi.echo, { value: "test" }), { wrapper: MockClientWrapper(testApi) });
178351

@@ -240,4 +413,16 @@ describe("useView", () => {
240413

241414
expect(mockUrqlClient.executeQuery).toHaveBeenCalledTimes(0);
242415
});
416+
417+
test("doesn't issue a request if an inline view is paused", async () => {
418+
const { result } = renderHook(() => useView("{ count(todos) }", {}, { pause: true }), {
419+
wrapper: MockClientWrapper(testApi),
420+
});
421+
422+
expect(result.current[0].data).toBeFalsy();
423+
expect(result.current[0].fetching).toBe(false);
424+
expect(result.current[0].error).toBeFalsy();
425+
426+
expect(mockUrqlClient.executeQuery).toHaveBeenCalledTimes(0);
427+
});
243428
});

packages/react/src/auto/shadcn/GadgetShadcnTailwindSafelist.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const GadgetShadcnTailwindSafelistFromTailwind = [
7474
"h-9",
7575
"h-fit",
7676
"hidden",
77+
"inline",
7778
"inline-flex",
7879
"inset-0",
7980
"items-center",

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export * from "./useList.js";
2525
export * from "./useMaybeFindFirst.js";
2626
export * from "./useMaybeFindOne.js";
2727
export * from "./useTable.js";
28+
export * from "./useView.js";

packages/react/src/useView.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { ViewFunction, ViewFunctionWithoutVariables, ViewFunctionWithVariables, ViewResult } from "@gadgetinc/api-client-core";
1+
import type {
2+
GQLBuilderResult,
3+
ViewFunction,
4+
ViewFunctionWithoutVariables,
5+
ViewFunctionWithVariables,
6+
ViewResult,
7+
} from "@gadgetinc/api-client-core";
28
import { get, namespaceDataPath } from "@gadgetinc/api-client-core";
39
import { useMemo } from "react";
410
import { useGadgetQuery } from "./useGadgetQuery.js";
@@ -9,7 +15,7 @@ import { ErrorWrapper, useQueryArgs } from "./utils.js";
915
/**
1016
* React hook to fetch the result of a computed view from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the shape of the computed view's result.
1117
*
12-
* @param manager Gadget view function to run
18+
* @param view Gadget view function to run, like `api.leaderboard` or `api.todos.summary`
1319
* @param options options for controlling client side execution
1420
*
1521
* @example
@@ -58,34 +64,84 @@ export function useView<F extends ViewFunctionWithVariables<any, any>>(
5864
variables: F["variablesType"],
5965
options?: Omit<ReadOperationOptions, "live">
6066
): ReadHookResult<ViewResult<F>>;
67+
/**
68+
* React hook to fetch the result of an inline computed view with variables from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the shape of the computed view's result.
69+
*
70+
* Does not know the type of the result from the input string -- for type safety, use a named view defined in a .gelly file in the backend.
71+
*
72+
* @param view Gelly query string to run, like `{ count(todos) }`
73+
* @param variables variables to pass to the backend view
74+
* @param options options for controlling client side execution
75+
*
76+
* @example
77+
*
78+
* ```
79+
* export function Leaderboard() {
80+
* const [result, refresh] = useView("{ count(todos) }", {
81+
* first: 10,
82+
* });
83+
*
84+
* if (result.error) return <>Error: {result.error.toString()}</>;
85+
* if (result.fetching && !result.data) return <>Fetching...</>;
86+
* if (!result.data) return <>No data found</>;
87+
*
88+
* return <>{result.data.map((leaderboard) => <div>{leaderboard.name}: {leaderboard.score}</div>)}</>;
89+
* }
90+
* ```
91+
*/
92+
export function useView(
93+
gellyQuery: string,
94+
variables?: Record<string, unknown>,
95+
options?: Omit<ReadOperationOptions, "live">
96+
): ReadHookResult<ViewResult<ViewFunction<unknown, unknown>>>;
6197
export function useView<VariablesT, F extends ViewFunction<VariablesT, any>>(
62-
view: F,
98+
view: F | string,
6399
variablesOrOptions?: VariablesT | Omit<ReadOperationOptions, "live">,
64100
maybeOptions?: Omit<ReadOperationOptions, "live">
65101
): ReadHookResult<ViewResult<F>> {
66102
let variables: VariablesT | undefined;
67103
let options: Omit<ReadOperationOptions, "live"> | undefined;
68104

69-
if ("variables" in view) {
105+
if (typeof view == "string" || "variables" in view) {
70106
variables = variablesOrOptions as VariablesT;
71107
options = maybeOptions;
72108
} else if (variablesOrOptions) {
73109
options = variablesOrOptions as Omit<ReadOperationOptions, "live">;
74110
}
75111

76112
const memoizedVariables = useStructuralMemo(variables);
77-
const memoizedOptions = useStructuralMemo(options);
78-
const plan = useMemo(() => view.plan((memoizedVariables ?? {}) as unknown as VariablesT), [view, memoizedVariables]);
113+
const memoizedOptions = useStructuralMemo({
114+
...options,
115+
context: {
116+
...options?.context,
117+
// if the view exports the typenames it references, add them to the context so urql will refresh the view when mutations are made against these typenames
118+
additionalTypenames: [
119+
...(options?.context?.additionalTypenames ?? []),
120+
...(typeof view == "string" ? [] : view.referencedTypenames ?? []),
121+
],
122+
},
123+
});
124+
125+
const [plan, dataPath] = useMemo((): [plan: GQLBuilderResult, dataPath: string[]] => {
126+
if (typeof view == "string") {
127+
return [{ query: inlineViewQuery, variables: { query: view, variables: memoizedVariables } }, ["gellyView"]];
128+
} else {
129+
return [view.plan((memoizedVariables ?? {}) as unknown as VariablesT), namespaceDataPath([view.gqlFieldName], view.namespace)];
130+
}
131+
}, [view, memoizedVariables]);
79132

80133
const [rawResult, refresh] = useGadgetQuery(useQueryArgs(plan, memoizedOptions));
81134

82135
const result = useMemo(() => {
83-
const dataPath = namespaceDataPath([view.operationName], view.namespace);
84136
const data = get(rawResult.data, dataPath);
85137
const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause);
86138

87139
return { ...rawResult, data, error };
88-
}, [view, options?.pause, rawResult]);
140+
}, [dataPath, options?.pause, rawResult]);
89141

90142
return [result, refresh];
91143
}
144+
145+
const inlineViewQuery = `query InlineView($query: String!, $variables: JSONObject) {
146+
gellyView(query: $query, variables: $variables)
147+
}`;

0 commit comments

Comments
 (0)