@protobase/ui 0.9.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 +67 -28
- package/dist/app/batch-failure.js +1 -1
- package/dist/app/bottom-bar-from-meta.js +37 -0
- 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/delete/record-diff.js +1 -1
- 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/index.html +3 -2
- 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 +51 -26
- 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/app-shell/app-shell.d.ts +1 -0
- package/dist/app-shell/app-shell.js +27 -14
- package/dist/app-shell/bottom-bar.d.ts +1 -0
- package/dist/app-shell/bottom-bar.js +57 -0
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/profile-menu.js +35 -26
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/switch-group.js +29 -0
- package/dist/auth/auth-provider.js +5 -1
- package/dist/auth/sign-in-form.js +23 -12
- package/dist/auth/sign-in-page.js +3 -2
- package/dist/data/meta-data.d.ts +3 -1
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +5 -1
- package/dist/live/audit-fields.js +5 -0
- package/dist/live/naming.js +2 -2
- package/dist/live/use-live-rows.js +7 -2
- package/dist/primitives/segmented.js +8 -5
- package/dist/roles/acting-user.js +11 -0
- package/dist/roles/use-active-role.js +33 -0
- package/package.json +4 -4
|
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
|
|
|
2
2
|
import { Badge } from "../../primitives/badge.js";
|
|
3
3
|
import { keys } from "../../data/query-keys.js";
|
|
4
4
|
import { useClient } from "../../data/api-provider.js";
|
|
5
|
-
import {
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { AppLink } from "../use-app-address.js";
|
|
6
7
|
import { fileShown } from "../../live/file-values.js";
|
|
7
8
|
import { displayValue } from "../../live/display-value.js";
|
|
8
|
-
import { useAdminMeta } from "../meta-gate.js";
|
|
9
9
|
import { toneFor } from "../../live/columns-from-model.js";
|
|
10
10
|
import { fetchLabels } from "../../live/relation-labels.js";
|
|
11
11
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -23,8 +23,12 @@ const RelationValue = ({ field, value, known }) => {
|
|
|
23
23
|
queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
|
|
24
24
|
});
|
|
25
25
|
if (!target) return /* @__PURE__ */ jsx("span", { children: id });
|
|
26
|
-
return /* @__PURE__ */ jsx(
|
|
27
|
-
to:
|
|
26
|
+
return /* @__PURE__ */ jsx(AppLink, {
|
|
27
|
+
to: {
|
|
28
|
+
kind: "record",
|
|
29
|
+
resource: target.name,
|
|
30
|
+
key: id
|
|
31
|
+
},
|
|
28
32
|
className: "font-medium text-primary-text hover:underline",
|
|
29
33
|
children: known ?? label.data ?? id
|
|
30
34
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { uiFromModule } from "./ui-from-module.js";
|
|
1
2
|
import { jsx } from "react/jsx-runtime";
|
|
2
3
|
//#region src/app/pages/merge-ui.tsx
|
|
3
|
-
const
|
|
4
|
+
const configOf = (parent) => Object.keys(parent).some((key) => key === "default" || /^[A-Z]/.test(key)) ? uiFromModule(parent) : parent;
|
|
5
|
+
const flatten = (ui, label) => [...(ui.extends ?? []).map(configOf).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
|
|
4
6
|
ui,
|
|
5
7
|
label
|
|
6
8
|
}];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
//#region src/app/pages/problem.tsx
|
|
3
|
+
/** A warning in place of something the app cannot draw, such as a component no one registered. */
|
|
4
|
+
const Problem = ({ children }) => /* @__PURE__ */ jsx("div", {
|
|
5
|
+
role: "alert",
|
|
6
|
+
className: "rounded-lg border border-dashed border-warning bg-warning-soft p-3 text-[13px] text-warning-text",
|
|
7
|
+
children
|
|
8
|
+
});
|
|
9
|
+
//#endregion
|
|
10
|
+
export { Problem };
|
|
@@ -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";
|
|
@@ -20,6 +21,8 @@ import { RecordHeader } from "../record-view/record-header.js";
|
|
|
20
21
|
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";
|
|
24
|
+
import { auditField } from "../live/audit-fields.js";
|
|
25
|
+
import { CustomSection } from "./custom-section.js";
|
|
23
26
|
import { RecordActions } from "./pages/record-actions.js";
|
|
24
27
|
import { RecordLines, findLinesResource } from "./record-lines.js";
|
|
25
28
|
import { draftStorageKey } from "./draft-storage.js";
|
|
@@ -28,7 +31,7 @@ import { useLinesDraft } from "./use-lines-draft.js";
|
|
|
28
31
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
29
32
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
30
33
|
import { RelatedSection } from "./related-section.js";
|
|
31
|
-
import { useMemo } from "react";
|
|
34
|
+
import { Fragment, useMemo } from "react";
|
|
32
35
|
import { Check } from "lucide-react";
|
|
33
36
|
import { isApiError } from "@protobase/client";
|
|
34
37
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -84,7 +87,8 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
84
87
|
};
|
|
85
88
|
const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
86
89
|
const meta = useAdminMeta();
|
|
87
|
-
const { basePath
|
|
90
|
+
const { basePath } = useRouter();
|
|
91
|
+
const { go, open } = useAppAddress();
|
|
88
92
|
const view = meta.views[model.name];
|
|
89
93
|
const singular = view?.names?.singular ?? humanize(model.name);
|
|
90
94
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
@@ -111,14 +115,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
111
115
|
model,
|
|
112
116
|
view,
|
|
113
117
|
restore,
|
|
114
|
-
onDeleted: () =>
|
|
118
|
+
onDeleted: () => go({
|
|
119
|
+
kind: "list",
|
|
120
|
+
resource: model.name
|
|
121
|
+
}, { replace: true }),
|
|
115
122
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
116
123
|
});
|
|
117
124
|
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
118
125
|
const layout = layoutFor(model, view);
|
|
119
126
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
120
127
|
const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
|
|
121
|
-
const
|
|
128
|
+
const updatedAt = auditField(model, "updated");
|
|
129
|
+
const updated = updatedAt && stored.record[updatedAt] ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record[updatedAt]))) / 1e3)) : void 0;
|
|
122
130
|
const hint = (name) => view?.fields[name];
|
|
123
131
|
const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
|
|
124
132
|
const fieldView = (name) => {
|
|
@@ -147,7 +155,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
147
155
|
value: editor.value(name),
|
|
148
156
|
stored: stored.record[name],
|
|
149
157
|
relationLabel: related.labels[name],
|
|
150
|
-
|
|
158
|
+
relationTo: related.addresses[name],
|
|
151
159
|
locked: !canUpdate || access === "read",
|
|
152
160
|
invalid: Boolean(editor.errors[name]),
|
|
153
161
|
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
@@ -156,6 +164,29 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
156
164
|
})
|
|
157
165
|
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
158
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
|
+
};
|
|
159
190
|
const saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
160
191
|
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
161
192
|
className: "text-xs text-muted-foreground",
|
|
@@ -241,11 +272,15 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
241
272
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
242
273
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
243
274
|
className: "divide-y divide-border",
|
|
244
|
-
children: [layout.flatMap((item) => {
|
|
275
|
+
children: [layout.flatMap((item, index) => {
|
|
245
276
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
246
277
|
item,
|
|
247
278
|
recordKey
|
|
248
279
|
}, `related:${item.title}`)];
|
|
280
|
+
if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
|
|
281
|
+
item,
|
|
282
|
+
scope
|
|
283
|
+
}, `custom:${index}`)];
|
|
249
284
|
if (item.kind !== "section") return [];
|
|
250
285
|
const fields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
251
286
|
if (fields.length === 0) return [];
|
|
@@ -271,26 +306,16 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
271
306
|
stored,
|
|
272
307
|
recordKey
|
|
273
308
|
}),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const text = displayValue(field, stored.record[name], hint(name));
|
|
280
|
-
return [{
|
|
281
|
-
label: hint(name)?.label ?? humanize(name),
|
|
282
|
-
value: field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
|
|
283
|
-
tone: toneFor(String(stored.record[name])),
|
|
284
|
-
children: text
|
|
285
|
-
}) : /* @__PURE__ */ jsx("strong", { children: text })
|
|
286
|
-
}];
|
|
287
|
-
})
|
|
288
|
-
}),
|
|
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),
|
|
289
314
|
/* @__PURE__ */ jsx(MetadataCard, {
|
|
290
315
|
title: "Details",
|
|
291
|
-
rows: ["
|
|
292
|
-
const field = model.fields[name];
|
|
293
|
-
return field && visible(name) ? [{
|
|
316
|
+
rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
|
|
317
|
+
const field = name ? model.fields[name] : void 0;
|
|
318
|
+
return name && field && visible(name) ? [{
|
|
294
319
|
label: hint(name)?.label ?? humanize(name),
|
|
295
320
|
value: displayValue(field, stored.record[name], hint(name))
|
|
296
321
|
}] : [];
|
|
@@ -300,7 +325,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
300
325
|
title: "Related records",
|
|
301
326
|
children: /* @__PURE__ */ jsx(RelatedRecords, {
|
|
302
327
|
records: related.records,
|
|
303
|
-
onNavigate:
|
|
328
|
+
onNavigate: open
|
|
304
329
|
})
|
|
305
330
|
})
|
|
306
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
|
};
|