diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 329a3cc..a85ac77 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -72,6 +72,6 @@ export const querySchema = Type.Object( } ); -export type RepoQueryIn = Simplify>; +export type RepoQueryIn = Static; export type RepoQuery = Required>; export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 5094abf..2473f0d 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -90,7 +90,9 @@ export abstract class ModuleApi( @@ -142,11 +144,13 @@ export class FetchPromise> implements Promise { constructor( public request: Request, - protected fetcher?: typeof fetch + protected options?: { + fetcher?: typeof fetch; + } ) {} - async execute() { - const fetcher = this.fetcher ?? fetch; + async execute(): Promise { + const fetcher = this.options?.fetcher ?? fetch; const res = await fetcher(this.request); let resBody: any; let resData: any; @@ -167,7 +171,7 @@ export class FetchPromise> implements Promise { body: resBody, data: resData, res - }; + } as T; } // biome-ignore lint/suspicious/noThenProperty: it's a promise :) @@ -197,8 +201,21 @@ export class FetchPromise> implements Promise { ); } - getKey(): string { + path(): string { const url = new URL(this.request.url); - return url.pathname + url.search; + return url.pathname; + } + + key(options?: { search: boolean }): string { + const url = new URL(this.request.url); + return options?.search !== false ? this.path() + url.search : this.path(); + } + + keyArray(options?: { search: boolean }): string[] { + const url = new URL(this.request.url); + const path = this.path().split("/"); + return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter( + Boolean + ); } } diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 21544ca..1101b21 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -80,8 +80,6 @@ export const useClient = () => { if (!context) { throw new Error("useClient must be used within a ClientProvider"); } - - console.log("useClient", context.baseUrl); return context.client; }; diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts new file mode 100644 index 0000000..ae8e345 --- /dev/null +++ b/app/src/ui/client/api/use-api.ts @@ -0,0 +1,30 @@ +import type { Api } from "Api"; +import type { FetchPromise } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; +import { useClient } from "ui/client/ClientProvider"; + +export const useApi = () => { + const client = useClient(); + return client.api; +}; + +export const useApiQuery = any = (data: Data) => Data>( + fn: (api: Api) => FetchPromise, + options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn } +) => { + const api = useApi(); + const promise = fn(api); + const refine = options?.refine ?? ((data: Data) => data); + const fetcher = () => promise.execute().then(refine); + const key = promise.key(); + + type RefinedData = RefineFn extends (data: Data) => infer R ? R : Data; + + const swr = useSWR(options?.enabled === false ? null : key, fetcher, options); + return { + ...swr, + promise, + key, + api + }; +}; diff --git a/app/src/ui/client/api/use-data.ts b/app/src/ui/client/api/use-data.ts new file mode 100644 index 0000000..46cc81a --- /dev/null +++ b/app/src/ui/client/api/use-data.ts @@ -0,0 +1,37 @@ +import type { DataApi } from "data/api/DataApi"; +import { useApi } from "ui/client"; + +type OmitFirstArg = F extends (x: any, ...args: infer P) => any + ? (...args: P) => ReturnType + : never; + +/** + * Maps all DataApi functions and omits + * the first argument "entity" for convenience + * @param entity + */ +export const useData = (entity: string) => { + const api = useApi().data; + const methods = [ + "readOne", + "readMany", + "readManyByReference", + "createOne", + "updateOne", + "deleteOne" + ] as const; + + return methods.reduce( + (acc, method) => { + // @ts-ignore + acc[method] = (...params) => { + // @ts-ignore + return api[method](entity, ...params); + }; + return acc; + }, + {} as { + [K in (typeof methods)[number]]: OmitFirstArg<(typeof api)[K]>; + } + ); +}; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts new file mode 100644 index 0000000..3169bc2 --- /dev/null +++ b/app/src/ui/client/api/use-entity.ts @@ -0,0 +1,76 @@ +import type { PrimaryFieldType } from "core"; +import { objectTransform } from "core/utils"; +import type { EntityData, RepoQuery } from "data"; +import useSWR, { type SWRConfiguration } from "swr"; +import { useApi } from "ui/client"; + +export const useEntity = < + Entity extends string, + Id extends PrimaryFieldType | undefined = undefined +>( + entity: Entity, + id?: Id +) => { + const api = useApi().data; + + return { + create: async (input: EntityData) => { + const res = await api.createOne(entity, input); + return res.data; + }, + read: async (query: Partial = {}) => { + const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); + return res.data; + }, + update: async (input: Partial, _id: PrimaryFieldType | undefined = id) => { + if (!_id) { + throw new Error("id is required"); + } + const res = await api.updateOne(entity, _id, input); + return res.data; + }, + _delete: async (_id: PrimaryFieldType | undefined = id) => { + if (!_id) { + throw new Error("id is required"); + } + + const res = await api.deleteOne(entity, _id); + return res.data; + } + }; +}; + +export const useEntityQuery = < + Entity extends string, + Id extends PrimaryFieldType | undefined = undefined +>( + entity: Entity, + id?: Id, + query?: Partial, + options?: SWRConfiguration +) => { + const api = useApi().data; + const key = [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter( + Boolean + ); + const { read, ...actions } = useEntity(entity, id) as any; + const fetcher = id ? () => read(query) : () => null; + const swr = useSWR(id ? key : null, fetcher, options); + + const mapped = objectTransform(actions, (action) => { + if (action === "read") return; + + return async (...args) => { + return swr.mutate(async () => { + const res = await action(...args); + return res; + }); + }; + }) as Omit>, "read">; + + return { + ...swr, + ...mapped, + key + }; +}; diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 9a22c49..9bbdd65 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -6,6 +6,8 @@ export { useBaseUrl } from "./ClientProvider"; -export { useApi, useApiQuery } from "./use-api"; +export * from "./api/use-api"; +export * from "./api/use-data"; +export * from "./api/use-entity"; export { useAuth } from "./schema/auth/use-auth"; export { Api } from "../../Api"; diff --git a/app/src/ui/client/use-api.ts b/app/src/ui/client/use-api.ts deleted file mode 100644 index e22de44..0000000 --- a/app/src/ui/client/use-api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Api } from "Api"; -import type { FetchPromise } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration } from "swr"; -import { useClient } from "ui/client/ClientProvider"; - -export const useApi = () => { - const client = useClient(); - return client.api; -}; - -export const useApiQuery = ( - fn: (api: Api) => FetchPromise, - options?: SWRConfiguration & { enabled?: boolean } -) => { - const api = useApi(); - const p = fn(api); - return useSWR(options?.enabled === false ? null : p.getKey(), () => p, options); -}; diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index a303ccd..5c758b1 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -1,6 +1,7 @@ import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; import SwaggerTest from "ui/routes/test/tests/swagger-test"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; +import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; import { Route, useParams } from "wouter"; import { Empty } from "../../components/display/Empty"; import { Link } from "../../components/wouter/Link"; @@ -39,7 +40,8 @@ const tests = { FlowsTest, AppShellAccordionsTest, SwaggerTest, - SWRAndAPI + SWRAndAPI, + SwrAndDataApi } as const; export default function TestRoutes() { diff --git a/app/src/ui/routes/test/tests/swr-and-api.tsx b/app/src/ui/routes/test/tests/swr-and-api.tsx index a4808ea..53c632e 100644 --- a/app/src/ui/routes/test/tests/swr-and-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -1,20 +1,45 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useApiQuery } from "ui/client"; import { Scrollable } from "ui/layouts/AppShell/AppShell"; export default function SWRAndAPI() { - const [enabled, setEnabled] = useState(false); - const { data, error, isLoading } = useApiQuery(({ data }) => data.readMany("posts"), { - enabled, + const [text, setText] = useState(""); + const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), { + refine: (data) => data.data, revalidateOnFocus: true }); + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); return ( - - {error &&
failed to load
} - {isLoading &&
loading...
} +
{JSON.stringify(r.promise.keyArray({ search: false }))}
+ {r.error &&
failed to load
} + {r.isLoading &&
loading...
} {data &&
{JSON.stringify(data, null, 2)}
} + {data && ( +
{ + e.preventDefault(); + if (!comment) return; + + await r.mutate(async () => { + const res = await r.api.data.updateOne("comments", comment.id, { + content: text + }); + return res.data; + }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ )}
); } diff --git a/app/src/ui/routes/test/tests/swr-and-data-api.tsx b/app/src/ui/routes/test/tests/swr-and-data-api.tsx new file mode 100644 index 0000000..7c2e2a6 --- /dev/null +++ b/app/src/ui/routes/test/tests/swr-and-data-api.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { useEntity, useEntityQuery } from "ui/client/api/use-entity"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; + +export default function SwrAndDataApi() { + return ( +
+ + +
+ ); +} + +function QueryDataApi() { + const [text, setText] = useState(""); + const { data, update, ...r } = useEntityQuery("comments", 1, {}); + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + return ( + +
{JSON.stringify(r.key)}
+ {r.error &&
failed to load
} + {r.isLoading &&
loading...
} + {data &&
{JSON.stringify(data, null, 2)}
} + {data && ( +
{ + e.preventDefault(); + if (!comment) return; + await update({ content: text }); + return false; + }} + > + setText(e.target.value)} /> + +
+ )} +
+ ); +} + +function DirectDataApi() { + const [data, setData] = useState(); + const { create, read, update, _delete } = useEntity("comments", 1); + + useEffect(() => { + read().then(setData); + }, []); + + return
{JSON.stringify(data, null, 2)}
; +} diff --git a/docs/mint.json b/docs/mint.json index 5be4fbe..69cde17 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -61,7 +61,7 @@ "navigation": [ { "group": "Getting Started", - "pages": ["introduction", "setup", "sdk", "cli"] + "pages": ["introduction", "setup", "sdk", "react", "cli"] }, { "group": "Modules", diff --git a/docs/react.mdx b/docs/react.mdx new file mode 100644 index 0000000..1c30c2c --- /dev/null +++ b/docs/react.mdx @@ -0,0 +1,194 @@ +--- +title: 'SDK (React)' +description: 'Use the bknd SDK for React' +--- + +For all SDK options targeting React, you always have 2 options: +1. use simple hooks which are solely based on the [API](/sdk) +2. use the query hook that makes wraps the API in [SWR](https://swr.vercel.app/) + +To use the simple hook that returns the Api, you can use: +```tsx +import { useApi } from "bknd/client"; + +export default function App() { + const api = useApi(); + // ... +} +``` + +## `useApiQuery([selector], [options])` +This hook wraps the API class in an SWR hook for convenience. You can use any API endpoint +supported, like so: +```tsx +import { useApiQuery } from "bknd/client"; + +export default function App() { + const { data, ...swr } = useApiQuery((api) => api.data.readMany("comments")); + + if (swr.error) return
Error
+ if (swr.isLoading) return
Loading...
+ + return
{JSON.stringify(data, null, 2)}
+} +``` + +### Props +* `selector: (api: Api) => FetchPromise` + + The first parameter is a selector function that provides an Api instance and expects an + endpoint function to be returned. + +* `options`: optional object that inherits from `SWRConfiguration` + + ```ts + type Options = SWRConfiguration & { + enabled?: boolean; + refine?: (data: Data) => Data | any; + } + ``` + + * `enabled`: Determines whether this hook should trigger a fetch of the data or not. + * `refine`: Optional refinement that is called after a response from the API has been + received. Useful to omit irrelevant data from the response (see example below). + +### Using mutations +To query and mutate data using this hook, you can leverage the parameters returned. In the +following example we'll also use a `refine` function as well as `revalidateOnFocus` (option from +`SWRConfiguration`) so that our data keeps updating on window focus change. + +```tsx +import { useState } from "react"; +import { useApiQuery } from "bknd/client"; + +export default function App() { + const [text, setText] = useState(""); + const { data, api, mutate, ...q } = useApiQuery( + (api) => api.data.readOne("comments", 1), + { + // filter to a subset of the response + refine: (data) => data.data, + revalidateOnFocus: true + } + ); + + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + if (q.error) return
Error
+ if (q.isLoading) return
Loading...
+ + return ( +
{ + e.preventDefault(); + if (!comment) return; + + // this will automatically revalidate the query + await mutate(async () => { + const res = await api.data.updateOne("comments", comment.id, { + content: text + }); + return res.data; + }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ ); +} +``` + +## `useEntity()` +This hook wraps the endpoints of `DataApi` and returns CRUD options as parameters: +```tsx +import { useState } from "react", +import { useEntity } from "bknd/client"; + +export default function App() { + const [data, setData] = useState(); + const { create, read, update, _delete } = useEntity("comments", 1); + + useEffect(() => { + read().then(setData); + }, []); + + return
{JSON.stringify(data, null, 2)}
+} +``` +If you only supply the entity name as string without an ID, the `read` method will fetch a list +of entities instead of a single entry. + +### Props +Following props are available when using `useEntityQuery([entity], [id?])`: +- `entity: string`: Specify the table name of the entity +- `id?: number | string`: If an id given, it will fetch a single entry, otherwise a list + +### Returned actions +The following actions are returned from this hook: +- `create: (input: object)`: Create a new entry +- `read: (query: Partial = {})`: If an id was given, +it returns a single item, otherwise a list +- `update: (input: object, id?: number | string)`: If an id was given, the id parameter is +optional. Updates the given entry partially. +- `_delete: (id?: number | string)`: If an id was given, the id parameter is +optional. Deletes the given entry. + +## `useEntityQuery()` +This hook wraps the actions from `useEntity` around `SWR`. The previous example would look like +this: +```tsx +import { useState } from "react", +import { useEntityQuery } from "bknd/client"; + +export default function App() { + const { data } = useEntityQuery("comments", 1); + + return
{JSON.stringify(data, null, 2)}
+} +``` + +### Using mutations +All actions returned from `useEntityQuery` are conveniently wrapped around the `mutate` function, +so you don't have think about this: +```tsx +import { useState } from "react"; +import { useEntityQuery } from "bknd/client"; + +export default function App() { + const [text, setText] = useState(""); + const { data, update, ...q } = useEntityQuery("comments", 1); + + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + if (q.error) return
Error
+ if (q.isLoading) return
Loading...
+ + return ( +
{ + e.preventDefault(); + if (!comment) return; + + // this will automatically revalidate the query + await update({ content: text }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ ); +} +```