@protobase/ui 0.12.1 → 0.12.3

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.
Files changed (35) hide show
  1. package/dist/app/app.d.ts +2 -0
  2. package/dist/app/app.js +6 -2
  3. package/dist/app/current-address.js +5 -4
  4. package/dist/app/displays/cards-display.js +1 -1
  5. package/dist/app/displays/display-controls.js +1 -1
  6. package/dist/app/displays/list-displays.js +11 -1
  7. package/dist/app/list-page.js +1 -1
  8. package/dist/app/meta-gate.js +47 -6
  9. package/dist/app/nav-from-meta.js +20 -1
  10. package/dist/app/pages/project-ui.d.ts +5 -1
  11. package/dist/app/pages/use-run-action.js +3 -2
  12. package/dist/app/source-notice.js +47 -0
  13. package/dist/app/use-app-address.js +7 -1
  14. package/dist/app-shell/sidebar-item.js +7 -1
  15. package/dist/data/api-provider.d.ts +46 -3
  16. package/dist/data/api-provider.js +57 -3
  17. package/dist/data/meta-context.js +6 -0
  18. package/dist/data/meta-data.d.ts +15 -1
  19. package/dist/data/query-keys.js +8 -0
  20. package/dist/data/use-meta.d.ts +4 -2
  21. package/dist/data/use-meta.js +15 -3
  22. package/dist/data/use-source-metas.js +61 -0
  23. package/dist/data/with-sources.js +38 -0
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +2 -2
  26. package/dist/lib/use-media-query.js +3 -1
  27. package/dist/package.js +1 -1
  28. package/dist/shell/assets/app-Cqr5Ze7R.js +65 -0
  29. package/dist/shell/assets/{index-Bro4QDEX.js → index-ntYv19kQ.js} +1 -1
  30. package/dist/shell/assets/shared-protobase-ui-eH-UjL47.js +1 -0
  31. package/dist/shell/index.html +3 -3
  32. package/dist/shell/protobase.shell.json +1 -1
  33. package/package.json +4 -4
  34. package/dist/shell/assets/app-BKdW5nhg.js +0 -65
  35. package/dist/shell/assets/shared-protobase-ui-PjtR6P4n.js +0 -1
package/dist/app/app.d.ts CHANGED
@@ -9,6 +9,8 @@ export type AppProps = {
9
9
  baseUrl?: string;
10
10
  /** A ready-made client, for tests. */
11
11
  client?: Client;
12
+ /** Ready-made clients of the sources `/meta` names, by their URL, for stories and tests. */
13
+ sources?: Record<string, Client>;
12
14
  /** Sign-in state for the app; made from the page's origin when omitted. */
13
15
  auth?: AuthSession;
14
16
  /** Path prefix the app is mounted under, for example `/admin`. */
package/dist/app/app.js CHANGED
@@ -15,6 +15,7 @@ import { useProjectUi } from "./pages/project-ui.js";
15
15
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
16
16
  import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
17
17
  import { breadcrumbFor } from "./breadcrumb-for.js";
18
+ import { sourceStatusOf } from "../data/with-sources.js";
18
19
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
19
20
  import { Router, useRouter } from "./router.js";
20
21
  import { useAppAddress } from "./use-app-address.js";
@@ -34,6 +35,7 @@ import { ComposedPage } from "./pages/composed-page.js";
34
35
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
35
36
  import { PluginNotice } from "../plugins/plugin-notice.js";
36
37
  import { RecordPage } from "./record-page.js";
38
+ import { SourceNotice } from "./source-notice.js";
37
39
  import { useGlobalSearch } from "./search/use-global-search.js";
38
40
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
39
41
  import { StaffBanner } from "./staff-banner.js";
@@ -78,6 +80,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
78
80
  go
79
81
  ]);
80
82
  const resource = address?.kind === "page" ? void 0 : address?.resource;
83
+ const drawnFrom = resource ? sourceStatusOf(meta, resource) : void 0;
81
84
  const view = resource ? meta.views[resource] : void 0;
82
85
  const group = groups.find((entry) => entry.items.some((item) => item.id === place));
83
86
  const model = resource ? meta.resources[resource] : void 0;
@@ -146,7 +149,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
146
149
  address.page,
147
150
  "."
148
151
  ]
149
- }) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, { resource: address.resource }, `${address.resource}/new`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
152
+ }) : drawnFrom && drawnFrom.state !== "ready" ? /* @__PURE__ */ jsx(SourceNotice, { status: drawnFrom }) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, { resource: address.resource }, `${address.resource}/new`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
150
153
  resource: address.resource,
151
154
  recordKey: address.key
152
155
  }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
@@ -220,11 +223,12 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
220
223
  ]
221
224
  });
222
225
  };
223
- const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime, pluginLoader }) => {
226
+ const Authenticated = ({ baseUrl, client, sources, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime, pluginLoader }) => {
224
227
  const { session, markSignedOut } = useAuth();
225
228
  return /* @__PURE__ */ jsx(ApiProvider, {
226
229
  baseUrl,
227
230
  client,
231
+ sources,
228
232
  token: session.token,
229
233
  onUnauthenticated: markSignedOut,
230
234
  children: /* @__PURE__ */ jsx(Router, {
@@ -1,9 +1,10 @@
1
+ import { defaultDisplay } from "./displays/list-displays.js";
1
2
  import { defaultOrderBy } from "./list-order.js";
2
- import { encodeKey, tableDisplay } from "@protobase/schema";
3
+ import { encodeKey } from "@protobase/schema";
3
4
  //#region src/app/current-address.ts
4
- /** What a list shows when its address names nothing: the view's default display, the list's sort, the filters in a bar. */
5
- const listDefaults = (model, view) => ({
6
- display: view?.list?.default ?? tableDisplay,
5
+ /** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
6
+ const listDefaults = (model, view, xs = false) => ({
7
+ display: defaultDisplay(view, xs),
7
8
  orderBy: model ? defaultOrderBy(model, view) : "",
8
9
  filterLayout: "bar"
9
10
  });
@@ -1,6 +1,7 @@
1
1
  import { Badge } from "../../primitives/badge.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { useProjectUi } from "../pages/project-ui.js";
4
+ import { cardFields } from "./list-displays.js";
4
5
  import { AppLink } from "../use-app-address.js";
5
6
  import { displayValue } from "../../live/display-value.js";
6
7
  import { shownWidget } from "../field-types/shown-widget.js";
@@ -9,7 +10,6 @@ import { WidgetPart } from "../field-types/widget-part.js";
9
10
  import { Skeleton } from "../../primitives/skeleton.js";
10
11
  import { toneFor } from "../../live/columns-from-model.js";
11
12
  import { DisplayFooter, DisplayState } from "./display-frame.js";
12
- import { cardFields } from "./list-displays.js";
13
13
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
14
  //#region src/app/displays/cards-display.tsx
15
15
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
@@ -1,6 +1,6 @@
1
1
  import { humanize } from "../../live/naming.js";
2
- import { parseSort } from "../list-order.js";
3
2
  import { displayChoices, sortFields } from "./list-displays.js";
3
+ import { parseSort } from "../list-order.js";
4
4
  import { Segmented } from "../../primitives/segmented.js";
5
5
  import { Calendar, Columns3, LayoutGrid, List, SquareKanban, Table2 } from "lucide-react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -3,6 +3,16 @@ import { humanize } from "../../live/naming.js";
3
3
  import { encodeKey, tableDisplay } from "@protobase/schema";
4
4
  //#region src/app/displays/list-displays.ts
5
5
  const displaysOf = (view) => view?.list?.displays ?? [];
6
+ /**
7
+ * The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
8
+ * in one: the view's default when that is a cards display, else the first. Otherwise the view's default, or the table.
9
+ */
10
+ const defaultDisplay = (view, xs) => {
11
+ const fallback = view?.list?.default ?? tableDisplay;
12
+ if (!xs) return fallback;
13
+ const cards = displaysOf(view).filter((display) => display.kind === "cards");
14
+ return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
15
+ };
6
16
  /** The display a list shows for the address's `name`: `undefined` for the table, and the view's default for a name it does not have. */
7
17
  const shownDisplay = (view, name) => {
8
18
  if (name === tableDisplay) return void 0;
@@ -59,4 +69,4 @@ const displayRow = (model, row) => {
59
69
  };
60
70
  };
61
71
  //#endregion
62
- export { cardFields, displayChoices, displayRow, displaysOf, loadedFields, shownDisplay, shownFields, sortFields };
72
+ export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, loadedFields, shownDisplay, shownFields, sortFields };
@@ -5,6 +5,7 @@ import { Button } from "../primitives/button.js";
5
5
  import { recordKey, recordTitleField } from "../live/model-helpers.js";
6
6
  import { humanize } from "../live/naming.js";
7
7
  import { useProjectUi } from "./pages/project-ui.js";
8
+ import { loadedFields, shownDisplay } from "./displays/list-displays.js";
8
9
  import { parseSort, sortToOrderBy } from "./list-order.js";
9
10
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
11
  import { useAppAddress } from "./use-app-address.js";
@@ -23,7 +24,6 @@ import { useLiveRows } from "../live/use-live-rows.js";
23
24
  import { Sheet } from "../primitives/sheet.js";
24
25
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
25
26
  import { useRestore } from "./delete/use-restore.js";
26
- import { loadedFields, shownDisplay } from "./displays/list-displays.js";
27
27
  import { CardsDisplay } from "./displays/cards-display.js";
28
28
  import { CustomDisplay } from "./displays/custom-display.js";
29
29
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
@@ -1,11 +1,14 @@
1
1
  import { Spinner } from "../primitives/spinner.js";
2
+ import { ClientProvider, useClient, useSourceClients } from "../data/api-provider.js";
3
+ import { MetaContext } from "../data/meta-context.js";
2
4
  import { allowEverything } from "../data/meta-data.js";
3
5
  import { useMeta } from "../data/use-meta.js";
4
- import { createContext, useContext } from "react";
5
- import { isApiError } from "@protobase/client";
6
+ import { useSourceMetas } from "../data/use-source-metas.js";
7
+ import { withSources } from "../data/with-sources.js";
8
+ import { useContext, useMemo } from "react";
9
+ import { isApiError, routedClient } from "@protobase/client";
6
10
  import { jsx, jsxs } from "react/jsx-runtime";
7
11
  //#region src/app/meta-gate.tsx
8
- const MetaContext = createContext(null);
9
12
  const useAdminMeta = () => {
10
13
  const meta = useContext(MetaContext);
11
14
  if (!meta) throw new Error("useAdminMeta must be used inside MetaGate");
@@ -24,7 +27,45 @@ const Notice = ({ title, children }) => /* @__PURE__ */ jsx("div", {
24
27
  })]
25
28
  })
26
29
  });
27
- /** Renders its children once `/meta` has loaded. */
30
+ /**
31
+ * Draws the resources of the app's sources as its own: reads each source's `/meta`, merges what is ready into the app's,
32
+ * and gives what is below a client that sends each resource's calls to the API that serves it. The app does not wait
33
+ * for a source; its resources join when it has loaded.
34
+ */
35
+ const WithSources = ({ meta, children }) => {
36
+ const own = useClient();
37
+ const clientOf = useSourceClients();
38
+ const declared = useMemo(() => (meta.sources ?? []).map((status) => status.source), [meta.sources]);
39
+ const results = useSourceMetas(declared, clientOf);
40
+ const merged = useMemo(() => withSources(meta, results), [meta, results]);
41
+ const routing = useMemo(() => {
42
+ const byResource = new Map(declared.flatMap((source) => source.resources.map((resource) => [resource, source])));
43
+ const resourceClient = (resource) => {
44
+ const source = byResource.get(resource);
45
+ return source && clientOf(source);
46
+ };
47
+ return {
48
+ client: routedClient(own, resourceClient),
49
+ resourceClient
50
+ };
51
+ }, [
52
+ declared,
53
+ own,
54
+ clientOf
55
+ ]);
56
+ return /* @__PURE__ */ jsx(ClientProvider, {
57
+ client: routing.client,
58
+ resourceClient: routing.resourceClient,
59
+ children: /* @__PURE__ */ jsx(MetaContext.Provider, {
60
+ value: merged,
61
+ children
62
+ })
63
+ });
64
+ };
65
+ /**
66
+ * Renders its children once `/meta` has loaded, with the resources of the app's sources as they load. The sources'
67
+ * layer is always there, also without sources, so `/meta` gaining or losing them never draws the app anew.
68
+ */
28
69
  const MetaGate = ({ children }) => {
29
70
  const meta = useMeta();
30
71
  if (meta.isPending) return /* @__PURE__ */ jsxs("div", {
@@ -35,8 +76,8 @@ const MetaGate = ({ children }) => {
35
76
  title: "Cannot reach the API",
36
77
  children: isApiError(meta.error) ? meta.error.message : "The server did not answer. Is it running?"
37
78
  });
38
- return /* @__PURE__ */ jsx(MetaContext.Provider, {
39
- value: meta.data,
79
+ return /* @__PURE__ */ jsx(WithSources, {
80
+ meta: meta.data,
40
81
  children
41
82
  });
42
83
  };
@@ -16,7 +16,8 @@ const groupLabels = {
16
16
  /**
17
17
  * One sidebar group per `nav.group` (default: the database schema, and "Pages" for pages); `nav.hidden` and a place in
18
18
  * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
19
- * collapsible. Groups with pages come first, so a dashboard leads the sidebar.
19
+ * collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
20
+ * read are listed under its label, each with the problem.
20
21
  */
21
22
  const navFromMeta = (meta, basePath = "", recent = {}) => {
22
23
  const groups = /* @__PURE__ */ new Map();
@@ -63,6 +64,24 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
63
64
  groups.set(groupName, group);
64
65
  orders.set(model.name, view.nav?.order ?? Number.MAX_SAFE_INTEGER);
65
66
  }
67
+ for (const status of meta.sources ?? []) {
68
+ if (status.state !== "error") continue;
69
+ const group = groups.get(status.source.label) ?? {
70
+ label: status.source.label,
71
+ items: []
72
+ };
73
+ for (const resource of status.source.resources) group.items.push({
74
+ id: resource,
75
+ label: humanize(resource),
76
+ icon: iconFor(resource),
77
+ href: appPath({
78
+ kind: "list",
79
+ resource
80
+ }, basePath),
81
+ problem: `Cannot reach ${status.source.label}`
82
+ });
83
+ groups.set(status.source.label, group);
84
+ }
66
85
  return [...groups.values()].map((group) => ({
67
86
  ...group,
68
87
  items: [...group.items].sort((a, b) => (orders.get(a.id) ?? 0) - (orders.get(b.id) ?? 0))
@@ -3,7 +3,11 @@ import { ComponentType } from "react";
3
3
  import { Client } from "@protobase/client";
4
4
  import { AppAddress } from "@protobase/schema";
5
5
  //#region src/app/pages/project-ui.d.ts
6
- /** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API, and a way to move. */
6
+ /**
7
+ * What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API, and a way to move.
8
+ * `client` is the client of the API that serves the resource: the app's own, or the source's for a resource drawn from
9
+ * another API, whose functions `invoke` then calls.
10
+ */
7
11
  export type ActionContext = {
8
12
  resource: string;
9
13
  record?: Record<string, unknown>;
@@ -1,4 +1,4 @@
1
- import { useClient } from "../../data/api-provider.js";
1
+ import { useClient, useResourceClient } from "../../data/api-provider.js";
2
2
  import { useToast } from "../../toasts/toast-provider.js";
3
3
  import { useProjectUi } from "./project-ui.js";
4
4
  import { useAdminMeta, usePermissions } from "../meta-gate.js";
@@ -20,6 +20,7 @@ const useActionRunner = (resource, scope, afterRun) => {
20
20
  const { actions: handlers } = useProjectUi();
21
21
  const permissions = usePermissions(resource);
22
22
  const client = useClient();
23
+ const handlerClient = useResourceClient(resource);
23
24
  const toast = useToast();
24
25
  const refresh = useRefreshResource();
25
26
  const { go } = useAppAddress();
@@ -46,7 +47,7 @@ const useActionRunner = (resource, scope, afterRun) => {
46
47
  record: scope?.record,
47
48
  recordKey: scope?.key,
48
49
  etag: scope?.etag,
49
- client,
50
+ client: handlerClient,
50
51
  navigate: go
51
52
  });
52
53
  await refresh(resource);
@@ -0,0 +1,47 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { keys } from "../data/query-keys.js";
4
+ import { isApiError } from "@protobase/client";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { useQueryClient } from "@tanstack/react-query";
7
+ //#region src/app/source-notice.tsx
8
+ const reason = (error) => {
9
+ if (!isApiError(error)) return "It did not answer.";
10
+ if (error.status === 401 || error.status === 403) return `It did not accept your sign-in: ${error.message}`;
11
+ return error.message;
12
+ };
13
+ /** Where a resource drawn from another API would be while that API's `/meta` loads, or when it cannot be read. */
14
+ const SourceNotice = ({ status }) => {
15
+ const queryClient = useQueryClient();
16
+ if (status.state !== "error") return /* @__PURE__ */ jsxs("div", {
17
+ className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
18
+ children: [
19
+ /* @__PURE__ */ jsx(Spinner, {}),
20
+ " Loading from ",
21
+ status.source.label
22
+ ]
23
+ });
24
+ return /* @__PURE__ */ jsx("div", {
25
+ className: "flex h-full min-h-48 items-center justify-center p-8",
26
+ children: /* @__PURE__ */ jsxs("div", {
27
+ className: "max-w-md text-center",
28
+ children: [
29
+ /* @__PURE__ */ jsxs("h1", {
30
+ className: "text-[15px] font-semibold",
31
+ children: ["Cannot reach ", status.source.label]
32
+ }),
33
+ /* @__PURE__ */ jsxs("p", {
34
+ className: "mt-1 text-[13px] text-muted-foreground",
35
+ children: [reason(status.error), " The rest of the app works as before."]
36
+ }),
37
+ /* @__PURE__ */ jsx(Button, {
38
+ className: "mt-3",
39
+ onClick: () => void queryClient.refetchQueries({ queryKey: keys.sourceMetas(status.source.url) }),
40
+ children: "Try again"
41
+ })
42
+ ]
43
+ })
44
+ });
45
+ };
46
+ //#endregion
47
+ export { SourceNotice };
@@ -1,3 +1,4 @@
1
+ import { useIsXs } from "../lib/use-media-query.js";
1
2
  import { changedList, currentAddress, listDefaults } from "./current-address.js";
2
3
  import { useAdminMeta } from "./meta-gate.js";
3
4
  import { Link, useRouter } from "./router.js";
@@ -13,6 +14,7 @@ import { appPath, readAppPath } from "@protobase/schema";
13
14
  const useAppAddress = () => {
14
15
  const { path, params, basePath, navigate } = useRouter();
15
16
  const { pages, resources, views } = useAdminMeta();
17
+ const xs = useIsXs();
16
18
  const pageNames = useMemo(() => new Set(Object.keys(pages)), [pages]);
17
19
  const query = params.toString();
18
20
  const read = useMemo(() => readAppPath(query ? `${path}?${query}` : path, pageNames), [
@@ -20,7 +22,11 @@ const useAppAddress = () => {
20
22
  query,
21
23
  pageNames
22
24
  ]);
23
- const defaults = useCallback((resource) => listDefaults(resources[resource], views[resource]), [resources, views]);
25
+ const defaults = useCallback((resource) => listDefaults(resources[resource], views[resource], xs), [
26
+ resources,
27
+ views,
28
+ xs
29
+ ]);
24
30
  const address = useMemo(() => currentAddress(read, defaults), [read, defaults]);
25
31
  const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
26
32
  return {
@@ -1,6 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Tooltip } from "../primitives/tooltip.js";
3
3
  import { isTextMode, showsLabelInline } from "./sidebar-mode.js";
4
+ import { TriangleAlert } from "lucide-react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/app-shell/sidebar-item.tsx
6
7
  const layout = {
@@ -19,8 +20,9 @@ const SidebarItem = ({ item, mode, active, onNavigate, className }) => {
19
20
  onNavigate(item.href ?? `#${item.id}`);
20
21
  }),
21
22
  href: item.href ?? `#${item.id}`,
22
- "aria-label": item.label,
23
+ "aria-label": item.problem ? `${item.label}: ${item.problem}` : item.label,
23
24
  "aria-current": active ? "page" : void 0,
25
+ title: item.problem,
24
26
  className: cn("flex shrink-0 items-center rounded-md font-medium transition-colors", layout[mode], active ? "bg-primary-soft text-primary-text" : "text-muted-foreground hover:bg-muted hover:text-foreground", className),
25
27
  children: [
26
28
  /* @__PURE__ */ jsx(Icon, {
@@ -34,6 +36,10 @@ const SidebarItem = ({ item, mode, active, onNavigate, className }) => {
34
36
  isTextMode(mode) && item.count && /* @__PURE__ */ jsx("span", {
35
37
  className: "ml-auto rounded bg-muted px-1.5 text-[11px] tabular-nums text-muted-foreground",
36
38
  children: item.count
39
+ }),
40
+ isTextMode(mode) && item.problem && /* @__PURE__ */ jsx(TriangleAlert, {
41
+ "aria-hidden": true,
42
+ className: "ml-auto size-3.5 shrink-0 text-warning-text"
37
43
  })
38
44
  ]
39
45
  });
@@ -1,7 +1,8 @@
1
1
  import { ReactNode } from "react";
2
- import { Client, ClientOptions } from "@protobase/client";
2
+ import { Client, ClientOptions, SourceRef } from "@protobase/client";
3
3
  import { QueryClient } from "@tanstack/react-query";
4
4
  //#region src/data/api-provider.d.ts
5
+ /** The API client: the app's own, or below `MetaGate` one that sends each resource's calls to the API that serves it. */
5
6
  export declare const useClient: () => {
6
7
  list: <R extends import("@protobase/client").ResourceRef>(resource: R, params?: import("@protobase/client").ListParams) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
7
8
  search: <R extends import("@protobase/client").ResourceRef>(resource: R, params?: import("@protobase/client").ListParams) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
@@ -30,7 +31,47 @@ export declare const useClient: () => {
30
31
  seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
31
32
  position: number;
32
33
  }) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
33
- meta: (etag?: string) => Promise<import("@protobase/client").MetaResult>;
34
+ meta: (etag?: string, { signal }?: {
35
+ signal?: AbortSignal;
36
+ }) => Promise<import("@protobase/client").MetaResult>;
37
+ invoke: <T = unknown>(name: string, { method, body, path, query, headers }?: import("@protobase/client").InvokeOptions) => Promise<T>;
38
+ upload: (resource: import("@protobase/client").ResourceRef, field: string, file: Blob, { onProgress, signal }?: import("@protobase/client").UploadOptions) => Promise<import("@protobase/client").UploadResult>;
39
+ };
40
+ /**
41
+ * The client of the API that serves `resource`: a source's for a resource the app draws from one, the app's own
42
+ * otherwise. A source's client calls that API's functions with `invoke`, where `useClient()` calls the app's own.
43
+ */
44
+ export declare const useResourceClient: (resource: string) => {
45
+ list: <R extends import("@protobase/client").ResourceRef>(resource: R, params?: import("@protobase/client").ListParams) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
46
+ search: <R extends import("@protobase/client").ResourceRef>(resource: R, params?: import("@protobase/client").ListParams) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
47
+ get: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
48
+ create: <R extends import("@protobase/client").ResourceRef>(resource: R, body: Partial<import("@protobase/client").RecordOf<R>>) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>> & {
49
+ location: string;
50
+ }>;
51
+ update: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], patch: Partial<import("@protobase/client").RecordOf<R>>, etag: string) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
52
+ remove: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { etag }?: {
53
+ etag?: string;
54
+ }) => Promise<void>;
55
+ undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
56
+ reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
57
+ batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
58
+ facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
59
+ filter?: import("@protobase/client").FilterInput;
60
+ limit?: number;
61
+ }) => Promise<import("@protobase/client").Facet[]>;
62
+ series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
63
+ filter?: import("@protobase/client").FilterInput;
64
+ }) => Promise<import("@protobase/client").Series>;
65
+ histogram: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
66
+ filter?: import("@protobase/client").FilterInput;
67
+ buckets?: number;
68
+ }) => Promise<import("@protobase/client").Histogram>;
69
+ seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
70
+ position: number;
71
+ }) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
72
+ meta: (etag?: string, { signal }?: {
73
+ signal?: AbortSignal;
74
+ }) => Promise<import("@protobase/client").MetaResult>;
34
75
  invoke: <T = unknown>(name: string, { method, body, path, query, headers }?: import("@protobase/client").InvokeOptions) => Promise<T>;
35
76
  upload: (resource: import("@protobase/client").ResourceRef, field: string, file: Blob, { onProgress, signal }?: import("@protobase/client").UploadOptions) => Promise<import("@protobase/client").UploadResult>;
36
77
  };
@@ -39,6 +80,8 @@ export type ApiProviderProps = {
39
80
  baseUrl?: string;
40
81
  /** A ready-made client, for tests; skips the meta-version watcher. */
41
82
  client?: Client;
83
+ /** Ready-made clients of the sources `/meta` names, by their URL, for stories and tests. */
84
+ sources?: Record<string, Client>;
42
85
  /** The `fetch` the built client uses, for tests that keep the meta-version watcher. */
43
86
  fetch?: ClientOptions['fetch'];
44
87
  queryClient?: QueryClient;
@@ -52,5 +95,5 @@ export type ApiProviderProps = {
52
95
  * compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
53
96
  * per-caller body, so the two never match.
54
97
  */
55
- export declare const ApiProvider: ({ baseUrl, client: given, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }: ApiProviderProps) => import("react").JSX.Element;
98
+ export declare const ApiProvider: ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }: ApiProviderProps) => import("react").JSX.Element;
56
99
  //#endregion
@@ -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