@protobase/ui 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,17 +5,42 @@ import { jsx } from "react/jsx-runtime";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
6
  //#region src/data/api-provider.tsx
7
7
  const ApiContext = createContext(null);
8
+ const SourceClientsContext = createContext(null);
9
+ const ResourceClientContext = createContext(null);
10
+ /** The API client: the app's own, or below `MetaGate` one that sends each resource's calls to the API that serves it. */
8
11
  const useClient = () => {
9
12
  const client = useContext(ApiContext);
10
13
  if (!client) throw new Error("useClient must be used inside ApiProvider");
11
14
  return client;
12
15
  };
13
16
  /**
17
+ * The client of the API that serves `resource`: a source's for a resource the app draws from one, the app's own
18
+ * otherwise. A source's client calls that API's functions with `invoke`, where `useClient()` calls the app's own.
19
+ */
20
+ const useResourceClient = (resource) => {
21
+ const own = useClient();
22
+ return useContext(ResourceClientContext)?.(resource) ?? own;
23
+ };
24
+ /** The client of a source's API, one per URL, with the signed-in user's token; for `MetaGate`. */
25
+ const useSourceClients = () => {
26
+ const clientOf = useContext(SourceClientsContext);
27
+ if (!clientOf) throw new Error("useSourceClients must be used inside ApiProvider");
28
+ return clientOf;
29
+ };
30
+ /** Gives what is below it `client` from `useClient()`, and the client of a resource's API from `useResourceClient()`. */
31
+ const ClientProvider = ({ client, resourceClient, children }) => /* @__PURE__ */ jsx(ApiContext.Provider, {
32
+ value: client,
33
+ children: /* @__PURE__ */ jsx(ResourceClientContext.Provider, {
34
+ value: resourceClient,
35
+ children
36
+ })
37
+ });
38
+ /**
14
39
  * Owns the query cache and the API client; refetches `/meta` whenever `X-Meta-Version` changes. The version is
15
40
  * compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
16
41
  * per-caller body, so the two never match.
17
42
  */
18
- const ApiProvider = ({ baseUrl, client: given, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }) => {
43
+ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }) => {
19
44
  const [queryClient] = useState(() => givenQueryClient ?? new QueryClient({ defaultOptions: { queries: {
20
45
  staleTime: 3e4,
21
46
  retry: false,
@@ -41,13 +66,42 @@ const ApiProvider = ({ baseUrl, client: given, fetch, queryClient: givenQueryCli
41
66
  token,
42
67
  onUnauthenticated
43
68
  ]);
69
+ const sourceClient = useMemo(() => {
70
+ const made = /* @__PURE__ */ new Map();
71
+ return (source) => {
72
+ const ready = sources?.[source.url] ?? made.get(source.url);
73
+ if (ready) return ready;
74
+ let seen;
75
+ const client = createClient({
76
+ baseUrl: source.url,
77
+ fetch,
78
+ token,
79
+ onMetaVersion: (version) => {
80
+ const previous = seen;
81
+ seen = version;
82
+ if (previous === void 0 || previous === version) return;
83
+ queryClient.invalidateQueries({ queryKey: keys.sourceMetas(source.url) }, { cancelRefetch: false });
84
+ }
85
+ });
86
+ made.set(source.url, client);
87
+ return client;
88
+ };
89
+ }, [
90
+ sources,
91
+ fetch,
92
+ token,
93
+ queryClient
94
+ ]);
44
95
  return /* @__PURE__ */ jsx(QueryClientProvider, {
45
96
  client: queryClient,
46
97
  children: /* @__PURE__ */ jsx(ApiContext.Provider, {
47
98
  value: client,
48
- children
99
+ children: /* @__PURE__ */ jsx(SourceClientsContext.Provider, {
100
+ value: sourceClient,
101
+ children
102
+ })
49
103
  })
50
104
  });
51
105
  };
52
106
  //#endregion
53
- export { ApiProvider, useClient };
107
+ export { ApiProvider, ClientProvider, useClient, useResourceClient, useSourceClients };
@@ -0,0 +1,6 @@
1
+ import { createContext } from "react";
2
+ //#region src/data/meta-context.ts
3
+ /** The app's `/meta` with the resources of its sources, as `MetaGate` gives it to everything below it. */
4
+ const MetaContext = createContext(null);
5
+ //#endregion
6
+ export { MetaContext };
@@ -1,4 +1,4 @@
1
- import { PluginRef, ResourcePermissions } from "@protobase/client";
1
+ import { PluginRef, ResourcePermissions, SourceRef } from "@protobase/client";
2
2
  import { BottomBarModel, ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
3
3
  import { PageModel } from "@protobase/layout";
4
4
  //#region src/data/meta-data.d.ts
@@ -22,5 +22,19 @@ export type MetaData = {
22
22
  };
23
23
  /** The plugins the app loads for the user (see `PluginGate`). */
24
24
  plugins?: PluginRef[];
25
+ /** The other APIs the app draws resources from for the user, and how far their `/meta` has loaded (see `withSources`). */
26
+ sources?: SourceStatus[];
27
+ };
28
+ /**
29
+ * A source of resources and its `/meta`: `ready` once its resources are among the app's, `error` when it could not be
30
+ * read, the reason in `error`.
31
+ */
32
+ export type SourceStatus = {
33
+ source: SourceRef;
34
+ state: 'loading' | 'ready';
35
+ } | {
36
+ source: SourceRef;
37
+ state: 'error';
38
+ error: Error;
25
39
  };
26
40
  //#endregion
@@ -1,6 +1,14 @@
1
1
  //#region src/data/query-keys.ts
2
2
  const keys = {
3
3
  meta: ["meta"],
4
+ /** A source's `/meta`, as far as the app draws it: its URL, and the resources the app lists for it. */
5
+ sourceMeta: (url, resources) => [
6
+ "source-meta",
7
+ url,
8
+ resources.join(",")
9
+ ],
10
+ /** Every `/meta` query of the source at `url`. */
11
+ sourceMetas: (url) => ["source-meta", url],
4
12
  list: (resource, filter, orderBy, pageSize, startToken) => [
5
13
  "list",
6
14
  resource,
@@ -1,8 +1,10 @@
1
1
  import { MetaData } from "./meta-data.js";
2
+ import { UseQueryResult } from "@tanstack/react-query";
2
3
  //#region src/data/use-meta.d.ts
3
4
  /**
4
5
  * The resource and view models. Refetches on window focus with `If-None-Match`, so an unchanged
5
- * `/meta` costs a 304, and the cached object keeps its identity.
6
+ * `/meta` costs a 304, and the cached object keeps its identity. Below the app's `MetaGate`, `data` holds the resources
7
+ * of its sources too, as every page of the app reads them.
6
8
  */
7
- export declare const useMeta: () => import("@tanstack/react-query").UseQueryResult<MetaData, Error>;
9
+ export declare const useMeta: () => UseQueryResult<MetaData>;
8
10
  //#endregion
@@ -1,15 +1,19 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
+ import { MetaContext } from "./meta-context.js";
4
+ import { useContext, useMemo } from "react";
3
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
6
  //#region src/data/use-meta.ts
5
7
  /**
6
8
  * The resource and view models. Refetches on window focus with `If-None-Match`, so an unchanged
7
- * `/meta` costs a 304, and the cached object keeps its identity.
9
+ * `/meta` costs a 304, and the cached object keeps its identity. Below the app's `MetaGate`, `data` holds the resources
10
+ * of its sources too, as every page of the app reads them.
8
11
  */
9
12
  const useMeta = () => {
10
13
  const client = useClient();
11
14
  const queryClient = useQueryClient();
12
- return useQuery({
15
+ const merged = useContext(MetaContext);
16
+ const query = useQuery({
13
17
  queryKey: keys.meta,
14
18
  staleTime: 0,
15
19
  refetchOnWindowFocus: "always",
@@ -30,10 +34,18 @@ const useMeta = () => {
30
34
  ...result.meta.bottomBar && { bottomBar: result.meta.bottomBar },
31
35
  ...result.meta.assistant && { assistant: result.meta.assistant },
32
36
  ...result.meta.runtime && { runtime: result.meta.runtime },
33
- ...result.meta.plugins && { plugins: result.meta.plugins }
37
+ ...result.meta.plugins && { plugins: result.meta.plugins },
38
+ ...result.meta.sources && { sources: result.meta.sources.map((source) => ({
39
+ source,
40
+ state: "loading"
41
+ })) }
34
42
  };
35
43
  }
36
44
  });
45
+ return useMemo(() => merged && query.data ? {
46
+ ...query,
47
+ data: merged
48
+ } : query, [merged, query]);
37
49
  };
38
50
  //#endregion
39
51
  export { useMeta };
@@ -0,0 +1,61 @@
1
+ import { keys } from "./query-keys.js";
2
+ import { useMemo } from "react";
3
+ import { sourceMeta } from "@protobase/client";
4
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
5
+ //#region src/data/use-source-metas.ts
6
+ /** How long the app waits for a source's `/meta` before it shows the source's resources as unavailable. */
7
+ const sourceTimeoutMs = 1e4;
8
+ const combine = (results) => results.map((result) => ({
9
+ data: result.data?.meta,
10
+ error: result.error
11
+ }));
12
+ /**
13
+ * Each source's `/meta`, read with the user's token from that API and scoped to the resources the app draws from it
14
+ * (`sourceMeta`). Like the app's own, it is read again on window focus with `If-None-Match`, and a source that cannot be
15
+ * read is an `error` without holding up the others or the app.
16
+ */
17
+ const useSourceMetas = (sources, clientOf) => {
18
+ const queryClient = useQueryClient();
19
+ const results = useQueries({
20
+ queries: sources.map((source) => {
21
+ const queryKey = keys.sourceMeta(source.url, source.resources);
22
+ return {
23
+ queryKey,
24
+ staleTime: 0,
25
+ refetchOnWindowFocus: "always",
26
+ queryFn: async () => {
27
+ const previous = queryClient.getQueryData(queryKey);
28
+ const result = await clientOf(source).meta(previous?.etag, { signal: AbortSignal.timeout(sourceTimeoutMs) });
29
+ if (result.status === "unchanged") {
30
+ if (!previous) throw new Error(`${source.label} answered 304 to a request without If-None-Match`);
31
+ return previous;
32
+ }
33
+ return {
34
+ etag: result.etag,
35
+ meta: sourceMeta(result.meta, source)
36
+ };
37
+ }
38
+ };
39
+ }),
40
+ combine
41
+ });
42
+ return useMemo(() => sources.map((source, index) => {
43
+ const { data, error } = results[index];
44
+ if (data) return {
45
+ source,
46
+ state: "ready",
47
+ meta: data
48
+ };
49
+ if (error) return {
50
+ source,
51
+ state: "error",
52
+ error
53
+ };
54
+ return {
55
+ source,
56
+ state: "loading"
57
+ };
58
+ }), [sources, results]);
59
+ };
60
+ //#endregion
61
+ export { sourceTimeoutMs, useSourceMetas };
@@ -0,0 +1,38 @@
1
+ //#region src/data/with-sources.ts
2
+ /**
3
+ * The app's `/meta` with the resources it draws from its sources: those of every source that is ready, with their views
4
+ * and permissions, as if they were the app's own, and how far each source has loaded in `sources`. A source that is
5
+ * loading or cannot be read adds no resources; the app shows its resources as unavailable (see `sourceStatusOf`).
6
+ */
7
+ const withSources = (meta, results) => {
8
+ if (results.length === 0) return meta;
9
+ const ready = results.flatMap((result) => result.state === "ready" ? [result.meta] : []);
10
+ const sources = results.map((result) => result.state === "error" ? {
11
+ source: result.source,
12
+ state: "error",
13
+ error: result.error
14
+ } : {
15
+ source: result.source,
16
+ state: result.state
17
+ });
18
+ return {
19
+ ...meta,
20
+ resources: {
21
+ ...meta.resources,
22
+ ...Object.fromEntries(ready.flatMap((part) => part.resources.map((model) => [model.name, model])))
23
+ },
24
+ views: {
25
+ ...meta.views,
26
+ ...Object.fromEntries(ready.flatMap((part) => part.views.map((view) => [view.resource, view])))
27
+ },
28
+ permissions: {
29
+ ...meta.permissions,
30
+ ...Object.assign({}, ...ready.map((part) => part.permissions))
31
+ },
32
+ sources
33
+ };
34
+ };
35
+ /** The source the app draws `resource` from, with how far it has loaded; `undefined` for a resource of the app's own. */
36
+ const sourceStatusOf = (meta, resource) => meta.sources?.find((status) => status.source.resources.includes(resource));
37
+ //#endregion
38
+ export { sourceStatusOf, withSources };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { ActionContext, ActionHandler, ProjectUi, definePlugin, defineUi } from
3
3
  import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
4
4
  import { App, AppProps } from "./app/app.js";
5
5
  import "./app/index.js";
6
- import { ApiProvider, ApiProviderProps, useClient } from "./data/api-provider.js";
6
+ import { ApiProvider, ApiProviderProps, useClient, useResourceClient } from "./data/api-provider.js";
7
7
  import { MetaData } from "./data/meta-data.js";
8
8
  import { useMeta } from "./data/use-meta.js";
9
9
  import { useList } from "./data/use-list.js";
@@ -39,4 +39,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
39
39
  import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
40
40
  import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
41
41
  import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
42
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useSeries, useUpdateRecord };
42
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApiProvider, useClient } from "./data/api-provider.js";
1
+ import { ApiProvider, useClient, useResourceClient } from "./data/api-provider.js";
2
2
  import { useApiToken } from "./auth/use-api-token.js";
3
3
  import { useRecord } from "./data/use-record.js";
4
4
  import { ChatMessage } from "./chat/chat-message.js";
@@ -30,4 +30,4 @@ import { useList } from "./data/use-list.js";
30
30
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
31
31
  import { App } from "./app/app.js";
32
32
  import { RecordLink } from "./app/displays/record-link.js";
33
- export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useSeries, useUpdateRecord };
33
+ export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.2";
2
+ var version = "0.12.4";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };