@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.
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,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);
@@ -114,6 +114,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
114
114
  const permissions = usePermissions(model.name);
115
115
  const restore = useRestore(model);
116
116
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
117
+ const canDelete = can(permissions, "delete") && stored.permissions?.delete !== false;
117
118
  const remove = useDeleteFlow({
118
119
  model,
119
120
  view,
@@ -210,7 +211,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
210
211
  onClick: editor.discard,
211
212
  children: "Discard"
212
213
  }),
213
- can(permissions, "delete") && stored.permissions?.delete !== false && /* @__PURE__ */ jsx(Button, {
214
+ canDelete && /* @__PURE__ */ jsx(Button, {
214
215
  variant: "danger",
215
216
  className: "max-md:min-h-11",
216
217
  onClick: () => void remove.start({
@@ -231,122 +232,125 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
231
232
  })
232
233
  ] });
233
234
  return /* @__PURE__ */ jsxs("div", {
234
- className: "min-h-0 flex-1 overflow-y-auto",
235
+ className: "flex min-h-0 flex-1 flex-col",
235
236
  children: [
236
237
  remove.dialogs,
237
- /* @__PURE__ */ jsxs("div", {
238
- className: "mx-auto max-w-[1100px] px-4 pb-28 pt-6 md:px-10 md:py-8",
239
- children: [
240
- /* @__PURE__ */ jsx(RecordHeader, {
241
- collection: view?.names?.plural ?? humanize(model.name),
242
- title,
243
- status: {
244
- label: status ? enumLabel(status, hint("status")?.valueLabels) : singular,
245
- tone: status ? toneFor(status) : "neutral"
246
- },
247
- lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
248
- actions: /* @__PURE__ */ jsx("div", {
249
- className: "hidden items-center gap-4 md:flex",
250
- children: saveControls
251
- })
252
- }),
253
- editor.message && /* @__PURE__ */ jsx("div", {
254
- className: "mt-4",
255
- children: /* @__PURE__ */ jsx(ErrorBanner, {
256
- title: "Not saved",
257
- error: new Error(editor.message)
258
- })
259
- }),
260
- editor.linesConflict && /* @__PURE__ */ jsx("div", {
261
- className: "mt-4",
262
- children: /* @__PURE__ */ jsx(ErrorBanner, {
263
- title: "The lines were changed by someone else",
264
- error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
265
- })
266
- }),
267
- editor.restored && /* @__PURE__ */ jsxs("div", {
268
- className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
269
- role: "status",
270
- children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
271
- className: "flex gap-2",
272
- children: [/* @__PURE__ */ jsx(Button, {
273
- size: "sm",
274
- onClick: editor.dismissRestored,
275
- children: "Keep"
276
- }), /* @__PURE__ */ jsx(Button, {
277
- size: "sm",
278
- onClick: editor.discard,
279
- children: "Discard"
238
+ /* @__PURE__ */ jsx("div", {
239
+ className: "min-h-0 flex-1 overflow-y-auto",
240
+ children: /* @__PURE__ */ jsxs("div", {
241
+ className: "mx-auto max-w-[1100px] px-4 pb-8 pt-6 md:px-10 md:py-8",
242
+ children: [
243
+ /* @__PURE__ */ jsx(RecordHeader, {
244
+ collection: view?.names?.plural ?? humanize(model.name),
245
+ title,
246
+ status: {
247
+ label: status ? enumLabel(status, hint("status")?.valueLabels) : singular,
248
+ tone: status ? toneFor(status) : "neutral"
249
+ },
250
+ lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
251
+ actions: /* @__PURE__ */ jsx("div", {
252
+ className: "hidden items-center gap-4 md:flex",
253
+ children: saveControls
254
+ })
255
+ }),
256
+ editor.message && /* @__PURE__ */ jsx("div", {
257
+ className: "mt-4",
258
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
259
+ title: "Not saved",
260
+ error: new Error(editor.message)
261
+ })
262
+ }),
263
+ editor.linesConflict && /* @__PURE__ */ jsx("div", {
264
+ className: "mt-4",
265
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
266
+ title: "The lines were changed by someone else",
267
+ error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
268
+ })
269
+ }),
270
+ editor.restored && /* @__PURE__ */ jsxs("div", {
271
+ className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
272
+ role: "status",
273
+ children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
274
+ className: "flex gap-2",
275
+ children: [/* @__PURE__ */ jsx(Button, {
276
+ size: "sm",
277
+ onClick: editor.dismissRestored,
278
+ children: "Keep"
279
+ }), /* @__PURE__ */ jsx(Button, {
280
+ size: "sm",
281
+ onClick: editor.discard,
282
+ children: "Discard"
283
+ })]
280
284
  })]
281
- })]
282
- }),
283
- /* @__PURE__ */ jsxs("div", {
284
- className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
285
- children: [/* @__PURE__ */ jsxs("main", {
286
- className: "divide-y divide-border",
287
- children: [layout.flatMap((item, index) => {
288
- if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
289
- item,
290
- recordKey
291
- }, `related:${item.title}`)];
292
- if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
293
- item,
294
- scope
295
- }, `custom:${index}`)];
296
- if (item.kind !== "section") return [];
297
- const fields = item.fields.filter((name) => model.fields[name] && visible(name));
298
- if (fields.length === 0) return [];
299
- return [/* @__PURE__ */ jsx(Section, {
300
- title: item.title,
301
- help: item.help,
302
- children: /* @__PURE__ */ jsx("div", {
303
- className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
304
- children: fields.map(fieldView)
285
+ }),
286
+ /* @__PURE__ */ jsxs("div", {
287
+ className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
288
+ children: [/* @__PURE__ */ jsxs("main", {
289
+ className: "divide-y divide-border",
290
+ children: [layout.flatMap((item, index) => {
291
+ if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
292
+ item,
293
+ recordKey
294
+ }, `related:${item.title}`)];
295
+ if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
296
+ item,
297
+ scope
298
+ }, `custom:${index}`)];
299
+ if (item.kind !== "section") return [];
300
+ const fields = item.fields.filter((name) => model.fields[name] && visible(name));
301
+ if (fields.length === 0) return [];
302
+ return [/* @__PURE__ */ jsx(Section, {
303
+ title: item.title,
304
+ help: item.help,
305
+ children: /* @__PURE__ */ jsx("div", {
306
+ className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
307
+ children: fields.map(fieldView)
308
+ })
309
+ }, item.title)];
310
+ }), linesModel && /* @__PURE__ */ jsx(RecordLines, {
311
+ lines: linesModel,
312
+ draft: linesDraft,
313
+ vatRate: Number(stored.record.vatRate ?? 21) / 100,
314
+ saving: editor.saving
315
+ })]
316
+ }), /* @__PURE__ */ jsxs("aside", {
317
+ className: "space-y-4 md:pt-8",
318
+ children: [
319
+ /* @__PURE__ */ jsx(RecordActions, {
320
+ model,
321
+ stored,
322
+ recordKey
323
+ }),
324
+ firstSidebar === -1 && summary,
325
+ layout.map((item, index) => item.kind === "custom" && item.sidebar ? /* @__PURE__ */ jsx(CustomSection, {
326
+ item,
327
+ scope
328
+ }, `custom:${index}`) : index === firstSidebar ? /* @__PURE__ */ jsx(Fragment, { children: summary }, "summary") : null),
329
+ /* @__PURE__ */ jsx(MetadataCard, {
330
+ title: "Details",
331
+ rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
332
+ const field = name ? model.fields[name] : void 0;
333
+ return name && field && visible(name) ? [{
334
+ label: hint(name)?.label ?? humanize(name),
335
+ value: displayValue(field, stored.record[name], hint(name))
336
+ }] : [];
337
+ })
338
+ }),
339
+ related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
340
+ title: "Related records",
341
+ children: /* @__PURE__ */ jsx(RelatedRecords, {
342
+ records: related.records,
343
+ onNavigate: open
344
+ })
305
345
  })
306
- }, item.title)];
307
- }), linesModel && /* @__PURE__ */ jsx(RecordLines, {
308
- lines: linesModel,
309
- draft: linesDraft,
310
- vatRate: Number(stored.record.vatRate ?? 21) / 100,
311
- saving: editor.saving
346
+ ]
312
347
  })]
313
- }), /* @__PURE__ */ jsxs("aside", {
314
- className: "space-y-4 md:pt-8",
315
- children: [
316
- /* @__PURE__ */ jsx(RecordActions, {
317
- model,
318
- stored,
319
- recordKey
320
- }),
321
- firstSidebar === -1 && summary,
322
- layout.map((item, index) => item.kind === "custom" && item.sidebar ? /* @__PURE__ */ jsx(CustomSection, {
323
- item,
324
- scope
325
- }, `custom:${index}`) : index === firstSidebar ? /* @__PURE__ */ jsx(Fragment, { children: summary }, "summary") : null),
326
- /* @__PURE__ */ jsx(MetadataCard, {
327
- title: "Details",
328
- rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
329
- const field = name ? model.fields[name] : void 0;
330
- return name && field && visible(name) ? [{
331
- label: hint(name)?.label ?? humanize(name),
332
- value: displayValue(field, stored.record[name], hint(name))
333
- }] : [];
334
- })
335
- }),
336
- related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
337
- title: "Related records",
338
- children: /* @__PURE__ */ jsx(RelatedRecords, {
339
- records: related.records,
340
- onNavigate: open
341
- })
342
- })
343
- ]
344
- })]
345
- })
346
- ]
348
+ })
349
+ ]
350
+ })
347
351
  }),
348
- /* @__PURE__ */ jsx("div", {
349
- className: "fixed inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t bg-background p-3 md:hidden",
352
+ (editor.dirty || canDelete || canUpdate) && /* @__PURE__ */ jsx("div", {
353
+ className: "flex shrink-0 items-center gap-2 border-t bg-background p-3 md:hidden",
350
354
  children: saveControls
351
355
  })
352
356
  ]
@@ -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,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