@protobase/ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/app/app.js +54 -26
  2. package/dist/app/bottom-bar-from-meta.js +9 -2
  3. package/dist/app/breadcrumb-for.js +18 -9
  4. package/dist/app/create/create-page.js +12 -6
  5. package/dist/app/current-address.d.ts +26 -0
  6. package/dist/app/current-address.js +41 -0
  7. package/dist/app/custom-section.js +37 -0
  8. package/dist/app/displays/cards-display.js +76 -0
  9. package/dist/app/displays/custom-display.js +28 -0
  10. package/dist/app/displays/display-context.d.ts +21 -0
  11. package/dist/app/displays/display-context.js +21 -0
  12. package/dist/app/displays/display-controls.js +55 -0
  13. package/dist/app/displays/display-frame.js +30 -0
  14. package/dist/app/displays/list-displays.d.ts +11 -0
  15. package/dist/app/displays/list-displays.js +62 -0
  16. package/dist/app/displays/record-link.d.ts +19 -0
  17. package/dist/app/displays/record-link.js +25 -0
  18. package/dist/app/displays/use-display-rows.js +28 -0
  19. package/dist/app/field-editor.js +4 -4
  20. package/dist/app/list-page.js +87 -37
  21. package/dist/app/main.js +3 -2
  22. package/dist/app/nav-from-meta.js +9 -2
  23. package/dist/app/nav-recent.js +6 -2
  24. package/dist/app/pages/action-href.js +1 -3
  25. package/dist/app/pages/blocks/card-row.js +1 -1
  26. package/dist/app/pages/blocks/link.js +20 -11
  27. package/dist/app/pages/blocks/modal-form.js +1 -1
  28. package/dist/app/pages/blocks/record-card.js +1 -1
  29. package/dist/app/pages/blocks/stat.js +7 -4
  30. package/dist/app/pages/blocks/table.js +1 -1
  31. package/dist/app/pages/field-value.js +8 -4
  32. package/dist/app/pages/merge-ui.js +3 -1
  33. package/dist/app/pages/problem.js +10 -0
  34. package/dist/app/pages/project-ui.d.ts +12 -4
  35. package/dist/app/pages/record-actions.js +6 -3
  36. package/dist/app/pages/record-table.js +7 -3
  37. package/dist/app/pages/render-node.js +3 -6
  38. package/dist/app/pages/ui-from-module.d.ts +9 -0
  39. package/dist/app/pages/ui-from-module.js +23 -0
  40. package/dist/app/pages/use-run-action.js +7 -7
  41. package/dist/app/record-page.js +45 -22
  42. package/dist/app/related-section.js +1 -1
  43. package/dist/app/router.js +4 -20
  44. package/dist/app/search/search-model.js +6 -2
  45. package/dist/app/search/use-global-search.js +1 -1
  46. package/dist/app/use-app-address.d.ts +24 -0
  47. package/dist/app/use-app-address.js +47 -0
  48. package/dist/app/use-related-records.js +8 -2
  49. package/dist/app/user-menu-from-meta.js +9 -2
  50. package/dist/index.d.ts +7 -1
  51. package/dist/index.js +5 -1
  52. package/dist/live/use-live-rows.js +7 -2
  53. package/dist/primitives/segmented.js +8 -5
  54. package/package.json +4 -4
@@ -1,7 +1,8 @@
1
1
  import { ComponentType } from "react";
2
2
  import { Client } from "@protobase/client";
3
+ import { AppAddress } from "@protobase/schema";
3
4
  //#region src/app/pages/project-ui.d.ts
4
- /** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API and 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";
@@ -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 };
@@ -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
  };
@@ -2,6 +2,7 @@ import { humanize } from "../live/naming.js";
2
2
  import { iconFor } from "./icons.js";
3
3
  import { menuIcon } from "./menu-icons.js";
4
4
  import { ExternalLink, LayoutDashboard } from "lucide-react";
5
+ import { appPath } from "@protobase/schema";
5
6
  //#region src/app/user-menu-from-meta.ts
6
7
  /** The resources and pages the user menu holds; the sidebar leaves them out. */
7
8
  const userMenuResources = (meta) => new Set((meta.userMenu?.items ?? []).flatMap((item) => item.kind === "resource" ? [item.resource] : item.kind === "page" ? [item.page] : []));
@@ -20,7 +21,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
20
21
  id: item.page,
21
22
  label: item.label ?? page?.title ?? humanize(item.page),
22
23
  icon: menuIcon(item.icon ?? page?.icon, LayoutDashboard),
23
- href: `${basePath}/${item.page}`,
24
+ href: appPath({
25
+ kind: "page",
26
+ page: item.page
27
+ }, basePath),
24
28
  active: item.page === activeResource
25
29
  };
26
30
  }
@@ -28,7 +32,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
28
32
  id: item.resource,
29
33
  label: item.label ?? meta.views[item.resource]?.names?.plural ?? humanize(item.resource),
30
34
  icon: menuIcon(item.icon, iconFor(item.resource)),
31
- href: `${basePath}/${item.resource}`,
35
+ href: appPath({
36
+ kind: "list",
37
+ resource: item.resource
38
+ }, basePath),
32
39
  active: item.resource === activeResource
33
40
  };
34
41
  });
package/dist/index.d.ts CHANGED
@@ -9,7 +9,13 @@ import { useRecord } from "./data/use-record.js";
9
9
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
10
10
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
11
11
  import { mergeUi } from "./app/pages/merge-ui.js";
12
+ import { uiFromModule } from "./app/pages/ui-from-module.js";
12
13
  import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
14
+ import { CurrentAddress, ListAddress, ListChange } from "./app/current-address.js";
15
+ import { AppLink, useAppAddress } from "./app/use-app-address.js";
16
+ import { ListDisplayRow } from "./app/displays/list-displays.js";
17
+ import { ListDisplay, useListDisplay } from "./app/displays/display-context.js";
18
+ import { RecordLink } from "./app/displays/record-link.js";
13
19
  import { useApiToken } from "./auth/use-api-token.js";
14
20
  import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
15
21
  import { PartView } from "./assistant-dock/part-view.js";
@@ -30,4 +36,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
30
36
  import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
31
37
  import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
32
38
  import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
33
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type MetaData, PartView, type ProjectUi, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
39
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type ProjectUi, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -18,10 +18,14 @@ import { AssistantDock } from "./assistant-dock/assistant-dock.js";
18
18
  import { useAssistant } from "./assistant-dock/use-assistant.js";
19
19
  import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
20
20
  import { useMeta } from "./data/use-meta.js";
21
+ import { AppLink, useAppAddress } from "./app/use-app-address.js";
21
22
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
23
+ import { useListDisplay } from "./app/displays/display-context.js";
22
24
  import { useLayoutRecord } from "./app/pages/record-scope.js";
25
+ import { uiFromModule } from "./app/pages/ui-from-module.js";
23
26
  import { mergeUi } from "./app/pages/merge-ui.js";
24
27
  import { useList } from "./data/use-list.js";
25
28
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
26
29
  import { App } from "./app/app.js";
27
- export { ActionCard, ApiProvider, App, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
30
+ import { RecordLink } from "./app/displays/record-link.js";
31
+ export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
@@ -4,7 +4,10 @@ import { createGridLoader } from "./grid-loader.js";
4
4
  import { useMemo } from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  //#region src/live/use-live-rows.ts
7
- /** A grid row source backed by the API; `total` is the server's estimate from the first page. */
7
+ /**
8
+ * A grid row source backed by the API; `total` is the server's estimate from the first page. `page` loads one page of
9
+ * the same rows by number, and `key` names them in the query cache, for list displays that page through them.
10
+ */
8
11
  const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
9
12
  const client = useClient();
10
13
  const queryClient = useQueryClient();
@@ -38,7 +41,9 @@ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
38
41
  loadRows: loader.loadRows
39
42
  } : void 0, [first.data, loader]),
40
43
  error: first.error,
41
- isLoading: first.isLoading
44
+ isLoading: first.isLoading,
45
+ page: loader.page,
46
+ key: [...keys.first(model.name, filter, orderBy), columnKey]
42
47
  };
43
48
  };
44
49
  //#endregion