@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.
- package/dist/app/app.d.ts +2 -0
- package/dist/app/app.js +6 -2
- package/dist/app/current-address.js +5 -4
- package/dist/app/displays/cards-display.js +1 -1
- package/dist/app/displays/display-controls.js +1 -1
- package/dist/app/displays/list-displays.js +11 -1
- package/dist/app/list-page.js +1 -1
- 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/source-notice.js +47 -0
- package/dist/app/use-app-address.js +7 -1
- 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/lib/use-media-query.js +3 -1
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-Cqr5Ze7R.js +65 -0
- package/dist/shell/assets/{index-Bro4QDEX.js → index-ntYv19kQ.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-eH-UjL47.js +1 -0
- package/dist/shell/index.html +3 -3
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BKdW5nhg.js +0 -65
- 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
|
|
3
|
+
import { encodeKey } from "@protobase/schema";
|
|
3
4
|
//#region src/app/current-address.ts
|
|
4
|
-
/** What a list shows when its address names nothing:
|
|
5
|
-
const listDefaults = (model, view) => ({
|
|
6
|
-
display: view
|
|
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 };
|
package/dist/app/list-page.js
CHANGED
|
@@ -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";
|
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);
|
|
@@ -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]), [
|
|
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
|
|
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 };
|
package/dist/data/meta-data.d.ts
CHANGED
|
@@ -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
|
package/dist/data/query-keys.js
CHANGED
|
@@ -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,
|
package/dist/data/use-meta.d.ts
CHANGED
|
@@ -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: () =>
|
|
9
|
+
export declare const useMeta: () => UseQueryResult<MetaData>;
|
|
8
10
|
//#endregion
|