Skip to content

Commit 2432079

Browse files
committed
Add support for inline views and fix operation name on model namespaced views
[no-changelog-required]
1 parent 1134708 commit 2432079

File tree

10 files changed

+257
-12
lines changed

10 files changed

+257
-12
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export interface ViewFunctionWithoutVariables<ResultT> {
8787
(): Promise<ResultT>;
8888
type: "computedView";
8989
operationName: string;
90+
gqlFieldName: string;
9091
namespace?: string | string[] | null;
9192
resultType: ResultT;
9293
plan(): GQLBuilderResult;
@@ -96,6 +97,7 @@ export interface ViewFunctionWithVariables<VariablesT, ResultT> {
9697
(variables: VariablesT): Promise<ResultT>;
9798
type: "computedView";
9899
operationName: string;
100+
gqlFieldName: string;
99101
namespace?: string | string[] | null;
100102
variables: VariablesOptions;
101103
variablesType: VariablesT;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@gadgetinc/react": minor
3+
---
4+
5+
Add `useView` hook for executing computed views
6+
7+
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: 184 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({
@@ -173,6 +214,137 @@ describe("useView", () => {
173214
expect(result.current[0].error).toBeFalsy();
174215
});
175216

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

@@ -240,4 +412,16 @@ describe("useView", () => {
240412

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

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: 52 additions & 7 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,15 +64,45 @@ 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) {
@@ -75,17 +111,26 @@ export function useView<VariablesT, F extends ViewFunction<VariablesT, any>>(
75111

76112
const memoizedVariables = useStructuralMemo(variables);
77113
const memoizedOptions = useStructuralMemo(options);
78-
const plan = useMemo(() => view.plan((memoizedVariables ?? {}) as unknown as VariablesT), [view, memoizedVariables]);
114+
const [plan, dataPath] = useMemo((): [plan: GQLBuilderResult, dataPath: string[]] => {
115+
if (typeof view == "string") {
116+
return [{ query: inlineViewQuery, variables: { query: view, variables: memoizedVariables } }, ["gellyView"]];
117+
} else {
118+
return [view.plan((memoizedVariables ?? {}) as unknown as VariablesT), namespaceDataPath([view.gqlFieldName], view.namespace)];
119+
}
120+
}, [view, memoizedVariables]);
79121

80122
const [rawResult, refresh] = useGadgetQuery(useQueryArgs(plan, memoizedOptions));
81123

82124
const result = useMemo(() => {
83-
const dataPath = namespaceDataPath([view.operationName], view.namespace);
84125
const data = get(rawResult.data, dataPath);
85126
const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause);
86127

87128
return { ...rawResult, data, error };
88-
}, [view, options?.pause, rawResult]);
129+
}, [dataPath, options?.pause, rawResult]);
89130

90131
return [result, refresh];
91132
}
133+
134+
const inlineViewQuery = `query InlineView($query: String!, $variables: JSONObject) {
135+
gellyView(query: $query, variables: $variables)
136+
}`;

pnpm-lock.yaml

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)