@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
@@ -0,0 +1,62 @@
1
+ import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { encodeKey, tableDisplay } from "@protobase/schema";
4
+ //#region src/app/displays/list-displays.ts
5
+ const displaysOf = (view) => view?.list?.displays ?? [];
6
+ /** The display a list shows for the address's `name`: `undefined` for the table, and the view's default for a name it does not have. */
7
+ const shownDisplay = (view, name) => {
8
+ if (name === tableDisplay) return void 0;
9
+ const named = (wanted) => displaysOf(view).find((display) => display.name === wanted);
10
+ return named(name) ?? named(view?.list?.default);
11
+ };
12
+ const displayChoices = (view) => [{
13
+ name: tableDisplay,
14
+ label: "Table",
15
+ icon: "table"
16
+ }, ...displaysOf(view).map((display) => ({
17
+ name: display.name,
18
+ label: display.label ?? humanize(display.name),
19
+ icon: display.icon ?? (display.kind === "cards" ? "cards" : void 0)
20
+ }))];
21
+ /** The fields a cards display shows, its title the view's title field when it names none. */
22
+ const cardFields = (model, view, display) => ({
23
+ title: display.title ?? recordTitleField(model, view),
24
+ subtitle: display.subtitle,
25
+ badge: display.badge,
26
+ meta: display.meta ?? []
27
+ });
28
+ /** Every field a display shows, the fields its rows are loaded and labelled for. */
29
+ const shownFields = (model, view, display) => {
30
+ if (display.kind === "custom") return display.fields;
31
+ const { title, subtitle, badge, meta } = cardFields(model, view, display);
32
+ return [
33
+ title,
34
+ subtitle,
35
+ badge,
36
+ ...meta
37
+ ].filter((name) => name !== void 0);
38
+ };
39
+ /** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
40
+ const loadedFields = (model, view, columns) => [.../* @__PURE__ */ new Set([...columns, ...displaysOf(view).flatMap((display) => shownFields(model, view, display))])];
41
+ /** The sortable fields a display shows, for the sort menu that stands in for column headers. */
42
+ const sortFields = (model, view, display) => [...new Set(shownFields(model, view, display))].filter((name) => model.fields[name]?.sortable);
43
+ const isLabelled = (value) => typeof value === "object" && value !== null && "id" in value && "name" in value;
44
+ /** A loaded row, its relations as `{ id, name }`, split into the record as the API gives it and the relations' names. */
45
+ const displayRow = (model, row) => {
46
+ const record = {};
47
+ const labels = {};
48
+ for (const [name, value] of Object.entries(row)) {
49
+ if (name === "id" && !Object.hasOwn(model.fields, "id")) continue;
50
+ if (model.fields[name]?.type === "relation" && isLabelled(value)) {
51
+ record[name] = value.id;
52
+ labels[name] = value.name;
53
+ } else record[name] = value;
54
+ }
55
+ return {
56
+ key: encodeKey(recordKey(model, record)),
57
+ record,
58
+ labels
59
+ };
60
+ };
61
+ //#endregion
62
+ export { cardFields, displayChoices, displayRow, displaysOf, loadedFields, shownDisplay, shownFields, sortFields };
@@ -0,0 +1,19 @@
1
+ import { ListDisplayRow } from "./list-displays.js";
2
+ import { AnchorHTMLAttributes } from "react";
3
+ import { KeyValue } from "@protobase/schema";
4
+ //#region src/app/displays/record-link.d.ts
5
+ type Target = {
6
+ row: ListDisplayRow;
7
+ resource?: never;
8
+ recordKey?: never;
9
+ } | {
10
+ row?: never;
11
+ resource?: string;
12
+ recordKey: KeyValue | readonly KeyValue[];
13
+ };
14
+ /**
15
+ * A link to a record's page, through the app's addresses: a list display's `row`, or a `recordKey` of `resource`. The
16
+ * resource defaults to the list display's or the record's around it, so a custom component never writes a path.
17
+ */
18
+ export declare const RecordLink: ({ row, resource, recordKey, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & Target) => import("react").JSX.Element;
19
+ //#endregion
@@ -0,0 +1,25 @@
1
+ import { AppLink } from "../use-app-address.js";
2
+ import { useOptionalListDisplay } from "./display-context.js";
3
+ import { useLayoutRecord } from "../pages/record-scope.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/displays/record-link.tsx
6
+ /**
7
+ * A link to a record's page, through the app's addresses: a list display's `row`, or a `recordKey` of `resource`. The
8
+ * resource defaults to the list display's or the record's around it, so a custom component never writes a path.
9
+ */
10
+ const RecordLink = ({ row, resource, recordKey, ...rest }) => {
11
+ const display = useOptionalListDisplay();
12
+ const around = useLayoutRecord();
13
+ const name = resource ?? display?.resource ?? around?.model.name;
14
+ if (name === void 0) throw new Error("RecordLink needs a resource outside a list display or a record");
15
+ return /* @__PURE__ */ jsx(AppLink, {
16
+ to: {
17
+ kind: "record",
18
+ resource: name,
19
+ key: row ? row.key : recordKey
20
+ },
21
+ ...rest
22
+ });
23
+ };
24
+ //#endregion
25
+ export { RecordLink };
@@ -0,0 +1,28 @@
1
+ import { displayRow } from "./list-displays.js";
2
+ import { useInfiniteQuery } from "@tanstack/react-query";
3
+ //#region src/app/displays/use-display-rows.ts
4
+ /**
5
+ * The list page's rows page by page, for a display other than the table: the same loader and pages as the table, so
6
+ * switching between them reads no row twice.
7
+ */
8
+ const useDisplayRows = (model, page, key, enabled) => {
9
+ const query = useInfiniteQuery({
10
+ queryKey: [...key, "display"],
11
+ enabled,
12
+ initialPageParam: 0,
13
+ queryFn: ({ pageParam }) => page(pageParam),
14
+ getNextPageParam: (last, pages) => last.rows.length === 40 && pages.length * 40 < (pages[0]?.total ?? 0) ? pages.length : void 0
15
+ });
16
+ const pages = query.data?.pages ?? [];
17
+ return {
18
+ resource: model.name,
19
+ rows: pages.flatMap((loaded) => loaded.rows.map((row) => displayRow(model, row))),
20
+ total: pages[0]?.total ?? 0,
21
+ loading: query.isPending,
22
+ error: query.error,
23
+ more: query.hasNextPage,
24
+ loadMore: () => void query.fetchNextPage()
25
+ };
26
+ };
27
+ //#endregion
28
+ export { useDisplayRows };
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Input } from "../primitives/input.js";
3
3
  import { Switch } from "../primitives/switch.js";
4
- import { Link } from "./router.js";
4
+ import { AppLink } from "./use-app-address.js";
5
5
  import { RelationPicker } from "./relation-picker.js";
6
6
  import { displayValue } from "../live/display-value.js";
7
7
  import { isEditable } from "../live/field-values.js";
@@ -15,7 +15,7 @@ const numeric = /* @__PURE__ */ new Set([
15
15
  "bigint",
16
16
  "decimal"
17
17
  ]);
18
- const FieldEditor = ({ field, hints, value, stored, relationLabel, relationHref, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
18
+ const FieldEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
19
19
  if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
20
20
  field,
21
21
  hints,
@@ -59,8 +59,8 @@ const FieldEditor = ({ field, hints, value, stored, relationLabel, relationHref,
59
59
  const label = relationLabel ?? String(stored ?? "—");
60
60
  return /* @__PURE__ */ jsx("div", {
61
61
  className: "py-1.5 text-[13px]",
62
- children: relationHref ? /* @__PURE__ */ jsx(Link, {
63
- to: relationHref,
62
+ children: relationTo ? /* @__PURE__ */ jsx(AppLink, {
63
+ to: relationTo,
64
64
  className: "font-medium text-primary-text hover:underline",
65
65
  children: label
66
66
  }) : label
@@ -2,11 +2,12 @@ import { useIsDesktop } from "../lib/use-media-query.js";
2
2
  import { PageHeader } from "../app-shell/page-header.js";
3
3
  import { useAuth } from "../auth/auth-provider.js";
4
4
  import { Button } from "../primitives/button.js";
5
- import { recordTitleField } from "../live/model-helpers.js";
5
+ import { recordKey, recordTitleField } from "../live/model-helpers.js";
6
6
  import { humanize } from "../live/naming.js";
7
- import { useRouter } from "./router.js";
8
- import { ErrorBanner } from "./error-banner.js";
7
+ import { parseSort, sortToOrderBy } from "./list-order.js";
9
8
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
9
+ import { useAppAddress } from "./use-app-address.js";
10
+ import { ErrorBanner } from "./error-banner.js";
10
11
  import { can, rowAllows } from "./permissions.js";
11
12
  import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
12
13
  import { DataGrid } from "../data-grid/data-grid.js";
@@ -21,42 +22,48 @@ import { useLiveRows } from "../live/use-live-rows.js";
21
22
  import { Sheet } from "../primitives/sheet.js";
22
23
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
23
24
  import { useRestore } from "./delete/use-restore.js";
25
+ import { loadedFields, shownDisplay } from "./displays/list-displays.js";
26
+ import { CardsDisplay } from "./displays/cards-display.js";
27
+ import { CustomDisplay } from "./displays/custom-display.js";
28
+ import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
29
+ import { useDisplayRows } from "./displays/use-display-rows.js";
24
30
  import { ListChart } from "./list-chart.js";
25
- import { defaultOrderBy, parseSort, sortToOrderBy } from "./list-order.js";
26
31
  import { ListSearch } from "./list-search.js";
27
32
  import { useMemo, useState } from "react";
28
33
  import { Plus, SlidersHorizontal } from "lucide-react";
29
34
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
30
- import { createWhere, encodeKey } from "@protobase/schema";
35
+ import { createWhere, tableDisplay } from "@protobase/schema";
31
36
  //#region src/app/list-page.tsx
32
37
  const where = createWhere();
33
38
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
34
- const ListPage = ({ resource }) => {
39
+ const ListPage = ({ address }) => {
35
40
  const { resources } = useAdminMeta();
36
- const model = resources[resource];
41
+ const model = resources[address.resource];
37
42
  if (!model) return /* @__PURE__ */ jsxs(Notice, {
38
43
  title: "Not found",
39
44
  children: [
40
45
  "There is no resource called ",
41
- resource,
46
+ address.resource,
42
47
  "."
43
48
  ]
44
49
  });
45
- return /* @__PURE__ */ jsx(LoadedList, { model });
50
+ return /* @__PURE__ */ jsx(LoadedList, {
51
+ model,
52
+ address
53
+ });
46
54
  };
47
- const LoadedList = ({ model }) => {
55
+ const LoadedList = ({ model, address }) => {
48
56
  const meta = useAdminMeta();
49
- const { params, setParams, navigate } = useRouter();
57
+ const { set, go } = useAppAddress();
50
58
  const resource = model.name;
51
59
  const view = meta.views[resource];
52
60
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
53
61
  const columns = useMemo(() => columnsFromModel(model, view), [model, view]);
54
- const filterText = params.get("filter") ?? "";
62
+ const filterText = address.filter;
55
63
  const parsed = useMemo(() => textToState(fields, filterText), [fields, filterText]);
56
64
  const state = parsed.ok ? parsed.state : emptyFilterState;
57
65
  const extras = parsed.ok ? parsed.extras : [];
58
- const orderBy = params.get("order_by") ?? defaultOrderBy(model, view);
59
- const layout = params.get("layout") === "panel" ? "panel" : "bar";
66
+ const { orderBy, filterLayout: layout } = address;
60
67
  const desktop = useIsDesktop();
61
68
  const [sheetOpen, setSheetOpen] = useState(false);
62
69
  const config = useFilterConfig({
@@ -67,14 +74,21 @@ const LoadedList = ({ model }) => {
67
74
  state,
68
75
  extras
69
76
  });
77
+ const loaded = useMemo(() => loadedFields(model, view, columns.map((column) => column.id)), [
78
+ model,
79
+ view,
80
+ columns
81
+ ]);
70
82
  const rows = useLiveRows({
71
83
  resources: meta.resources,
72
84
  views: meta.views,
73
85
  model,
74
- columns: columns.map((column) => column.id),
86
+ columns: loaded,
75
87
  filter: parsed.ok ? filterText : "",
76
88
  orderBy
77
89
  });
90
+ const display = shownDisplay(view, address.display);
91
+ const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0);
78
92
  const titleField = recordTitleField(model, view);
79
93
  const target = (row) => ({
80
94
  key: row.id,
@@ -90,9 +104,13 @@ const LoadedList = ({ model }) => {
90
104
  model,
91
105
  view,
92
106
  restore,
93
- onReview: (deleted) => navigate(`/${resource}/${encodeURIComponent(deleted.key)}`)
107
+ onReview: (deleted) => go({
108
+ kind: "record",
109
+ resource,
110
+ key: deleted.key
111
+ })
94
112
  });
95
- const setFilter = (next, nextExtras = extras) => setParams({ filter: stateToText(fields, next, nextExtras) || void 0 });
113
+ const setFilter = (next, nextExtras = extras) => set({ filter: stateToText(fields, next, nextExtras) });
96
114
  const search = searchText(extras);
97
115
  const commitSearch = (text) => {
98
116
  const others = extras.filter((clause) => clause.kind !== "search");
@@ -141,15 +159,20 @@ const LoadedList = ({ model }) => {
141
159
  })
142
160
  })]
143
161
  });
144
- const grid = rows.source ? /* @__PURE__ */ jsx(DataGrid, {
162
+ const openRecord = (row) => go({
163
+ kind: "record",
164
+ resource,
165
+ key: recordKey(model, row)
166
+ });
167
+ const table = rows.source ? /* @__PURE__ */ jsx(DataGrid, {
145
168
  columns,
146
169
  source: rows.source,
147
170
  sort: parseSort(orderBy),
148
- onSortChange: (sort) => setParams({ order_by: sortToOrderBy(sort) }),
149
- onOpenRow: (row) => navigate(`/${resource}/${encodeKey(row.id.split(","))}`),
171
+ onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
172
+ onOpenRow: openRecord,
150
173
  rowActions: [{
151
174
  label: `Open ${singular.toLowerCase()}`,
152
- onSelect: (row) => navigate(`/${resource}/${encodeKey(row.id.split(","))}`)
175
+ onSelect: openRecord
153
176
  }, ...can(permissions, "delete") ? [{
154
177
  label: "Delete",
155
178
  destructive: true,
@@ -165,6 +188,15 @@ const LoadedList = ({ model }) => {
165
188
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
166
189
  className: "min-h-0 flex-1"
167
190
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
191
+ const shown = !display ? table : display.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
192
+ model,
193
+ view,
194
+ display,
195
+ list: displayRows
196
+ }) : /* @__PURE__ */ jsx(CustomDisplay, {
197
+ display,
198
+ list: displayRows
199
+ });
168
200
  return /* @__PURE__ */ jsxs("div", {
169
201
  className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
170
202
  children: [
@@ -172,25 +204,43 @@ const LoadedList = ({ model }) => {
172
204
  /* @__PURE__ */ jsx(PageHeader, {
173
205
  title: plural,
174
206
  subtitle: view?.help,
175
- actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [model.search?.length ? /* @__PURE__ */ jsx(ListSearch, {
176
- value: search?.kind === "search" ? search.text : "",
177
- onCommit: commitSearch,
178
- placeholder: `Search ${plural.toLowerCase()}`
179
- }) : null, can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
180
- variant: "primary",
181
- onClick: () => navigate(`/${resource}/new`),
182
- children: [
183
- /* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
184
- "New ",
185
- singular.toLowerCase()
186
- ]
187
- })] })
207
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [
208
+ /* @__PURE__ */ jsx(DisplaySwitch, {
209
+ view,
210
+ value: display?.name ?? tableDisplay,
211
+ onChange: (name) => set({ display: name })
212
+ }),
213
+ display && /* @__PURE__ */ jsx(SortMenu, {
214
+ model,
215
+ view,
216
+ display,
217
+ orderBy,
218
+ onChange: (next) => set({ orderBy: next })
219
+ }),
220
+ model.search?.length ? /* @__PURE__ */ jsx(ListSearch, {
221
+ value: search?.kind === "search" ? search.text : "",
222
+ onCommit: commitSearch,
223
+ placeholder: `Search ${plural.toLowerCase()}`
224
+ }) : null,
225
+ can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
226
+ variant: "primary",
227
+ onClick: () => go({
228
+ kind: "create",
229
+ resource
230
+ }),
231
+ children: [
232
+ /* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
233
+ "New ",
234
+ singular.toLowerCase()
235
+ ]
236
+ })
237
+ ] })
188
238
  }),
189
239
  !parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
190
240
  title: "The filter in the address could not be read",
191
241
  error: new Error(parsed.errors.map((error) => error.message).join("; "))
192
242
  }),
193
- rows.error && /* @__PURE__ */ jsx(ErrorBanner, {
243
+ rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
194
244
  title: "Could not load rows",
195
245
  error: rows.error
196
246
  }),
@@ -204,8 +254,8 @@ const LoadedList = ({ model }) => {
204
254
  !desktop ? phoneFilters : layout === "bar" && panel,
205
255
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
206
256
  className: "flex min-h-0 flex-1 gap-4",
207
- children: [panel, grid]
208
- }) : grid
257
+ children: [panel, shown]
258
+ }) : shown
209
259
  ]
210
260
  });
211
261
  };
package/dist/app/main.js CHANGED
@@ -1,14 +1,15 @@
1
+ import { uiFromModule } from "./pages/ui-from-module.js";
1
2
  import { App } from "./app.js";
2
3
  import { StrictMode } from "react";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  import { createRoot } from "react-dom/client";
5
- import ui from "virtual:protobase-project-ui";
6
+ import * as projectUi from "virtual:protobase-project-ui";
6
7
  import "../styles.css";
7
8
  //#region src/app/main.tsx
8
9
  const root = document.getElementById("root");
9
10
  if (!root) throw new Error("index.html needs an element with id \"root\"");
10
11
  createRoot(root).render(/* @__PURE__ */ jsx(StrictMode, { children: /* @__PURE__ */ jsx(App, {
11
12
  baseUrl: "/api/v1",
12
- ui
13
+ ui: uiFromModule(projectUi)
13
14
  }) }));
14
15
  //#endregion
@@ -3,6 +3,7 @@ import { iconFor } from "./icons.js";
3
3
  import { menuIcon } from "./menu-icons.js";
4
4
  import { userMenuResources } from "./user-menu-from-meta.js";
5
5
  import { LayoutDashboard } from "lucide-react";
6
+ import { appPath } from "@protobase/schema";
6
7
  //#region src/app/nav-from-meta.ts
7
8
  const groupLabels = {
8
9
  sales: "Sales",
@@ -32,7 +33,10 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
32
33
  id: page.name,
33
34
  label: page.title,
34
35
  icon: menuIcon(page.icon, LayoutDashboard),
35
- href: `${basePath}/${page.name}`
36
+ href: appPath({
37
+ kind: "page",
38
+ page: page.name
39
+ }, basePath)
36
40
  });
37
41
  groups.set(groupName, group);
38
42
  orders.set(page.name, page.nav?.order ?? Number.MAX_SAFE_INTEGER);
@@ -50,7 +54,10 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
50
54
  id: model.name,
51
55
  label: view.names?.plural ?? humanize(model.name),
52
56
  icon: iconFor(model.name),
53
- href: `${basePath}/${model.name}`,
57
+ href: appPath({
58
+ kind: "list",
59
+ resource: model.name
60
+ }, basePath),
54
61
  ...recent[model.name] && { recent: recent[model.name] }
55
62
  });
56
63
  groups.set(groupName, group);
@@ -1,6 +1,6 @@
1
1
  import { recordKey, recordTitle, recordTitleField } from "../live/model-helpers.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { defaultRecentLimit, encodeKey, enumLabel } from "@protobase/schema";
3
+ import { appPath, defaultRecentLimit, encodeKey, enumLabel } from "@protobase/schema";
4
4
  //#region src/app/nav-recent.ts
5
5
  /** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows. */
6
6
  const recentRequest = (model, view, recent) => ({
@@ -21,7 +21,11 @@ const recentRecords = (model, view, recent, rows, basePath, activeKey) => {
21
21
  return {
22
22
  id: key,
23
23
  label: recordTitle(model, view, row, key),
24
- href: `${basePath}/${model.name}/${encodeURIComponent(key)}`,
24
+ href: appPath({
25
+ kind: "record",
26
+ resource: model.name,
27
+ key
28
+ }, basePath),
25
29
  status: model.fields[recent.status]?.type === "enum" && status ? enumLabel(status, view.fields[recent.status]?.valueLabels) : status,
26
30
  tone: recent.tones[status] ?? "neutral",
27
31
  pulse: recent.pulse?.includes(status) ?? false,
@@ -1,7 +1,5 @@
1
1
  //#region src/app/pages/action-href.ts
2
2
  /** `href` with every `{field}` replaced by the record's value, percent-encoded; a missing value becomes empty. */
3
3
  const fillHref = (href, record = {}) => href.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => encodeURIComponent(record[name] === null || record[name] === void 0 ? "" : String(record[name])));
4
- /** Paths in the app start with one `/`; anything else (`https:`, `mailto:`, `//host`) leaves the app. */
5
- const isAppPath = (href) => href.startsWith("/") && !href.startsWith("//");
6
4
  //#endregion
7
- export { fillHref, isAppPath };
5
+ export { fillHref };
@@ -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 { RecordScope } from "../record-scope.js";
5
5
  import { scopeOf, useListPage } from "../use-layout-data.js";
@@ -1,18 +1,27 @@
1
- import { Link } from "../../router.js";
2
- import { isAppPath } from "../action-href.js";
1
+ import { AppLink } from "../../use-app-address.js";
2
+ import { useLayoutRecord } from "../record-scope.js";
3
3
  import { propsOf } from "./block-props.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
+ import { appPath, fillAddress } from "@protobase/schema";
5
6
  //#region src/app/pages/blocks/link.tsx
6
7
  const style = "font-medium text-primary-text hover:underline";
7
- /** A path in the app goes through the router; other links (`mailto:`, `https:`) are ordinary anchors. Its text is its children, else the href. */
8
+ /**
9
+ * `to` an address in the app, through the router, its `{field}`s filled from the record around it; an `href` (`mailto:`,
10
+ * `https:`) is an ordinary anchor. Its text is its children, else where it leads.
11
+ */
8
12
  const LinkBlock = ({ node }) => {
9
- const { href } = propsOf(node);
10
- const label = node.children.filter((child) => typeof child === "string").join("") || href;
11
- if (isAppPath(href)) return /* @__PURE__ */ jsx(Link, {
12
- to: href,
13
- className: style,
14
- children: label
15
- });
13
+ const props = propsOf(node);
14
+ const record = useLayoutRecord()?.record;
15
+ const text = node.children.filter((child) => typeof child === "string").join("");
16
+ if (props.to) {
17
+ const to = fillAddress(props.to, record);
18
+ return /* @__PURE__ */ jsx(AppLink, {
19
+ to,
20
+ className: style,
21
+ children: text || appPath(to)
22
+ });
23
+ }
24
+ const href = props.href ?? "";
16
25
  return /* @__PURE__ */ jsx("a", {
17
26
  href,
18
27
  className: style,
@@ -20,7 +29,7 @@ const LinkBlock = ({ node }) => {
20
29
  target: "_blank",
21
30
  rel: "noreferrer"
22
31
  },
23
- children: label
32
+ children: text || href
24
33
  });
25
34
  };
26
35
  //#endregion
@@ -3,10 +3,10 @@ import { Dialog } from "../../../primitives/dialog.js";
3
3
  import { useClient } from "../../../data/api-provider.js";
4
4
  import { humanize } from "../../../live/naming.js";
5
5
  import { useToast } from "../../../toasts/toast-provider.js";
6
+ import { useAdminMeta, usePermissions } from "../../meta-gate.js";
6
7
  import { differs, toDraft, toPatchValue } from "../../../live/field-values.js";
7
8
  import { ErrorBanner } from "../../error-banner.js";
8
9
  import { FieldEditor } from "../../field-editor.js";
9
- import { useAdminMeta, usePermissions } from "../../meta-gate.js";
10
10
  import { can } from "../../permissions.js";
11
11
  import { Field } from "../../../record-view/field.js";
12
12
  import { createBody, isChosen } from "../../create/create-values.js";
@@ -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 { RecordScope } from "../record-scope.js";
5
5
  import { useFirstRecord } from "../use-layout-data.js";
@@ -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";
@@ -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 };