@protobase/ui 0.10.0 → 0.11.0
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.js +54 -26
- 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 +37 -0
- package/dist/app/displays/cards-display.js +76 -0
- package/dist/app/displays/custom-display.js +28 -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/field-value.js +8 -4
- package/dist/app/pages/merge-ui.js +3 -1
- package/dist/app/pages/problem.js +10 -0
- package/dist/app/pages/project-ui.d.ts +12 -4
- 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 +3 -6
- 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/index.d.ts +7 -1
- package/dist/index.js +5 -1
- package/dist/live/use-live-rows.js +7 -2
- package/dist/primitives/segmented.js +8 -5
- package/package.json +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ComponentType } from "react";
|
|
2
2
|
import { Client } from "@protobase/client";
|
|
3
|
+
import { AppAddress } from "@protobase/schema";
|
|
3
4
|
//#region src/app/pages/project-ui.d.ts
|
|
4
|
-
/** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API and
|
|
5
|
+
/** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API, and a way to move. */
|
|
5
6
|
export type ActionContext = {
|
|
6
7
|
resource: string;
|
|
7
8
|
record?: Record<string, unknown>;
|
|
@@ -9,17 +10,19 @@ export type ActionContext = {
|
|
|
9
10
|
recordKey?: string;
|
|
10
11
|
etag?: string;
|
|
11
12
|
client: Client;
|
|
12
|
-
|
|
13
|
+
/** Moves the app to an address, such as `{ kind: 'record', resource: 'orders', key: 42 }`. */
|
|
14
|
+
navigate: (to: AppAddress) => void;
|
|
13
15
|
};
|
|
14
16
|
export type ActionHandler = (context: ActionContext) => void | Promise<void>;
|
|
15
17
|
/**
|
|
16
18
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
17
19
|
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour, and
|
|
18
|
-
* the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`)
|
|
20
|
+
* the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
|
|
21
|
+
* (`import * as tasksUi from '...'`), their exported components included; `name` names it in their errors.
|
|
19
22
|
*/
|
|
20
23
|
export type ProjectUi = {
|
|
21
24
|
name?: string;
|
|
22
|
-
extends?: ProjectUi
|
|
25
|
+
extends?: Array<ProjectUi | UiModule>;
|
|
23
26
|
components?: Record<string, ComponentType<any>>;
|
|
24
27
|
actions?: Record<string, ActionHandler>;
|
|
25
28
|
/** Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page. */
|
|
@@ -28,6 +31,11 @@ export type ProjectUi = {
|
|
|
28
31
|
rightPanel?: ComponentType;
|
|
29
32
|
};
|
|
30
33
|
};
|
|
34
|
+
/** A `protobase.ui.tsx` module as `import * as` gives it: components exported by name, the rest as the default export. */
|
|
35
|
+
export type UiModule = {
|
|
36
|
+
readonly default?: ProjectUi;
|
|
37
|
+
readonly [name: string]: unknown;
|
|
38
|
+
};
|
|
31
39
|
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
32
40
|
export declare const defineUi: (ui: ProjectUi) => ProjectUi;
|
|
33
41
|
//#endregion
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
2
2
|
import { ActionRail } from "../../record-view/action-rail.js";
|
|
3
3
|
import { ConfirmAction } from "./confirm-action.js";
|
|
4
4
|
import { useActionRunner } from "./use-run-action.js";
|
|
@@ -8,7 +8,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
//#region src/app/pages/record-actions.tsx
|
|
9
9
|
/** The record page's named actions, run like the Action block runs them; deleting the record returns to the list. */
|
|
10
10
|
const RecordActions = ({ model, stored, recordKey }) => {
|
|
11
|
-
const {
|
|
11
|
+
const { go } = useAppAddress();
|
|
12
12
|
const scope = {
|
|
13
13
|
model,
|
|
14
14
|
record: stored.record,
|
|
@@ -16,7 +16,10 @@ const RecordActions = ({ model, stored, recordKey }) => {
|
|
|
16
16
|
etag: stored.etag,
|
|
17
17
|
permissions: stored.permissions
|
|
18
18
|
};
|
|
19
|
-
const runner = useActionRunner(model.name, scope, (action) => action.run?.kind === "delete" &&
|
|
19
|
+
const runner = useActionRunner(model.name, scope, (action) => action.run?.kind === "delete" && go({
|
|
20
|
+
kind: "list",
|
|
21
|
+
resource: model.name
|
|
22
|
+
}, { replace: true }));
|
|
20
23
|
const [asking, setAsking] = useState();
|
|
21
24
|
if (runner.actions.length === 0) return null;
|
|
22
25
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions: runner.actions.map((action) => ({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Button } from "../../primitives/button.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
|
-
import { Link } from "../router.js";
|
|
4
3
|
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { AppLink } from "../use-app-address.js";
|
|
5
5
|
import { scopeOf } from "./use-layout-data.js";
|
|
6
6
|
import { FieldValue } from "./field-value.js";
|
|
7
7
|
import { usePathValue } from "./use-path-value.js";
|
|
@@ -75,8 +75,12 @@ const RecordTable = ({ model, columns, rows, label, pager }) => {
|
|
|
75
75
|
className: "border-b last:border-0 hover:bg-surface",
|
|
76
76
|
children: columns.map((column, index) => /* @__PURE__ */ jsx("td", {
|
|
77
77
|
className: "px-4 py-2",
|
|
78
|
-
children: index === 0 && opensRecord ? /* @__PURE__ */ jsx(
|
|
79
|
-
to:
|
|
78
|
+
children: index === 0 && opensRecord ? /* @__PURE__ */ jsx(AppLink, {
|
|
79
|
+
to: {
|
|
80
|
+
kind: "record",
|
|
81
|
+
resource: model.name,
|
|
82
|
+
key: scope.key
|
|
83
|
+
},
|
|
80
84
|
className: "font-medium text-primary-text hover:underline",
|
|
81
85
|
children: cell(row, column)
|
|
82
86
|
}) : cell(row, column)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useProjectUi } from "./project-ui.js";
|
|
2
|
+
import { Problem } from "./problem.js";
|
|
2
3
|
import { ActionBlock } from "./blocks/action.js";
|
|
3
4
|
import { CardBlock } from "./blocks/card.js";
|
|
4
5
|
import { CardRowBlock } from "./blocks/card-row.js";
|
|
@@ -14,6 +15,7 @@ import { StatBlock } from "./blocks/stat.js";
|
|
|
14
15
|
import { TableBlock } from "./blocks/table.js";
|
|
15
16
|
import { Fragment } from "react";
|
|
16
17
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { unmarkFieldRefs } from "@protobase/schema";
|
|
17
19
|
import { isLayoutNode } from "@protobase/layout";
|
|
18
20
|
//#region src/app/pages/render-node.tsx
|
|
19
21
|
const blocks = {
|
|
@@ -31,11 +33,6 @@ const blocks = {
|
|
|
31
33
|
Show: ShowBlock,
|
|
32
34
|
Link: LinkBlock
|
|
33
35
|
};
|
|
34
|
-
const Problem = ({ children }) => /* @__PURE__ */ jsx("div", {
|
|
35
|
-
role: "alert",
|
|
36
|
-
className: "rounded-lg border border-dashed border-warning bg-warning-soft p-3 text-[13px] text-warning-text",
|
|
37
|
-
children
|
|
38
|
-
});
|
|
39
36
|
const isInline = (child) => typeof child === "string" || !child.custom && child.type === "Link";
|
|
40
37
|
/** Text and links run together as one paragraph, like a sentence with a link in it; other elements stand alone. */
|
|
41
38
|
const renderChildren = (children) => {
|
|
@@ -68,7 +65,7 @@ const RenderNode = ({ node }) => {
|
|
|
68
65
|
". Add it to the components in protobase.ui.tsx."
|
|
69
66
|
] });
|
|
70
67
|
return /* @__PURE__ */ jsx(Custom, {
|
|
71
|
-
...node.props,
|
|
68
|
+
...unmarkFieldRefs(node.props),
|
|
72
69
|
children: node.children.length > 0 ? renderChildren(node.children) : void 0
|
|
73
70
|
});
|
|
74
71
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ProjectUi } from "./project-ui.js";
|
|
2
|
+
//#region src/app/pages/ui-from-module.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The UI config of a `protobase.ui.tsx` module: its default export (`defineUi`, for action handlers, shell slots and
|
|
5
|
+
* the configs it extends), and every component it exports under a PascalCase name as the component of that name. A
|
|
6
|
+
* name both exported and in `components` must be the same component.
|
|
7
|
+
*/
|
|
8
|
+
export declare const uiFromModule: (module: Record<string, unknown>) => ProjectUi;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/app/pages/ui-from-module.ts
|
|
2
|
+
const componentName = /^[A-Z][A-Za-z0-9]*$/;
|
|
3
|
+
const isComponent = (value) => typeof value === "function" || typeof value === "object" && value !== null && "$$typeof" in value;
|
|
4
|
+
/**
|
|
5
|
+
* The UI config of a `protobase.ui.tsx` module: its default export (`defineUi`, for action handlers, shell slots and
|
|
6
|
+
* the configs it extends), and every component it exports under a PascalCase name as the component of that name. A
|
|
7
|
+
* name both exported and in `components` must be the same component.
|
|
8
|
+
*/
|
|
9
|
+
const uiFromModule = (module) => {
|
|
10
|
+
const ui = module.default ?? {};
|
|
11
|
+
const named = Object.entries(module).filter((entry) => entry[0] !== "default" && componentName.test(entry[0]) && isComponent(entry[1]));
|
|
12
|
+
if (named.length === 0) return ui;
|
|
13
|
+
for (const [name, component] of named) if (ui.components && Object.hasOwn(ui.components, name) && ui.components[name] !== component) throw new Error(`The component "${name}" is exported by name and given in components as another one; keep the export`);
|
|
14
|
+
return {
|
|
15
|
+
...ui,
|
|
16
|
+
components: {
|
|
17
|
+
...ui.components,
|
|
18
|
+
...Object.fromEntries(named)
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { uiFromModule };
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
3
3
|
import { useProjectUi } from "./project-ui.js";
|
|
4
|
-
import { useRouter } from "../router.js";
|
|
5
4
|
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
5
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
6
6
|
import { can } from "../permissions.js";
|
|
7
|
-
import { fillHref
|
|
7
|
+
import { fillHref } from "./action-href.js";
|
|
8
8
|
import { useRefreshResource } from "./use-layout-data.js";
|
|
9
9
|
import { useState } from "react";
|
|
10
|
+
import { fillAddress } from "@protobase/schema";
|
|
10
11
|
//#region src/app/pages/use-run-action.ts
|
|
11
12
|
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
12
13
|
/**
|
|
@@ -21,7 +22,7 @@ const useActionRunner = (resource, scope, afterRun) => {
|
|
|
21
22
|
const client = useClient();
|
|
22
23
|
const toast = useToast();
|
|
23
24
|
const refresh = useRefreshResource();
|
|
24
|
-
const {
|
|
25
|
+
const { go } = useAppAddress();
|
|
25
26
|
const [running, setRunning] = useState();
|
|
26
27
|
const actions = views[resource]?.actions ?? [];
|
|
27
28
|
const blocked = (action) => {
|
|
@@ -34,9 +35,8 @@ const useActionRunner = (resource, scope, afterRun) => {
|
|
|
34
35
|
const perform = async (action) => {
|
|
35
36
|
const { run } = action;
|
|
36
37
|
if (run?.kind === "link") {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
else window.location.assign(href);
|
|
38
|
+
if ("to" in run) go(fillAddress(run.to, scope?.record));
|
|
39
|
+
else window.location.assign(fillHref(run.href, scope?.record));
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
|
|
@@ -47,7 +47,7 @@ const useActionRunner = (resource, scope, afterRun) => {
|
|
|
47
47
|
recordKey: scope?.key,
|
|
48
48
|
etag: scope?.etag,
|
|
49
49
|
client,
|
|
50
|
-
navigate
|
|
50
|
+
navigate: go
|
|
51
51
|
});
|
|
52
52
|
await refresh(resource);
|
|
53
53
|
toast.show({
|
package/dist/app/record-page.js
CHANGED
|
@@ -7,11 +7,12 @@ import { useClient } from "../data/api-provider.js";
|
|
|
7
7
|
import { useRecord } from "../data/use-record.js";
|
|
8
8
|
import { recordTitle } from "../live/model-helpers.js";
|
|
9
9
|
import { humanize } from "../live/naming.js";
|
|
10
|
+
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
10
11
|
import { useNavigationGuard, useRouter } from "./router.js";
|
|
12
|
+
import { useAppAddress } from "./use-app-address.js";
|
|
11
13
|
import { displayValue } from "../live/display-value.js";
|
|
12
14
|
import { ErrorBanner } from "./error-banner.js";
|
|
13
15
|
import { FieldEditor } from "./field-editor.js";
|
|
14
|
-
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
15
16
|
import { can, fieldAccess } from "./permissions.js";
|
|
16
17
|
import { Field } from "../record-view/field.js";
|
|
17
18
|
import { Section } from "../record-view/section.js";
|
|
@@ -21,6 +22,7 @@ import { toneFor } from "../live/columns-from-model.js";
|
|
|
21
22
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
22
23
|
import { useRestore } from "./delete/use-restore.js";
|
|
23
24
|
import { auditField } from "../live/audit-fields.js";
|
|
25
|
+
import { CustomSection } from "./custom-section.js";
|
|
24
26
|
import { RecordActions } from "./pages/record-actions.js";
|
|
25
27
|
import { RecordLines, findLinesResource } from "./record-lines.js";
|
|
26
28
|
import { draftStorageKey } from "./draft-storage.js";
|
|
@@ -29,7 +31,7 @@ import { useLinesDraft } from "./use-lines-draft.js";
|
|
|
29
31
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
30
32
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
31
33
|
import { RelatedSection } from "./related-section.js";
|
|
32
|
-
import { useMemo } from "react";
|
|
34
|
+
import { Fragment, useMemo } from "react";
|
|
33
35
|
import { Check } from "lucide-react";
|
|
34
36
|
import { isApiError } from "@protobase/client";
|
|
35
37
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -85,7 +87,8 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
85
87
|
};
|
|
86
88
|
const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
87
89
|
const meta = useAdminMeta();
|
|
88
|
-
const { basePath
|
|
90
|
+
const { basePath } = useRouter();
|
|
91
|
+
const { go, open } = useAppAddress();
|
|
89
92
|
const view = meta.views[model.name];
|
|
90
93
|
const singular = view?.names?.singular ?? humanize(model.name);
|
|
91
94
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
@@ -112,7 +115,10 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
112
115
|
model,
|
|
113
116
|
view,
|
|
114
117
|
restore,
|
|
115
|
-
onDeleted: () =>
|
|
118
|
+
onDeleted: () => go({
|
|
119
|
+
kind: "list",
|
|
120
|
+
resource: model.name
|
|
121
|
+
}, { replace: true }),
|
|
116
122
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
117
123
|
});
|
|
118
124
|
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
@@ -149,7 +155,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
149
155
|
value: editor.value(name),
|
|
150
156
|
stored: stored.record[name],
|
|
151
157
|
relationLabel: related.labels[name],
|
|
152
|
-
|
|
158
|
+
relationTo: related.addresses[name],
|
|
153
159
|
locked: !canUpdate || access === "read",
|
|
154
160
|
invalid: Boolean(editor.errors[name]),
|
|
155
161
|
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
@@ -158,6 +164,29 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
158
164
|
})
|
|
159
165
|
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
160
166
|
};
|
|
167
|
+
const firstSidebar = layout.findIndex((item) => item.kind === "sidebar");
|
|
168
|
+
const summary = /* @__PURE__ */ jsx(MetadataCard, {
|
|
169
|
+
title: "Summary",
|
|
170
|
+
rows: sidebarFields.flatMap((name) => {
|
|
171
|
+
const field = model.fields[name];
|
|
172
|
+
if (!field || !visible(name)) return [];
|
|
173
|
+
const text = displayValue(field, stored.record[name], hint(name));
|
|
174
|
+
return [{
|
|
175
|
+
label: hint(name)?.label ?? humanize(name),
|
|
176
|
+
value: field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
|
|
177
|
+
tone: toneFor(String(stored.record[name])),
|
|
178
|
+
children: text
|
|
179
|
+
}) : /* @__PURE__ */ jsx("strong", { children: text })
|
|
180
|
+
}];
|
|
181
|
+
})
|
|
182
|
+
});
|
|
183
|
+
const scope = {
|
|
184
|
+
model,
|
|
185
|
+
record: stored.record,
|
|
186
|
+
key: recordKey,
|
|
187
|
+
etag: stored.etag,
|
|
188
|
+
permissions: stored.permissions
|
|
189
|
+
};
|
|
161
190
|
const saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
162
191
|
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
163
192
|
className: "text-xs text-muted-foreground",
|
|
@@ -243,11 +272,15 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
243
272
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
244
273
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
245
274
|
className: "divide-y divide-border",
|
|
246
|
-
children: [layout.flatMap((item) => {
|
|
275
|
+
children: [layout.flatMap((item, index) => {
|
|
247
276
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
248
277
|
item,
|
|
249
278
|
recordKey
|
|
250
279
|
}, `related:${item.title}`)];
|
|
280
|
+
if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
|
|
281
|
+
item,
|
|
282
|
+
scope
|
|
283
|
+
}, `custom:${index}`)];
|
|
251
284
|
if (item.kind !== "section") return [];
|
|
252
285
|
const fields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
253
286
|
if (fields.length === 0) return [];
|
|
@@ -273,21 +306,11 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
273
306
|
stored,
|
|
274
307
|
recordKey
|
|
275
308
|
}),
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const text = displayValue(field, stored.record[name], hint(name));
|
|
282
|
-
return [{
|
|
283
|
-
label: hint(name)?.label ?? humanize(name),
|
|
284
|
-
value: field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
|
|
285
|
-
tone: toneFor(String(stored.record[name])),
|
|
286
|
-
children: text
|
|
287
|
-
}) : /* @__PURE__ */ jsx("strong", { children: text })
|
|
288
|
-
}];
|
|
289
|
-
})
|
|
290
|
-
}),
|
|
309
|
+
firstSidebar === -1 && summary,
|
|
310
|
+
layout.map((item, index) => item.kind === "custom" && item.sidebar ? /* @__PURE__ */ jsx(CustomSection, {
|
|
311
|
+
item,
|
|
312
|
+
scope
|
|
313
|
+
}, `custom:${index}`) : index === firstSidebar ? /* @__PURE__ */ jsx(Fragment, { children: summary }, "summary") : null),
|
|
291
314
|
/* @__PURE__ */ jsx(MetadataCard, {
|
|
292
315
|
title: "Details",
|
|
293
316
|
rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
|
|
@@ -302,7 +325,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
302
325
|
title: "Related records",
|
|
303
326
|
children: /* @__PURE__ */ jsx(RelatedRecords, {
|
|
304
327
|
records: related.records,
|
|
305
|
-
onNavigate:
|
|
328
|
+
onNavigate: open
|
|
306
329
|
})
|
|
307
330
|
})
|
|
308
331
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { keys } from "../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../data/api-provider.js";
|
|
3
|
-
import { ErrorBanner } from "./error-banner.js";
|
|
4
3
|
import { useAdminMeta } from "./meta-gate.js";
|
|
4
|
+
import { ErrorBanner } from "./error-banner.js";
|
|
5
5
|
import { Section } from "../record-view/section.js";
|
|
6
6
|
import { Skeleton } from "../primitives/skeleton.js";
|
|
7
7
|
import { RecordTable } from "./pages/record-table.js";
|
package/dist/app/router.js
CHANGED
|
@@ -59,7 +59,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
59
59
|
setUrl(full);
|
|
60
60
|
}, [memory]);
|
|
61
61
|
const navigate = useCallback((to, { replace = false } = {}) => {
|
|
62
|
-
const full =
|
|
62
|
+
const full = `${basePath}${to}`;
|
|
63
63
|
if (pathOf(full) !== pathOf(urlRef.current) && guards.current.size > 0) return setPending({
|
|
64
64
|
kind: "link",
|
|
65
65
|
full,
|
|
@@ -81,20 +81,12 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
81
81
|
};
|
|
82
82
|
const value = useMemo(() => {
|
|
83
83
|
const parsed = new URL(url, "http://local");
|
|
84
|
-
const path = parsed.pathname.slice(basePath.length) || "/";
|
|
85
84
|
return {
|
|
86
|
-
path,
|
|
85
|
+
path: parsed.pathname.slice(basePath.length) || "/",
|
|
87
86
|
params: parsed.searchParams,
|
|
88
87
|
basePath,
|
|
89
88
|
navigate,
|
|
90
|
-
registerGuard
|
|
91
|
-
setParams: (patch) => {
|
|
92
|
-
const next = new URLSearchParams(parsed.searchParams);
|
|
93
|
-
for (const [name, entry] of Object.entries(patch)) if (entry === void 0 || entry === "") next.delete(name);
|
|
94
|
-
else next.set(name, entry);
|
|
95
|
-
const query = next.toString();
|
|
96
|
-
navigate(`${path}${query ? `?${query}` : ""}`, { replace: true });
|
|
97
|
-
}
|
|
89
|
+
registerGuard
|
|
98
90
|
};
|
|
99
91
|
}, [
|
|
100
92
|
url,
|
|
@@ -132,14 +124,6 @@ const Link = ({ to, onClick, ...rest }) => {
|
|
|
132
124
|
...rest
|
|
133
125
|
});
|
|
134
126
|
};
|
|
135
|
-
/** `/orders` is a list, `/orders/<key>` a record; composite keys stay in their comma-joined form. */
|
|
136
|
-
const matchRoute = (path) => {
|
|
137
|
-
const [resource, key] = path.split("/").filter(Boolean);
|
|
138
|
-
return {
|
|
139
|
-
resource: resource && decodeURIComponent(resource),
|
|
140
|
-
key: key && decodeURIComponent(key)
|
|
141
|
-
};
|
|
142
|
-
};
|
|
143
127
|
/** While `active`, leaving the page asks first (in-app links and closing the tab). */
|
|
144
128
|
const useNavigationGuard = (active) => {
|
|
145
129
|
const { registerGuard } = useRouter();
|
|
@@ -152,4 +136,4 @@ const useNavigationGuard = (active) => {
|
|
|
152
136
|
}, [active]);
|
|
153
137
|
};
|
|
154
138
|
//#endregion
|
|
155
|
-
export { Link, Router,
|
|
139
|
+
export { Link, Router, useNavigationGuard, useRouter };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
|
-
import { createWhere, encodeKey, enumLabel, matchedSearchField, printFilter } from "@protobase/schema";
|
|
3
|
+
import { appPath, createWhere, encodeKey, enumLabel, matchedSearchField, printFilter } from "@protobase/schema";
|
|
4
4
|
//#region src/app/search/search-model.ts
|
|
5
5
|
const where = createWhere();
|
|
6
6
|
const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
|
|
@@ -63,7 +63,11 @@ const toHit = (target, row, text, basePath = "") => {
|
|
|
63
63
|
id: key,
|
|
64
64
|
title: title.map((name) => shown(target, row, name)).filter(Boolean).join(" ") || key,
|
|
65
65
|
...subtitle && { subtitle },
|
|
66
|
-
href:
|
|
66
|
+
href: appPath({
|
|
67
|
+
kind: "record",
|
|
68
|
+
resource: target.model.name,
|
|
69
|
+
key
|
|
70
|
+
}, basePath)
|
|
67
71
|
};
|
|
68
72
|
};
|
|
69
73
|
//#endregion
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
|
-
import { useRouter } from "../router.js";
|
|
3
2
|
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
+
import { useRouter } from "../router.js";
|
|
4
4
|
import { useDebounced } from "../../lib/use-debounced.js";
|
|
5
5
|
import { searchFields, searchFilter, searchPlaceholder, searchTargets, toHit } from "./search-model.js";
|
|
6
6
|
import { useMemo, useState } from "react";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CurrentAddress, ListAddress, ListChange } from "./current-address.js";
|
|
2
|
+
import { AnchorHTMLAttributes } from "react";
|
|
3
|
+
import { AppAddress } from "@protobase/schema";
|
|
4
|
+
//#region src/app/use-app-address.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `go` moves to
|
|
7
|
+
* another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
|
|
8
|
+
* writes the address through this hook, never the query string or a path of its own.
|
|
9
|
+
*/
|
|
10
|
+
export declare const useAppAddress: () => {
|
|
11
|
+
address: CurrentAddress | undefined;
|
|
12
|
+
set: (change: ListChange) => void;
|
|
13
|
+
go: (to: AppAddress, options?: {
|
|
14
|
+
replace?: boolean;
|
|
15
|
+
}) => void;
|
|
16
|
+
href: (to: AppAddress) => string;
|
|
17
|
+
open: (target: string) => void;
|
|
18
|
+
};
|
|
19
|
+
/** A link to an address in the app: an ordinary anchor that the router follows without reloading the page. */
|
|
20
|
+
export declare const AppLink: ({ to, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
21
|
+
to: AppAddress;
|
|
22
|
+
}) => import("react").JSX.Element;
|
|
23
|
+
//#endregion
|
|
24
|
+
export type { CurrentAddress, ListAddress, ListChange };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { changedList, currentAddress, listDefaults } from "./current-address.js";
|
|
2
|
+
import { useAdminMeta } from "./meta-gate.js";
|
|
3
|
+
import { Link, useRouter } from "./router.js";
|
|
4
|
+
import { useCallback, useMemo } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
import { appPath, readAppPath } from "@protobase/schema";
|
|
7
|
+
//#region src/app/use-app-address.tsx
|
|
8
|
+
/**
|
|
9
|
+
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `go` moves to
|
|
10
|
+
* another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
|
|
11
|
+
* writes the address through this hook, never the query string or a path of its own.
|
|
12
|
+
*/
|
|
13
|
+
const useAppAddress = () => {
|
|
14
|
+
const { path, params, basePath, navigate } = useRouter();
|
|
15
|
+
const { pages, resources, views } = useAdminMeta();
|
|
16
|
+
const pageNames = useMemo(() => new Set(Object.keys(pages)), [pages]);
|
|
17
|
+
const query = params.toString();
|
|
18
|
+
const read = useMemo(() => readAppPath(query ? `${path}?${query}` : path, pageNames), [
|
|
19
|
+
path,
|
|
20
|
+
query,
|
|
21
|
+
pageNames
|
|
22
|
+
]);
|
|
23
|
+
const defaults = useCallback((resource) => listDefaults(resources[resource], views[resource]), [resources, views]);
|
|
24
|
+
const address = useMemo(() => currentAddress(read, defaults), [read, defaults]);
|
|
25
|
+
const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
|
|
26
|
+
return {
|
|
27
|
+
address,
|
|
28
|
+
set: useCallback((change) => {
|
|
29
|
+
if (read?.kind !== "list") throw new Error("set changes the list the app shows; use go to move to another address");
|
|
30
|
+
go(changedList(read, change, defaults(read.resource)), { replace: true });
|
|
31
|
+
}, [
|
|
32
|
+
read,
|
|
33
|
+
defaults,
|
|
34
|
+
go
|
|
35
|
+
]),
|
|
36
|
+
go,
|
|
37
|
+
href: useCallback((to) => appPath(to, basePath), [basePath]),
|
|
38
|
+
open: useCallback((target) => navigate(target.startsWith(`${basePath}/`) ? target.slice(basePath.length) : target), [basePath, navigate])
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** A link to an address in the app: an ordinary anchor that the router follows without reloading the page. */
|
|
42
|
+
const AppLink = ({ to, ...rest }) => /* @__PURE__ */ jsx(Link, {
|
|
43
|
+
to: appPath(to),
|
|
44
|
+
...rest
|
|
45
|
+
});
|
|
46
|
+
//#endregion
|
|
47
|
+
export { AppLink, useAppAddress };
|
|
@@ -3,6 +3,7 @@ import { useClient } from "../data/api-provider.js";
|
|
|
3
3
|
import { humanize } from "../live/naming.js";
|
|
4
4
|
import { fetchLabels } from "../live/relation-labels.js";
|
|
5
5
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { appPath } from "@protobase/schema";
|
|
6
7
|
//#region src/app/use-related-records.ts
|
|
7
8
|
/** One entry per relation the record holds, named by the record it points at. The tenant is left out. */
|
|
8
9
|
const useRelatedRecords = (model, view, resources, views, record, basePath) => {
|
|
@@ -17,13 +18,18 @@ const useRelatedRecords = (model, view, resources, views, record, basePath) => {
|
|
|
17
18
|
queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
|
|
18
19
|
};
|
|
19
20
|
}) });
|
|
21
|
+
const addresses = relations.map((field) => ({
|
|
22
|
+
kind: "record",
|
|
23
|
+
resource: field.relation.resource,
|
|
24
|
+
key: String(record[field.name])
|
|
25
|
+
}));
|
|
20
26
|
return {
|
|
21
|
-
|
|
27
|
+
addresses: Object.fromEntries(relations.map((field, index) => [field.name, addresses[index]])),
|
|
22
28
|
labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data])),
|
|
23
29
|
records: relations.map((field, index) => ({
|
|
24
30
|
kind: view?.fields[field.name]?.label ?? humanize(field.name),
|
|
25
31
|
label: labels[index]?.data ?? String(record[field.name]),
|
|
26
|
-
href:
|
|
32
|
+
href: appPath(addresses[index], basePath)
|
|
27
33
|
}))
|
|
28
34
|
};
|
|
29
35
|
};
|
|
@@ -2,6 +2,7 @@ import { humanize } from "../live/naming.js";
|
|
|
2
2
|
import { iconFor } from "./icons.js";
|
|
3
3
|
import { menuIcon } from "./menu-icons.js";
|
|
4
4
|
import { ExternalLink, LayoutDashboard } from "lucide-react";
|
|
5
|
+
import { appPath } from "@protobase/schema";
|
|
5
6
|
//#region src/app/user-menu-from-meta.ts
|
|
6
7
|
/** The resources and pages the user menu holds; the sidebar leaves them out. */
|
|
7
8
|
const userMenuResources = (meta) => new Set((meta.userMenu?.items ?? []).flatMap((item) => item.kind === "resource" ? [item.resource] : item.kind === "page" ? [item.page] : []));
|
|
@@ -20,7 +21,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
|
|
|
20
21
|
id: item.page,
|
|
21
22
|
label: item.label ?? page?.title ?? humanize(item.page),
|
|
22
23
|
icon: menuIcon(item.icon ?? page?.icon, LayoutDashboard),
|
|
23
|
-
href:
|
|
24
|
+
href: appPath({
|
|
25
|
+
kind: "page",
|
|
26
|
+
page: item.page
|
|
27
|
+
}, basePath),
|
|
24
28
|
active: item.page === activeResource
|
|
25
29
|
};
|
|
26
30
|
}
|
|
@@ -28,7 +32,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
|
|
|
28
32
|
id: item.resource,
|
|
29
33
|
label: item.label ?? meta.views[item.resource]?.names?.plural ?? humanize(item.resource),
|
|
30
34
|
icon: menuIcon(item.icon, iconFor(item.resource)),
|
|
31
|
-
href:
|
|
35
|
+
href: appPath({
|
|
36
|
+
kind: "list",
|
|
37
|
+
resource: item.resource
|
|
38
|
+
}, basePath),
|
|
32
39
|
active: item.resource === activeResource
|
|
33
40
|
};
|
|
34
41
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,13 @@ import { useRecord } from "./data/use-record.js";
|
|
|
9
9
|
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
10
10
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
11
11
|
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
12
|
+
import { uiFromModule } from "./app/pages/ui-from-module.js";
|
|
12
13
|
import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
|
|
14
|
+
import { CurrentAddress, ListAddress, ListChange } from "./app/current-address.js";
|
|
15
|
+
import { AppLink, useAppAddress } from "./app/use-app-address.js";
|
|
16
|
+
import { ListDisplayRow } from "./app/displays/list-displays.js";
|
|
17
|
+
import { ListDisplay, useListDisplay } from "./app/displays/display-context.js";
|
|
18
|
+
import { RecordLink } from "./app/displays/record-link.js";
|
|
13
19
|
import { useApiToken } from "./auth/use-api-token.js";
|
|
14
20
|
import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
|
|
15
21
|
import { PartView } from "./assistant-dock/part-view.js";
|
|
@@ -30,4 +36,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
|
|
|
30
36
|
import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
|
|
31
37
|
import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
|
|
32
38
|
import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
|
|
33
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type MetaData, PartView, type ProjectUi, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
39
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type ProjectUi, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -18,10 +18,14 @@ import { AssistantDock } from "./assistant-dock/assistant-dock.js";
|
|
|
18
18
|
import { useAssistant } from "./assistant-dock/use-assistant.js";
|
|
19
19
|
import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
|
|
20
20
|
import { useMeta } from "./data/use-meta.js";
|
|
21
|
+
import { AppLink, useAppAddress } from "./app/use-app-address.js";
|
|
21
22
|
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
23
|
+
import { useListDisplay } from "./app/displays/display-context.js";
|
|
22
24
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
25
|
+
import { uiFromModule } from "./app/pages/ui-from-module.js";
|
|
23
26
|
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
24
27
|
import { useList } from "./data/use-list.js";
|
|
25
28
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
26
29
|
import { App } from "./app/app.js";
|
|
27
|
-
|
|
30
|
+
import { RecordLink } from "./app/displays/record-link.js";
|
|
31
|
+
export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
@@ -4,7 +4,10 @@ import { createGridLoader } from "./grid-loader.js";
|
|
|
4
4
|
import { useMemo } from "react";
|
|
5
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
6
|
//#region src/live/use-live-rows.ts
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A grid row source backed by the API; `total` is the server's estimate from the first page. `page` loads one page of
|
|
9
|
+
* the same rows by number, and `key` names them in the query cache, for list displays that page through them.
|
|
10
|
+
*/
|
|
8
11
|
const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
|
|
9
12
|
const client = useClient();
|
|
10
13
|
const queryClient = useQueryClient();
|
|
@@ -38,7 +41,9 @@ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
|
|
|
38
41
|
loadRows: loader.loadRows
|
|
39
42
|
} : void 0, [first.data, loader]),
|
|
40
43
|
error: first.error,
|
|
41
|
-
isLoading: first.isLoading
|
|
44
|
+
isLoading: first.isLoading,
|
|
45
|
+
page: loader.page,
|
|
46
|
+
key: [...keys.first(model.name, filter, orderBy), columnKey]
|
|
42
47
|
};
|
|
43
48
|
};
|
|
44
49
|
//#endregion
|