@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.
Files changed (74) hide show
  1. package/dist/app/app.js +67 -28
  2. package/dist/app/batch-failure.js +1 -1
  3. package/dist/app/bottom-bar-from-meta.js +37 -0
  4. package/dist/app/breadcrumb-for.js +18 -9
  5. package/dist/app/create/create-page.js +12 -6
  6. package/dist/app/current-address.d.ts +26 -0
  7. package/dist/app/current-address.js +41 -0
  8. package/dist/app/custom-section.js +37 -0
  9. package/dist/app/delete/record-diff.js +1 -1
  10. package/dist/app/displays/cards-display.js +76 -0
  11. package/dist/app/displays/custom-display.js +28 -0
  12. package/dist/app/displays/display-context.d.ts +21 -0
  13. package/dist/app/displays/display-context.js +21 -0
  14. package/dist/app/displays/display-controls.js +55 -0
  15. package/dist/app/displays/display-frame.js +30 -0
  16. package/dist/app/displays/list-displays.d.ts +11 -0
  17. package/dist/app/displays/list-displays.js +62 -0
  18. package/dist/app/displays/record-link.d.ts +19 -0
  19. package/dist/app/displays/record-link.js +25 -0
  20. package/dist/app/displays/use-display-rows.js +28 -0
  21. package/dist/app/field-editor.js +4 -4
  22. package/dist/app/index.html +3 -2
  23. package/dist/app/list-page.js +87 -37
  24. package/dist/app/main.js +3 -2
  25. package/dist/app/nav-from-meta.js +9 -2
  26. package/dist/app/nav-recent.js +6 -2
  27. package/dist/app/pages/action-href.js +1 -3
  28. package/dist/app/pages/blocks/card-row.js +1 -1
  29. package/dist/app/pages/blocks/link.js +20 -11
  30. package/dist/app/pages/blocks/modal-form.js +1 -1
  31. package/dist/app/pages/blocks/record-card.js +1 -1
  32. package/dist/app/pages/blocks/stat.js +7 -4
  33. package/dist/app/pages/blocks/table.js +1 -1
  34. package/dist/app/pages/field-value.js +8 -4
  35. package/dist/app/pages/merge-ui.js +3 -1
  36. package/dist/app/pages/problem.js +10 -0
  37. package/dist/app/pages/project-ui.d.ts +12 -4
  38. package/dist/app/pages/record-actions.js +6 -3
  39. package/dist/app/pages/record-table.js +7 -3
  40. package/dist/app/pages/render-node.js +3 -6
  41. package/dist/app/pages/ui-from-module.d.ts +9 -0
  42. package/dist/app/pages/ui-from-module.js +23 -0
  43. package/dist/app/pages/use-run-action.js +7 -7
  44. package/dist/app/record-page.js +51 -26
  45. package/dist/app/related-section.js +1 -1
  46. package/dist/app/router.js +4 -20
  47. package/dist/app/search/search-model.js +6 -2
  48. package/dist/app/search/use-global-search.js +1 -1
  49. package/dist/app/use-app-address.d.ts +24 -0
  50. package/dist/app/use-app-address.js +47 -0
  51. package/dist/app/use-related-records.js +8 -2
  52. package/dist/app/user-menu-from-meta.js +9 -2
  53. package/dist/app-shell/app-shell.d.ts +1 -0
  54. package/dist/app-shell/app-shell.js +27 -14
  55. package/dist/app-shell/bottom-bar.d.ts +1 -0
  56. package/dist/app-shell/bottom-bar.js +57 -0
  57. package/dist/app-shell/index.d.ts +1 -0
  58. package/dist/app-shell/profile-menu.js +35 -26
  59. package/dist/app-shell/sidebar.js +3 -2
  60. package/dist/app-shell/switch-group.js +29 -0
  61. package/dist/auth/auth-provider.js +5 -1
  62. package/dist/auth/sign-in-form.js +23 -12
  63. package/dist/auth/sign-in-page.js +3 -2
  64. package/dist/data/meta-data.d.ts +3 -1
  65. package/dist/data/use-meta.js +1 -0
  66. package/dist/index.d.ts +7 -1
  67. package/dist/index.js +5 -1
  68. package/dist/live/audit-fields.js +5 -0
  69. package/dist/live/naming.js +2 -2
  70. package/dist/live/use-live-rows.js +7 -2
  71. package/dist/primitives/segmented.js +8 -5
  72. package/dist/roles/acting-user.js +11 -0
  73. package/dist/roles/use-active-role.js +33 -0
  74. 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 { Link } from "../router.js";
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(Link, {
27
- to: `/${target.name}/${encodeURIComponent(id)}`,
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 flatten = (ui, label) => [...(ui.extends ?? []).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
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 the router. */
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
- navigate: (to: string) => void;
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`); `name` names it in their errors.
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 { useRouter } from "../router.js";
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 { navigate } = useRouter();
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" && navigate(`/${model.name}`, { replace: true }));
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(Link, {
79
- to: `/${model.name}/${encodeURIComponent(scope.key)}`,
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, isAppPath } from "./action-href.js";
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 { navigate } = useRouter();
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
- const href = fillHref(run.href, scope?.record);
38
- if (isAppPath(href)) navigate(href);
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({
@@ -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, navigate } = useRouter();
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: () => navigate(`/${model.name}`, { replace: true }),
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 updated = stored.record.updatedAt ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record.updatedAt))) / 1e3)) : void 0;
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
- relationHref: related.hrefs[name],
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
- /* @__PURE__ */ jsx(MetadataCard, {
275
- title: "Summary",
276
- rows: sidebarFields.flatMap((name) => {
277
- const field = model.fields[name];
278
- if (!field || !visible(name)) return [];
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: ["createdAt", "updatedAt"].flatMap((name) => {
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: (href) => navigate(href)
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";
@@ -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 = to.startsWith(basePath) && basePath ? to : `${basePath}${to}`;
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, matchRoute, useNavigationGuard, useRouter };
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: `${basePath}/${target.model.name}/${encodeURIComponent(key)}`
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
- hrefs: Object.fromEntries(relations.map((field) => [field.name, `/${field.relation.resource}/${encodeURIComponent(String(record[field.name]))}`])),
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: `${basePath}/${field.relation.resource}/${encodeURIComponent(String(record[field.name]))}`
32
+ href: appPath(addresses[index], basePath)
27
33
  }))
28
34
  };
29
35
  };