@protobase/ui 0.10.0 → 0.11.1
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 +3 -0
- package/dist/app/app.js +84 -39
- package/dist/app/bottom-bar-from-meta.js +9 -2
- package/dist/app/breadcrumb-for.js +18 -9
- package/dist/app/create/create-page.js +12 -6
- package/dist/app/current-address.d.ts +26 -0
- package/dist/app/current-address.js +41 -0
- package/dist/app/custom-section.js +41 -0
- package/dist/app/displays/cards-display.js +76 -0
- package/dist/app/displays/custom-display.js +32 -0
- package/dist/app/displays/display-context.d.ts +21 -0
- package/dist/app/displays/display-context.js +21 -0
- package/dist/app/displays/display-controls.js +55 -0
- package/dist/app/displays/display-frame.js +30 -0
- package/dist/app/displays/list-displays.d.ts +11 -0
- package/dist/app/displays/list-displays.js +62 -0
- package/dist/app/displays/record-link.d.ts +19 -0
- package/dist/app/displays/record-link.js +25 -0
- package/dist/app/displays/use-display-rows.js +28 -0
- package/dist/app/field-editor.js +4 -4
- package/dist/app/list-page.js +87 -37
- package/dist/app/main.js +3 -2
- package/dist/app/nav-from-meta.js +9 -2
- package/dist/app/nav-recent.js +6 -2
- package/dist/app/pages/action-href.js +1 -3
- package/dist/app/pages/blocks/card-row.js +1 -1
- package/dist/app/pages/blocks/link.js +20 -11
- package/dist/app/pages/blocks/modal-form.js +1 -1
- package/dist/app/pages/blocks/record-card.js +1 -1
- package/dist/app/pages/blocks/stat.js +7 -4
- package/dist/app/pages/blocks/table.js +1 -1
- package/dist/app/pages/custom-boundary.js +17 -0
- package/dist/app/pages/field-value.js +8 -4
- package/dist/app/pages/merge-ui.js +9 -3
- package/dist/app/pages/problem.js +10 -0
- package/dist/app/pages/project-ui.d.ts +18 -4
- package/dist/app/pages/project-ui.js +8 -2
- package/dist/app/pages/record-actions.js +6 -3
- package/dist/app/pages/record-table.js +7 -3
- package/dist/app/pages/render-node.js +10 -9
- package/dist/app/pages/ui-from-module.d.ts +9 -0
- package/dist/app/pages/ui-from-module.js +23 -0
- package/dist/app/pages/use-run-action.js +7 -7
- package/dist/app/record-page.js +45 -22
- package/dist/app/related-section.js +1 -1
- package/dist/app/router.js +4 -20
- package/dist/app/search/search-model.js +6 -2
- package/dist/app/search/use-global-search.js +1 -1
- package/dist/app/use-app-address.d.ts +24 -0
- package/dist/app/use-app-address.js +47 -0
- package/dist/app/use-related-records.js +8 -2
- package/dist/app/user-menu-from-meta.js +9 -2
- package/dist/data/meta-data.d.ts +3 -1
- package/dist/data/use-meta.js +2 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +8 -2
- package/dist/live/use-live-rows.js +7 -2
- package/dist/package.js +5 -0
- package/dist/plugins/load-plugin.d.ts +16 -0
- package/dist/plugins/load-plugin.js +73 -0
- package/dist/plugins/merge-plugins.js +36 -0
- package/dist/plugins/plugin-api.d.ts +10 -0
- package/dist/plugins/plugin-api.js +12 -0
- package/dist/plugins/plugin-context.d.ts +14 -0
- package/dist/plugins/plugin-context.js +12 -0
- package/dist/plugins/plugin-gate.js +35 -0
- package/dist/plugins/plugin-manifest.js +23 -0
- package/dist/plugins/plugin-notice.js +45 -0
- package/dist/plugins/plugin-ui.js +37 -0
- package/dist/plugins/use-plugin-loads.js +57 -0
- package/dist/primitives/segmented.js +8 -5
- package/dist/styles.css +1 -35
- package/dist/theme.css +39 -0
- package/package.json +9 -5
- package/dist/app/pages/project-ui-provider.js +0 -15
package/dist/app/app.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SidebarMode } from "../app-shell/sidebar-mode.js";
|
|
2
2
|
import "../app-shell/index.js";
|
|
3
3
|
import { ProjectUi } from "./pages/project-ui.js";
|
|
4
|
+
import { PluginLoader } from "../plugins/load-plugin.js";
|
|
4
5
|
import { AssistantClient, AuthSession, Client, RuntimeClient } from "@protobase/client";
|
|
5
6
|
//#region src/app/app.d.ts
|
|
6
7
|
export type AppProps = {
|
|
@@ -22,6 +23,8 @@ export type AppProps = {
|
|
|
22
23
|
assistant?: AssistantClient;
|
|
23
24
|
/** A ready-made client of the runtime endpoint, for stories and tests; used only when `/meta` names one. */
|
|
24
25
|
runtime?: RuntimeClient;
|
|
26
|
+
/** How the plugins `/meta` names are loaded, for stories and tests; the page's `fetch` and `import()` by default. */
|
|
27
|
+
pluginLoader?: PluginLoader;
|
|
25
28
|
};
|
|
26
29
|
/** The admin: sign-in first, then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent. */
|
|
27
30
|
export declare const App: ({ auth, workspace, ...rest }: AppProps) => import("react").JSX.Element;
|
package/dist/app/app.js
CHANGED
|
@@ -15,9 +15,11 @@ 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 { Router, matchRoute, useRouter } from "./router.js";
|
|
19
18
|
import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
|
|
19
|
+
import { Router, useRouter } from "./router.js";
|
|
20
|
+
import { useAppAddress } from "./use-app-address.js";
|
|
20
21
|
import { CreatePage } from "./create/create-page.js";
|
|
22
|
+
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
21
23
|
import { ListPage } from "./list-page.js";
|
|
22
24
|
import { MembersPage } from "../organizations/members-page.js";
|
|
23
25
|
import { InvitationsPage } from "../organizations/invitations-page.js";
|
|
@@ -29,7 +31,8 @@ import { useActiveRole } from "../roles/use-active-role.js";
|
|
|
29
31
|
import { userMenuFromMeta } from "./user-menu-from-meta.js";
|
|
30
32
|
import { navFromMeta } from "./nav-from-meta.js";
|
|
31
33
|
import { ComposedPage } from "./pages/composed-page.js";
|
|
32
|
-
import {
|
|
34
|
+
import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
|
|
35
|
+
import { PluginNotice } from "../plugins/plugin-notice.js";
|
|
33
36
|
import { RecordPage } from "./record-page.js";
|
|
34
37
|
import { useGlobalSearch } from "./search/use-global-search.js";
|
|
35
38
|
import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
|
|
@@ -38,31 +41,48 @@ import { useNavRecent } from "./use-nav-recent.js";
|
|
|
38
41
|
import { useCallback, useEffect, useState } from "react";
|
|
39
42
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
40
43
|
//#region src/app/app.tsx
|
|
44
|
+
const placeOf = (address) => address?.kind === "page" ? address.page : address?.resource;
|
|
45
|
+
const recordOf = (address) => address?.kind === "record" ? address : void 0;
|
|
41
46
|
const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
42
47
|
const meta = useAdminMeta();
|
|
43
48
|
const { state, session, signOut, stopStaffSession, organizations: organizationSettings } = useAuth();
|
|
44
49
|
if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
|
|
45
50
|
const { user, staff } = state;
|
|
46
|
-
const { path, params, basePath
|
|
47
|
-
const
|
|
51
|
+
const { path, params, basePath } = useRouter();
|
|
52
|
+
const { address, go, open } = useAppAddress();
|
|
53
|
+
const place = placeOf(address);
|
|
54
|
+
const shown = recordOf(address);
|
|
48
55
|
const search = useGlobalSearch();
|
|
49
|
-
const recent = useNavRecent(meta, basePath,
|
|
56
|
+
const recent = useNavRecent(meta, basePath, {
|
|
57
|
+
resource: place,
|
|
58
|
+
key: shown?.key
|
|
59
|
+
});
|
|
50
60
|
const groups = navFromMeta(meta, basePath, recent);
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
61
|
+
const firstPage = Object.keys(meta.pages)[0];
|
|
62
|
+
const firstResource = Object.keys(meta.resources)[0];
|
|
63
|
+
const page = address?.kind === "page" ? meta.pages[address.page] : void 0;
|
|
54
64
|
useEffect(() => {
|
|
55
|
-
if (
|
|
65
|
+
if (path !== "/") return;
|
|
66
|
+
if (firstPage) go({
|
|
67
|
+
kind: "page",
|
|
68
|
+
page: firstPage
|
|
69
|
+
}, { replace: true });
|
|
70
|
+
else if (firstResource) go({
|
|
71
|
+
kind: "list",
|
|
72
|
+
resource: firstResource
|
|
73
|
+
}, { replace: true });
|
|
56
74
|
}, [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
path,
|
|
76
|
+
firstPage,
|
|
77
|
+
firstResource,
|
|
78
|
+
go
|
|
60
79
|
]);
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
80
|
+
const resource = address?.kind === "page" ? void 0 : address?.resource;
|
|
81
|
+
const view = resource ? meta.views[resource] : void 0;
|
|
82
|
+
const group = groups.find((entry) => entry.items.some((item) => item.id === place));
|
|
83
|
+
const model = resource ? meta.resources[resource] : void 0;
|
|
84
|
+
const record = useRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
|
|
85
|
+
const title = model && shown && record.data ? recordTitle(model, view, record.data.record, shown.key) : shown?.key;
|
|
66
86
|
const { shell } = useProjectUi();
|
|
67
87
|
const Actions = shell?.actions;
|
|
68
88
|
const RightPanel = shell?.rightPanel;
|
|
@@ -86,6 +106,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
86
106
|
const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]));
|
|
87
107
|
const acting = actingUser(user, role.current);
|
|
88
108
|
const admin = isAdmin(acting);
|
|
109
|
+
const pluginsOff = usePluginsOff();
|
|
89
110
|
const organizations = useOrganizations(Boolean(organizationSettings));
|
|
90
111
|
const current = organizations.current;
|
|
91
112
|
const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
|
|
@@ -93,10 +114,10 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
93
114
|
const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
|
|
94
115
|
const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
|
|
95
116
|
basePath,
|
|
96
|
-
|
|
117
|
+
address,
|
|
97
118
|
group: group?.label,
|
|
98
119
|
page,
|
|
99
|
-
resourceLabel: view?.names?.plural ?? (
|
|
120
|
+
resourceLabel: view?.names?.plural ?? (resource && humanize(resource)),
|
|
100
121
|
recordTitle: title
|
|
101
122
|
});
|
|
102
123
|
const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
|
|
@@ -112,24 +133,34 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
112
133
|
}) : path === "/-/organization/members" ? /* @__PURE__ */ jsx(MembersPage, { current }) : /* @__PURE__ */ jsx(InvitationsPage, { current }) : organizationPage && path === "/-/organizations" || outsideOrganizations ? /* @__PURE__ */ jsx(OrganizationsPage, {
|
|
113
134
|
state: organizations,
|
|
114
135
|
basePath
|
|
115
|
-
}) : !
|
|
136
|
+
}) : !address ? path === "/" ? /* @__PURE__ */ jsx(Notice, {
|
|
116
137
|
title: "Choose a resource",
|
|
117
138
|
children: "Pick one from the sidebar."
|
|
118
|
-
}) :
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
139
|
+
}) : /* @__PURE__ */ jsx(Notice, {
|
|
140
|
+
title: "Not found",
|
|
141
|
+
children: "There is nothing at this address."
|
|
142
|
+
}) : address.kind === "page" ? page ? /* @__PURE__ */ jsx(ComposedPage, { name: page.name }, page.name) : /* @__PURE__ */ jsxs(Notice, {
|
|
143
|
+
title: "Not found",
|
|
144
|
+
children: [
|
|
145
|
+
"There is no page called ",
|
|
146
|
+
address.page,
|
|
147
|
+
"."
|
|
148
|
+
]
|
|
149
|
+
}) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, { resource: address.resource }, `${address.resource}/new`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
|
|
150
|
+
resource: address.resource,
|
|
151
|
+
recordKey: address.key
|
|
152
|
+
}, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
|
|
122
153
|
const appShell = /* @__PURE__ */ jsx(AppShell, {
|
|
123
154
|
sidebarMode: sidebarMode ?? "text-small",
|
|
124
|
-
activeItem:
|
|
155
|
+
activeItem: place ?? "",
|
|
125
156
|
breadcrumb,
|
|
126
157
|
user: userToShell(acting),
|
|
127
158
|
onSignOut: () => void signOut(),
|
|
128
159
|
workspace: current?.organization?.name ?? workspace,
|
|
129
160
|
navGroups: groups,
|
|
130
|
-
bottomBar: bottomBarFromMeta(meta, basePath,
|
|
161
|
+
bottomBar: bottomBarFromMeta(meta, basePath, place),
|
|
131
162
|
userMenu: [
|
|
132
|
-
...userMenuFromMeta(meta, basePath,
|
|
163
|
+
...userMenuFromMeta(meta, basePath, place),
|
|
133
164
|
...organizationSettings ? organizationMenuItems(basePath, path, current) : [],
|
|
134
165
|
...accountMenuItems(basePath, path, admin)
|
|
135
166
|
],
|
|
@@ -157,26 +188,39 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
157
188
|
onSelect: open
|
|
158
189
|
} : void 0,
|
|
159
190
|
actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
160
|
-
Actions && /* @__PURE__ */ jsx(
|
|
191
|
+
Actions && /* @__PURE__ */ jsx(CustomBoundary, {
|
|
192
|
+
name: "shell.actions",
|
|
193
|
+
fallback: null,
|
|
194
|
+
children: /* @__PURE__ */ jsx(Actions, {})
|
|
195
|
+
}),
|
|
161
196
|
runtimeButton,
|
|
162
197
|
assistantButton
|
|
163
198
|
] }),
|
|
164
|
-
rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(
|
|
199
|
+
rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(CustomBoundary, {
|
|
200
|
+
name: "shell.rightPanel",
|
|
201
|
+
fallback: null,
|
|
202
|
+
children: /* @__PURE__ */ jsx(RightPanel, {})
|
|
203
|
+
}), assistantPanel] }),
|
|
165
204
|
children: content
|
|
166
205
|
});
|
|
167
|
-
|
|
206
|
+
const pluginNotice = admin && pluginsOff.length > 0;
|
|
207
|
+
if (!staff && !pluginNotice) return appShell;
|
|
168
208
|
return /* @__PURE__ */ jsxs("div", {
|
|
169
209
|
className: "flex h-full min-h-0 flex-col",
|
|
170
|
-
children: [
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
210
|
+
children: [
|
|
211
|
+
staff && /* @__PURE__ */ jsx(StaffBanner, {
|
|
212
|
+
signIn: staff,
|
|
213
|
+
onStop: stopStaffSession
|
|
214
|
+
}),
|
|
215
|
+
pluginNotice && /* @__PURE__ */ jsx(PluginNotice, { off: pluginsOff }),
|
|
216
|
+
/* @__PURE__ */ jsx("div", {
|
|
217
|
+
className: "min-h-0 flex-1",
|
|
218
|
+
children: appShell
|
|
219
|
+
})
|
|
220
|
+
]
|
|
177
221
|
});
|
|
178
222
|
};
|
|
179
|
-
const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime }) => {
|
|
223
|
+
const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime, pluginLoader }) => {
|
|
180
224
|
const { session, markSignedOut } = useAuth();
|
|
181
225
|
return /* @__PURE__ */ jsx(ApiProvider, {
|
|
182
226
|
baseUrl,
|
|
@@ -186,8 +230,9 @@ const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sideb
|
|
|
186
230
|
children: /* @__PURE__ */ jsx(Router, {
|
|
187
231
|
basePath,
|
|
188
232
|
initialUrl,
|
|
189
|
-
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(
|
|
233
|
+
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(PluginGate, {
|
|
190
234
|
ui,
|
|
235
|
+
...pluginLoader && { loader: pluginLoader },
|
|
191
236
|
children: /* @__PURE__ */ jsx(Routes, {
|
|
192
237
|
workspace,
|
|
193
238
|
sidebarMode,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { iconFor } from "./icons.js";
|
|
2
2
|
import { menuIcon } from "./menu-icons.js";
|
|
3
3
|
import { ExternalLink, LayoutDashboard } from "lucide-react";
|
|
4
|
+
import { appPath } from "@protobase/schema";
|
|
4
5
|
//#region src/app/bottom-bar-from-meta.ts
|
|
5
6
|
/** The bottom bar's tabs from `/meta`: resources and pages open in the app (active while open), links open a new tab. */
|
|
6
7
|
const bottomBarFromMeta = (meta, basePath, activeResource) => (meta.bottomBar?.items ?? []).map((item, index) => {
|
|
@@ -15,14 +16,20 @@ const bottomBarFromMeta = (meta, basePath, activeResource) => (meta.bottomBar?.i
|
|
|
15
16
|
id: item.page,
|
|
16
17
|
label: item.label,
|
|
17
18
|
icon: menuIcon(item.icon ?? meta.pages[item.page]?.icon, LayoutDashboard),
|
|
18
|
-
href:
|
|
19
|
+
href: appPath({
|
|
20
|
+
kind: "page",
|
|
21
|
+
page: item.page
|
|
22
|
+
}, basePath),
|
|
19
23
|
active: item.page === activeResource
|
|
20
24
|
};
|
|
21
25
|
return {
|
|
22
26
|
id: item.resource,
|
|
23
27
|
label: item.label,
|
|
24
28
|
icon: menuIcon(item.icon, iconFor(item.resource)),
|
|
25
|
-
href:
|
|
29
|
+
href: appPath({
|
|
30
|
+
kind: "list",
|
|
31
|
+
resource: item.resource
|
|
32
|
+
}, basePath),
|
|
26
33
|
active: item.resource === activeResource
|
|
27
34
|
};
|
|
28
35
|
});
|
|
@@ -1,21 +1,30 @@
|
|
|
1
|
+
import { appPath } from "@protobase/schema";
|
|
1
2
|
//#region src/app/breadcrumb-for.ts
|
|
2
3
|
/** Group, then the page or resource list, then the record (or "New"): each with a page of its own links to it. */
|
|
3
|
-
const breadcrumbFor = ({ basePath,
|
|
4
|
+
const breadcrumbFor = ({ basePath, address, group, page, resourceLabel, recordTitle }) => {
|
|
4
5
|
const groupCrumb = group ? [group] : [];
|
|
5
6
|
if (page) return [...groupCrumb, {
|
|
6
7
|
label: page.title,
|
|
7
|
-
href:
|
|
8
|
+
href: appPath({
|
|
9
|
+
kind: "page",
|
|
10
|
+
page: page.name
|
|
11
|
+
}, basePath)
|
|
8
12
|
}];
|
|
9
|
-
if (!
|
|
10
|
-
const list = `${basePath}/${route.resource}`;
|
|
13
|
+
if (!address || address.kind === "page") return ["Admin"];
|
|
11
14
|
const resource = resourceLabel ? [{
|
|
12
15
|
label: resourceLabel,
|
|
13
|
-
href:
|
|
16
|
+
href: appPath({
|
|
17
|
+
kind: "list",
|
|
18
|
+
resource: address.resource
|
|
19
|
+
}, basePath)
|
|
14
20
|
}] : [];
|
|
15
|
-
if (
|
|
16
|
-
const record = {
|
|
17
|
-
label:
|
|
18
|
-
href:
|
|
21
|
+
if (address.kind === "list") return [...groupCrumb, ...resource];
|
|
22
|
+
const record = address.kind === "create" ? {
|
|
23
|
+
label: "New",
|
|
24
|
+
href: appPath(address, basePath)
|
|
25
|
+
} : {
|
|
26
|
+
label: recordTitle ?? address.key,
|
|
27
|
+
href: appPath(address, basePath)
|
|
19
28
|
};
|
|
20
29
|
return [
|
|
21
30
|
...groupCrumb,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { recordKey } from "../../live/model-helpers.js";
|
|
3
3
|
import { humanize } from "../../live/naming.js";
|
|
4
|
-
import { useRouter } from "../router.js";
|
|
5
|
-
import { FieldEditor } from "../field-editor.js";
|
|
6
4
|
import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
5
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
6
|
+
import { FieldEditor } from "../field-editor.js";
|
|
7
7
|
import { can } from "../permissions.js";
|
|
8
8
|
import { useLiveSave } from "../use-live-save.js";
|
|
9
9
|
import { CreateRecordForm } from "./create-record-form.js";
|
|
@@ -12,7 +12,6 @@ import { useState } from "react";
|
|
|
12
12
|
import { isApiError } from "@protobase/client";
|
|
13
13
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
14
|
import { useQueryClient } from "@tanstack/react-query";
|
|
15
|
-
import { encodeKey } from "@protobase/schema";
|
|
16
15
|
//#region src/app/create/create-page.tsx
|
|
17
16
|
const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
|
|
18
17
|
/** `/:resource/new`: a form from the view's layout; on success it opens the new record. */
|
|
@@ -44,7 +43,7 @@ const LoadedCreate = ({ resource }) => {
|
|
|
44
43
|
const view = meta.views[resource];
|
|
45
44
|
const client = useClient();
|
|
46
45
|
const queryClient = useQueryClient();
|
|
47
|
-
const {
|
|
46
|
+
const { go } = useAppAddress();
|
|
48
47
|
const noun = view?.names?.singular ?? humanize(resource);
|
|
49
48
|
const feedback = useLiveSave(view?.saveFeedback ?? "toast", `New ${noun.toLowerCase()}`);
|
|
50
49
|
const sections = createSections(model, view);
|
|
@@ -72,7 +71,11 @@ const LoadedCreate = ({ resource }) => {
|
|
|
72
71
|
"facets",
|
|
73
72
|
"series"
|
|
74
73
|
]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
|
|
75
|
-
|
|
74
|
+
go({
|
|
75
|
+
kind: "record",
|
|
76
|
+
resource,
|
|
77
|
+
key: recordKey(model, created.record)
|
|
78
|
+
}, { replace: true });
|
|
76
79
|
});
|
|
77
80
|
const formSections = sections.map((section) => ({
|
|
78
81
|
title: section.title,
|
|
@@ -112,7 +115,10 @@ const LoadedCreate = ({ resource }) => {
|
|
|
112
115
|
saving: feedback.phase === "saving",
|
|
113
116
|
message: feedback.message,
|
|
114
117
|
onSubmit: () => void submit(),
|
|
115
|
-
onCancel: () =>
|
|
118
|
+
onCancel: () => go({
|
|
119
|
+
kind: "list",
|
|
120
|
+
resource
|
|
121
|
+
})
|
|
116
122
|
});
|
|
117
123
|
};
|
|
118
124
|
//#endregion
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
|
|
2
|
+
//#region src/app/current-address.d.ts
|
|
3
|
+
/** A list as the app shows it: every value there, the view's default where the address names none. */
|
|
4
|
+
export type ListAddress = {
|
|
5
|
+
kind: 'list';
|
|
6
|
+
resource: string;
|
|
7
|
+
display: string;
|
|
8
|
+
filter: string;
|
|
9
|
+
orderBy: string;
|
|
10
|
+
filterLayout: FilterLayout;
|
|
11
|
+
};
|
|
12
|
+
/** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
|
|
13
|
+
export type CurrentAddress = ListAddress | {
|
|
14
|
+
kind: 'record';
|
|
15
|
+
resource: string;
|
|
16
|
+
key: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'create';
|
|
19
|
+
resource: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'page';
|
|
22
|
+
page: string;
|
|
23
|
+
};
|
|
24
|
+
/** What can change about the list the app shows; `undefined` returns a value to its default. */
|
|
25
|
+
export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource'>>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defaultOrderBy } from "./list-order.js";
|
|
2
|
+
import { encodeKey, tableDisplay } from "@protobase/schema";
|
|
3
|
+
//#region src/app/current-address.ts
|
|
4
|
+
/** What a list shows when its address names nothing: the view's default display, the list's sort, the filters in a bar. */
|
|
5
|
+
const listDefaults = (model, view) => ({
|
|
6
|
+
display: view?.list?.default ?? tableDisplay,
|
|
7
|
+
orderBy: model ? defaultOrderBy(model, view) : "",
|
|
8
|
+
filterLayout: "bar"
|
|
9
|
+
});
|
|
10
|
+
/** The address as read from the location, with a list's defaults filled in and a record's key as text. */
|
|
11
|
+
const currentAddress = (read, defaults) => {
|
|
12
|
+
if (read?.kind === "record") return {
|
|
13
|
+
kind: "record",
|
|
14
|
+
resource: read.resource,
|
|
15
|
+
key: encodeKey(read.key)
|
|
16
|
+
};
|
|
17
|
+
if (read?.kind !== "list") return read;
|
|
18
|
+
return {
|
|
19
|
+
...defaults(read.resource),
|
|
20
|
+
filter: "",
|
|
21
|
+
...read
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** The list address after `change`, leaving out every value that is empty or its default, so `/<resource>` stays clean. */
|
|
25
|
+
const changedList = (read, change, defaults) => {
|
|
26
|
+
const next = {
|
|
27
|
+
...read,
|
|
28
|
+
...change
|
|
29
|
+
};
|
|
30
|
+
const differs = (name) => next[name] !== void 0 && next[name] !== defaults[name];
|
|
31
|
+
return {
|
|
32
|
+
kind: "list",
|
|
33
|
+
resource: read.resource,
|
|
34
|
+
...next.filter && { filter: next.filter },
|
|
35
|
+
...differs("display") && { display: next.display },
|
|
36
|
+
...differs("orderBy") && { orderBy: next.orderBy },
|
|
37
|
+
...differs("filterLayout") && { filterLayout: next.filterLayout }
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { changedList, currentAddress, listDefaults };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useProjectUi } from "./pages/project-ui.js";
|
|
2
|
+
import { Section } from "../record-view/section.js";
|
|
3
|
+
import { SidebarCard } from "../record-view/record-sidebar.js";
|
|
4
|
+
import { Problem } from "./pages/problem.js";
|
|
5
|
+
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
6
|
+
import { RecordScope } from "./pages/record-scope.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { unmarkFieldRefs } from "@protobase/schema";
|
|
9
|
+
//#region src/app/custom-section.tsx
|
|
10
|
+
/**
|
|
11
|
+
* A component of the record layout (`l.custom`), drawn with the record around it, so `useLayoutRecord()` gives it, in a
|
|
12
|
+
* section or a sidebar card under its title when it has one. The record page around it stays the framework's.
|
|
13
|
+
*/
|
|
14
|
+
const CustomSection = ({ item, scope }) => {
|
|
15
|
+
const { components } = useProjectUi();
|
|
16
|
+
const Component = components?.[item.component];
|
|
17
|
+
const body = Component ? /* @__PURE__ */ jsx(RecordScope, {
|
|
18
|
+
value: scope,
|
|
19
|
+
children: /* @__PURE__ */ jsx(CustomBoundary, {
|
|
20
|
+
name: item.component,
|
|
21
|
+
children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(item.props ?? {}) })
|
|
22
|
+
})
|
|
23
|
+
}) : /* @__PURE__ */ jsxs(Problem, { children: [
|
|
24
|
+
"No component is registered as ",
|
|
25
|
+
item.component,
|
|
26
|
+
". Export it from protobase.ui.tsx, or load the plugin that provides it."
|
|
27
|
+
] });
|
|
28
|
+
if (item.sidebar) return item.title ? /* @__PURE__ */ jsx(SidebarCard, {
|
|
29
|
+
title: item.title,
|
|
30
|
+
children: body
|
|
31
|
+
}) : body;
|
|
32
|
+
return item.title ? /* @__PURE__ */ jsx(Section, {
|
|
33
|
+
title: item.title,
|
|
34
|
+
children: body
|
|
35
|
+
}) : /* @__PURE__ */ jsx("section", {
|
|
36
|
+
className: "py-8",
|
|
37
|
+
children: body
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { CustomSection };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Badge } from "../../primitives/badge.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { AppLink } from "../use-app-address.js";
|
|
4
|
+
import { displayValue } from "../../live/display-value.js";
|
|
5
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
6
|
+
import { toneFor } from "../../live/columns-from-model.js";
|
|
7
|
+
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
8
|
+
import { cardFields } from "./list-displays.js";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/app/displays/cards-display.tsx
|
|
11
|
+
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
12
|
+
/** A card per record, opening its page: the title over the subtitle, the badge beside it and small meta fields below. */
|
|
13
|
+
const CardsDisplay = ({ model, view, display, list }) => {
|
|
14
|
+
const { title, subtitle, badge, meta } = cardFields(model, view, display);
|
|
15
|
+
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
16
|
+
const text = (row, name) => {
|
|
17
|
+
const field = model.fields[name];
|
|
18
|
+
if (!field) return "";
|
|
19
|
+
return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
|
|
20
|
+
};
|
|
21
|
+
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
22
|
+
return /* @__PURE__ */ jsxs(DisplayState, {
|
|
23
|
+
list,
|
|
24
|
+
empty: `No ${plural} match.`,
|
|
25
|
+
loading: /* @__PURE__ */ jsx("div", {
|
|
26
|
+
className: grid,
|
|
27
|
+
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
28
|
+
}),
|
|
29
|
+
children: [/* @__PURE__ */ jsx("ul", {
|
|
30
|
+
className: grid,
|
|
31
|
+
"aria-label": view?.names?.plural ?? humanize(model.name),
|
|
32
|
+
children: list.rows.map((row) => {
|
|
33
|
+
const status = badge === void 0 ? void 0 : row.record[badge];
|
|
34
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
|
|
35
|
+
to: {
|
|
36
|
+
kind: "record",
|
|
37
|
+
resource: model.name,
|
|
38
|
+
key: row.key
|
|
39
|
+
},
|
|
40
|
+
className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
|
|
41
|
+
children: [
|
|
42
|
+
/* @__PURE__ */ jsxs("span", {
|
|
43
|
+
className: "flex items-start justify-between gap-2",
|
|
44
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
45
|
+
className: "min-w-0 truncate font-medium",
|
|
46
|
+
children: text(row, title)
|
|
47
|
+
}), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(Badge, {
|
|
48
|
+
tone: toneFor(String(status)),
|
|
49
|
+
children: text(row, badge)
|
|
50
|
+
})]
|
|
51
|
+
}),
|
|
52
|
+
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
53
|
+
className: "truncate text-muted-foreground",
|
|
54
|
+
children: text(row, subtitle)
|
|
55
|
+
}),
|
|
56
|
+
meta.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
57
|
+
className: "mt-auto flex flex-wrap gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
|
|
58
|
+
children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
|
|
59
|
+
className: "flex gap-1",
|
|
60
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
61
|
+
className: "sr-only",
|
|
62
|
+
children: label(name)
|
|
63
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
64
|
+
className: "tabular-nums",
|
|
65
|
+
children: text(row, name)
|
|
66
|
+
})]
|
|
67
|
+
}, name))
|
|
68
|
+
})
|
|
69
|
+
]
|
|
70
|
+
}) }, row.key);
|
|
71
|
+
})
|
|
72
|
+
}), /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { CardsDisplay };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
2
|
+
import { Problem } from "../pages/problem.js";
|
|
3
|
+
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
4
|
+
import { ListDisplayProvider } from "./display-context.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { unmarkFieldRefs } from "@protobase/schema";
|
|
7
|
+
//#region src/app/displays/custom-display.tsx
|
|
8
|
+
/**
|
|
9
|
+
* A display the project draws: the component registered under its name gets the display's props, field references
|
|
10
|
+
* as the names they reference, and reads the list page's rows with `useListDisplay()`.
|
|
11
|
+
*/
|
|
12
|
+
const CustomDisplay = ({ display, list }) => {
|
|
13
|
+
const { components } = useProjectUi();
|
|
14
|
+
const Component = components?.[display.component];
|
|
15
|
+
if (!Component) return /* @__PURE__ */ jsxs(Problem, { children: [
|
|
16
|
+
"No component is registered as ",
|
|
17
|
+
display.component,
|
|
18
|
+
". Export it from protobase.ui.tsx, or load the plugin that provides it."
|
|
19
|
+
] });
|
|
20
|
+
return /* @__PURE__ */ jsx(ListDisplayProvider, {
|
|
21
|
+
value: list,
|
|
22
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
23
|
+
className: "min-h-0 flex-1 overflow-y-auto",
|
|
24
|
+
children: /* @__PURE__ */ jsx(CustomBoundary, {
|
|
25
|
+
name: display.component,
|
|
26
|
+
children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(display.props ?? {}) })
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { CustomDisplay };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ListDisplayRow } from "./list-displays.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/app/displays/display-context.d.ts
|
|
4
|
+
/** The rows a list display draws: the list page loads them through the list API, with its filter, search and order. */
|
|
5
|
+
export type ListDisplay = {
|
|
6
|
+
resource: string;
|
|
7
|
+
rows: ListDisplayRow[];
|
|
8
|
+
/** How many records match, as the server counts them. */
|
|
9
|
+
total: number;
|
|
10
|
+
loading: boolean;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
/** Whether more rows can be loaded with `loadMore`. */
|
|
13
|
+
more: boolean;
|
|
14
|
+
loadMore: () => void;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The list a custom display (`d.custom`) draws. The list page owns the rows, the address and the data: a display never
|
|
18
|
+
* fetches its list, changes the address or keeps its own history.
|
|
19
|
+
*/
|
|
20
|
+
export declare const useListDisplay: () => ListDisplay;
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/app/displays/display-context.tsx
|
|
4
|
+
const ListDisplayContext = createContext(void 0);
|
|
5
|
+
const ListDisplayProvider = ({ value, children }) => /* @__PURE__ */ jsx(ListDisplayContext.Provider, {
|
|
6
|
+
value,
|
|
7
|
+
children
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* The list a custom display (`d.custom`) draws. The list page owns the rows, the address and the data: a display never
|
|
11
|
+
* fetches its list, changes the address or keeps its own history.
|
|
12
|
+
*/
|
|
13
|
+
const useListDisplay = () => {
|
|
14
|
+
const display = useContext(ListDisplayContext);
|
|
15
|
+
if (!display) throw new Error("useListDisplay is used inside a list display, which the list page draws");
|
|
16
|
+
return display;
|
|
17
|
+
};
|
|
18
|
+
/** The list display around a component, or `undefined` outside one. */
|
|
19
|
+
const useOptionalListDisplay = () => useContext(ListDisplayContext);
|
|
20
|
+
//#endregion
|
|
21
|
+
export { ListDisplayProvider, useListDisplay, useOptionalListDisplay };
|