@protobase/ui 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/app/app.d.ts +3 -0
  2. package/dist/app/app.js +84 -39
  3. package/dist/app/bottom-bar-from-meta.js +9 -2
  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 +41 -0
  9. package/dist/app/displays/cards-display.js +76 -0
  10. package/dist/app/displays/custom-display.js +32 -0
  11. package/dist/app/displays/display-context.d.ts +21 -0
  12. package/dist/app/displays/display-context.js +21 -0
  13. package/dist/app/displays/display-controls.js +55 -0
  14. package/dist/app/displays/display-frame.js +30 -0
  15. package/dist/app/displays/list-displays.d.ts +11 -0
  16. package/dist/app/displays/list-displays.js +62 -0
  17. package/dist/app/displays/record-link.d.ts +19 -0
  18. package/dist/app/displays/record-link.js +25 -0
  19. package/dist/app/displays/use-display-rows.js +28 -0
  20. package/dist/app/field-editor.js +4 -4
  21. package/dist/app/list-page.js +87 -37
  22. package/dist/app/main.js +3 -2
  23. package/dist/app/nav-from-meta.js +9 -2
  24. package/dist/app/nav-recent.js +6 -2
  25. package/dist/app/pages/action-href.js +1 -3
  26. package/dist/app/pages/blocks/card-row.js +1 -1
  27. package/dist/app/pages/blocks/link.js +20 -11
  28. package/dist/app/pages/blocks/modal-form.js +1 -1
  29. package/dist/app/pages/blocks/record-card.js +1 -1
  30. package/dist/app/pages/blocks/stat.js +7 -4
  31. package/dist/app/pages/blocks/table.js +1 -1
  32. package/dist/app/pages/custom-boundary.js +17 -0
  33. package/dist/app/pages/field-value.js +8 -4
  34. package/dist/app/pages/merge-ui.js +9 -3
  35. package/dist/app/pages/problem.js +10 -0
  36. package/dist/app/pages/project-ui.d.ts +18 -4
  37. package/dist/app/pages/project-ui.js +8 -2
  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 +10 -9
  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 +45 -22
  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/data/meta-data.d.ts +3 -1
  54. package/dist/data/use-meta.js +2 -1
  55. package/dist/index.d.ts +10 -2
  56. package/dist/index.js +8 -2
  57. package/dist/live/use-live-rows.js +7 -2
  58. package/dist/package.js +5 -0
  59. package/dist/plugins/load-plugin.d.ts +16 -0
  60. package/dist/plugins/load-plugin.js +73 -0
  61. package/dist/plugins/merge-plugins.js +36 -0
  62. package/dist/plugins/plugin-api.d.ts +10 -0
  63. package/dist/plugins/plugin-api.js +12 -0
  64. package/dist/plugins/plugin-context.d.ts +14 -0
  65. package/dist/plugins/plugin-context.js +12 -0
  66. package/dist/plugins/plugin-gate.js +35 -0
  67. package/dist/plugins/plugin-manifest.js +23 -0
  68. package/dist/plugins/plugin-notice.js +45 -0
  69. package/dist/plugins/plugin-ui.js +37 -0
  70. package/dist/plugins/use-plugin-loads.js +57 -0
  71. package/dist/primitives/segmented.js +8 -5
  72. package/dist/styles.css +1 -35
  73. package/dist/theme.css +39 -0
  74. package/package.json +9 -5
  75. package/dist/app/pages/project-ui-provider.js +0 -15
@@ -1,5 +1,5 @@
1
- import { Link } from "../../router.js";
2
1
  import { useAdminMeta } from "../../meta-gate.js";
2
+ import { AppLink } from "../../use-app-address.js";
3
3
  import { Skeleton } from "../../../primitives/skeleton.js";
4
4
  import { useLayoutRecord } from "../record-scope.js";
5
5
  import { useCount } from "../use-layout-data.js";
@@ -17,9 +17,12 @@ const Count = ({ model, filter }) => {
17
17
  className: "text-[13px] text-danger-text",
18
18
  children: "Could not count"
19
19
  });
20
- const href = `/${model.name}${filter ? `?filter=${encodeURIComponent(filter)}` : ""}`;
21
- return /* @__PURE__ */ jsx(Link, {
22
- to: href,
20
+ return /* @__PURE__ */ jsx(AppLink, {
21
+ to: {
22
+ kind: "list",
23
+ resource: model.name,
24
+ filter
25
+ },
23
26
  className: `${big} hover:text-primary-text`,
24
27
  children: count.data.toLocaleString("en-IE")
25
28
  });
@@ -1,5 +1,5 @@
1
- import { ErrorBanner } from "../../error-banner.js";
2
1
  import { useAdminMeta } from "../../meta-gate.js";
2
+ import { ErrorBanner } from "../../error-banner.js";
3
3
  import { Skeleton } from "../../../primitives/skeleton.js";
4
4
  import { useListPage } from "../use-layout-data.js";
5
5
  import { propsOf } from "./block-props.js";
@@ -0,0 +1,17 @@
1
+ import { Problem } from "./problem.js";
2
+ import { Component } from "react";
3
+ import { jsxs } from "react/jsx-runtime";
4
+ //#region src/app/pages/custom-boundary.tsx
5
+ /** Draws a warning in place of a project's or plugin's component that throws, so one component cannot blank the app. */
6
+ var CustomBoundary = class extends Component {
7
+ state = { failed: false };
8
+ static getDerivedStateFromError() {
9
+ return { failed: true };
10
+ }
11
+ render() {
12
+ if (!this.state.failed) return this.props.children;
13
+ return this.props.fallback === void 0 ? /* @__PURE__ */ jsxs(Problem, { children: [this.props.name, " could not be drawn."] }) : this.props.fallback;
14
+ }
15
+ };
16
+ //#endregion
17
+ export { CustomBoundary };
@@ -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,12 @@
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
+ /** Two UI configs define one component or action handler differently. */
5
+ var UiConflictError = class extends Error {
6
+ name = "UiConflictError";
7
+ };
8
+ const configOf = (parent) => Object.keys(parent).some((key) => key === "default" || /^[A-Z]/.test(key)) ? uiFromModule(parent) : parent;
9
+ const flatten = (ui, label) => [...(ui.extends ?? []).map(configOf).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
4
10
  ui,
5
11
  label
6
12
  }];
@@ -8,7 +14,7 @@ const union = (kind, named, pick) => {
8
14
  const merged = {};
9
15
  const owners = /* @__PURE__ */ new Map();
10
16
  for (const { ui, label } of named) for (const [name, value] of Object.entries(pick(ui) ?? {})) {
11
- if (Object.hasOwn(merged, name) && merged[name] !== value) throw new Error(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
17
+ if (Object.hasOwn(merged, name) && merged[name] !== value) throw new UiConflictError(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
12
18
  merged[name] = value;
13
19
  owners.set(name, owners.get(name) ?? label);
14
20
  }
@@ -42,4 +48,4 @@ const mergeUi = (...uis) => {
42
48
  };
43
49
  };
44
50
  //#endregion
45
- export { mergeUi };
51
+ export { UiConflictError, mergeUi };
@@ -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,17 @@ 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;
41
+ /**
42
+ * Typed identity, for `export default definePlugin({ name, components, actions, shell })` in a plugin's
43
+ * `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime instead of building it in, its components
44
+ * exported by name as in a project's.
45
+ */
46
+ export declare const definePlugin: (plugin: ProjectUi) => ProjectUi;
33
47
  //#endregion
@@ -2,8 +2,14 @@ import { createContext, useContext } from "react";
2
2
  //#region src/app/pages/project-ui.tsx
3
3
  /** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
4
4
  const defineUi = (ui) => ui;
5
- /** The project's UI, merged with the UI configs it extends; `ProjectUiProvider` provides it. */
5
+ /**
6
+ * Typed identity, for `export default definePlugin({ name, components, actions, shell })` in a plugin's
7
+ * `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime instead of building it in, its components
8
+ * exported by name as in a project's.
9
+ */
10
+ const definePlugin = (plugin) => plugin;
11
+ /** The project's UI, merged with the UI configs it extends and the plugins loaded; `PluginGate` or `ProjectUiProvider` provides it. */
6
12
  const ProjectUiContext = createContext({});
7
13
  const useProjectUi = () => useContext(ProjectUiContext);
8
14
  //#endregion
9
- export { ProjectUiContext, defineUi, useProjectUi };
15
+ export { ProjectUiContext, definePlugin, defineUi, useProjectUi };
@@ -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,6 @@
1
1
  import { useProjectUi } from "./project-ui.js";
2
+ import { Problem } from "./problem.js";
3
+ import { CustomBoundary } from "./custom-boundary.js";
2
4
  import { ActionBlock } from "./blocks/action.js";
3
5
  import { CardBlock } from "./blocks/card.js";
4
6
  import { CardRowBlock } from "./blocks/card-row.js";
@@ -14,6 +16,7 @@ import { StatBlock } from "./blocks/stat.js";
14
16
  import { TableBlock } from "./blocks/table.js";
15
17
  import { Fragment } from "react";
16
18
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
19
+ import { unmarkFieldRefs } from "@protobase/schema";
17
20
  import { isLayoutNode } from "@protobase/layout";
18
21
  //#region src/app/pages/render-node.tsx
19
22
  const blocks = {
@@ -31,11 +34,6 @@ const blocks = {
31
34
  Show: ShowBlock,
32
35
  Link: LinkBlock
33
36
  };
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
37
  const isInline = (child) => typeof child === "string" || !child.custom && child.type === "Link";
40
38
  /** Text and links run together as one paragraph, like a sentence with a link in it; other elements stand alone. */
41
39
  const renderChildren = (children) => {
@@ -65,11 +63,14 @@ const RenderNode = ({ node }) => {
65
63
  if (!Custom) return /* @__PURE__ */ jsxs(Problem, { children: [
66
64
  "No component is registered as ",
67
65
  node.type,
68
- ". Add it to the components in protobase.ui.tsx."
66
+ ". Export it from protobase.ui.tsx, or load the plugin that provides it."
69
67
  ] });
70
- return /* @__PURE__ */ jsx(Custom, {
71
- ...node.props,
72
- children: node.children.length > 0 ? renderChildren(node.children) : void 0
68
+ return /* @__PURE__ */ jsx(CustomBoundary, {
69
+ name: node.type,
70
+ children: /* @__PURE__ */ jsx(Custom, {
71
+ ...unmarkFieldRefs(node.props),
72
+ children: node.children.length > 0 ? renderChildren(node.children) : void 0
73
+ })
73
74
  });
74
75
  }
75
76
  const Block = blocks[node.type];
@@ -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";
@@ -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, navigate } = useRouter();
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: () => navigate(`/${model.name}`, { replace: true }),
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
- relationHref: related.hrefs[name],
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
- /* @__PURE__ */ jsx(MetadataCard, {
277
- title: "Summary",
278
- rows: sidebarFields.flatMap((name) => {
279
- const field = model.fields[name];
280
- if (!field || !visible(name)) return [];
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: (href) => navigate(href)
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";
@@ -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 };