@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 +2 -0
- package/dist/app/app.js +6 -2
- package/dist/app/meta-gate.js +47 -6
- package/dist/app/nav-from-meta.js +20 -1
- package/dist/app/pages/project-ui.d.ts +5 -1
- package/dist/app/pages/use-run-action.js +3 -2
- package/dist/app/record-page.js +115 -111
- package/dist/app/source-notice.js +47 -0
- package/dist/app-shell/sidebar-item.js +7 -1
- package/dist/data/api-provider.d.ts +46 -3
- package/dist/data/api-provider.js +57 -3
- package/dist/data/meta-context.js +6 -0
- package/dist/data/meta-data.d.ts +15 -1
- package/dist/data/query-keys.js +8 -0
- package/dist/data/use-meta.d.ts +4 -2
- package/dist/data/use-meta.js +15 -3
- package/dist/data/use-source-metas.js +61 -0
- package/dist/data/with-sources.js +38 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-C1uXjaoy.js +65 -0
- package/dist/shell/assets/{index-DheSSTn2.css → index-BVOqIEYd.css} +1 -1
- package/dist/shell/assets/{index-ACvBBjQJ.js → index-uIZKnNIY.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-DDq8y6_Q.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BpWT2V8v.js +0 -65
- package/dist/shell/assets/shared-protobase-ui-DLJ-Jjub.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, {
|
package/dist/app/meta-gate.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
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
|
-
/**
|
|
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(
|
|
39
|
-
|
|
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
|
-
/**
|
|
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);
|
package/dist/app/record-page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
235
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
235
236
|
children: [
|
|
236
237
|
remove.dialogs,
|
|
237
|
-
/* @__PURE__ */
|
|
238
|
-
className: "
|
|
239
|
-
children:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
children: [/* @__PURE__ */ jsx(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
314
|
-
|
|
315
|
-
|
|
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: "
|
|
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
|
|
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
|