@protobase/ui 0.19.15 → 0.19.16

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 (68) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/displays/board-column.js +114 -0
  3. package/dist/app/displays/board-columns.js +45 -0
  4. package/dist/app/displays/board-display.js +96 -0
  5. package/dist/app/displays/board-dnd.js +32 -0
  6. package/dist/app/displays/list-displays.js +13 -1
  7. package/dist/app/displays/record-card.js +16 -5
  8. package/dist/app/displays/use-board-column.js +23 -0
  9. package/dist/app/displays/use-board-move.js +60 -0
  10. package/dist/app/displays/virtual-cards.js +1 -0
  11. package/dist/app/embedded-list/embedded-list.js +9 -0
  12. package/dist/app/export/export-options.js +2 -2
  13. package/dist/app/gallery/use-gallery-photos.js +2 -2
  14. package/dist/app/import/lookups.js +2 -2
  15. package/dist/app/list-edit/cell-field-editor.js +61 -0
  16. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  17. package/dist/app/list-edit/use-list-edits.js +75 -0
  18. package/dist/app/list-page.js +28 -10
  19. package/dist/app/map/map-area.js +6 -6
  20. package/dist/app/pages/action-href.js +4 -2
  21. package/dist/app/pages/use-run-action.js +4 -3
  22. package/dist/app/record-page.js +30 -16
  23. package/dist/app/record-preview.js +25 -0
  24. package/dist/app/record-row/record-row-body.js +2 -2
  25. package/dist/app/record-row/record-row.js +1 -0
  26. package/dist/app/related-dots/card-dots-lines.js +16 -0
  27. package/dist/app/related-dots/card-dots.js +34 -0
  28. package/dist/app/related-dots/dot-card.js +3 -3
  29. package/dist/app/related-dots/related-dots-columns.js +3 -1
  30. package/dist/app/related-dots/related-dots.js +22 -9
  31. package/dist/app/related-section.js +4 -4
  32. package/dist/app/relation-picker.js +2 -2
  33. package/dist/app/saved/saved-records.js +2 -2
  34. package/dist/app/search/search-model.js +2 -2
  35. package/dist/app/searches/saved-search.js +3 -3
  36. package/dist/app/searches/search-diff.js +8 -8
  37. package/dist/app/use-nav-recent.js +1 -2
  38. package/dist/data/api-provider.d.ts +2 -1
  39. package/dist/data/api-provider.js +12 -5
  40. package/dist/data/focus-refresh.js +44 -0
  41. package/dist/data/query-keys.js +13 -0
  42. package/dist/data/use-record.js +17 -2
  43. package/dist/data-grid/cell-moves.js +26 -0
  44. package/dist/data-grid/data-grid.js +32 -7
  45. package/dist/data-grid/grid-header.js +1 -1
  46. package/dist/data-grid/grid-row.js +17 -13
  47. package/dist/data-grid/page-cache.js +3 -0
  48. package/dist/data-grid/use-cell-editing.js +113 -0
  49. package/dist/data-grid/use-paged-rows.js +25 -4
  50. package/dist/live/field-values.js +3 -1
  51. package/dist/live/related-column-rows.js +3 -3
  52. package/dist/live/relation-labels.js +2 -2
  53. package/dist/live/use-live-rows.js +17 -4
  54. package/dist/live/use-reference-search.js +2 -2
  55. package/dist/package.js +1 -1
  56. package/dist/shell/assets/app-xQmLJM07.js +69 -0
  57. package/dist/shell/assets/index-CmJl5dIO.css +2 -0
  58. package/dist/shell/assets/{index-CzV9U_NJ.js → index-CmmYBR7Y.js} +1 -1
  59. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-Siom2Zwj.js} +1 -1
  60. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-ChyGEubR.js} +1 -1
  61. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-B_QnHF99.js} +1 -1
  62. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-CcHhEDBC.js} +1 -1
  63. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-zRmreKZS.js} +1 -1
  64. package/dist/shell/index.html +4 -4
  65. package/dist/shell/protobase.shell.json +1 -1
  66. package/package.json +4 -4
  67. package/dist/shell/assets/app-WromtADN.js +0 -69
  68. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
@@ -0,0 +1,30 @@
1
+ import { isEditable } from "../../live/field-values.js";
2
+ import { can, rowAllows } from "../permissions.js";
3
+ import { CellFieldEditor } from "./cell-field-editor.js";
4
+ import { useListEdits } from "./use-list-edits.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/list-edit/use-list-cell-editor.tsx
7
+ /**
8
+ * The list's cells edited in place: the view's `editable` columns, which `/meta` keeps to the fields the caller may
9
+ * write, of the rows the caller may change. `patches` are the values saved since the rows loaded, for the grid to show.
10
+ */
11
+ const useListCellEditor = (model, view, permissions) => {
12
+ const edits = useListEdits(model);
13
+ const columns = view?.list?.editable ?? [];
14
+ return {
15
+ editor: columns.length > 0 && can(permissions, "update") ? {
16
+ editable: (row, column) => columns.includes(column) && model.fields[column] !== void 0 && isEditable(model.fields[column]) && rowAllows(row, "update"),
17
+ render: (row, column, close) => /* @__PURE__ */ jsx(CellFieldEditor, {
18
+ field: model.fields[column],
19
+ hints: view?.fields[column],
20
+ resource: model.name,
21
+ stored: row[column],
22
+ onSave: (value) => void edits.save(row, column, value),
23
+ close
24
+ })
25
+ } : void 0,
26
+ patches: edits.patches
27
+ };
28
+ };
29
+ //#endregion
30
+ export { useListCellEditor };
@@ -0,0 +1,75 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordKey } from "../../live/model-helpers.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
+ import { useRef, useState } from "react";
6
+ import { isApiError } from "@protobase/client";
7
+ import { useQueryClient } from "@tanstack/react-query";
8
+ import { encodeKey } from "@protobase/schema/internal";
9
+ //#region src/app/list-edit/use-list-edits.ts
10
+ /**
11
+ * The list's edits in place: `patches` holds, by row id, the values saving or saved since the rows loaded, with the
12
+ * ETag each saved row has now. `save` sends one field as a PATCH with the row's ETag, after any save of the same row
13
+ * still under way. A refusal puts the old value back, or the record's current one when someone else changed it, and
14
+ * says why in a toast.
15
+ */
16
+ const useListEdits = (model) => {
17
+ const client = useClient();
18
+ const queryClient = useQueryClient();
19
+ const toast = useToast();
20
+ const [patches, setPatches] = useState(/* @__PURE__ */ new Map());
21
+ const current = useRef(patches);
22
+ const queues = useRef(/* @__PURE__ */ new Map());
23
+ const patch = (id, values) => {
24
+ current.current = new Map(current.current).set(id, {
25
+ ...current.current.get(id),
26
+ ...values
27
+ });
28
+ setPatches(current.current);
29
+ };
30
+ const send = async (row, name, value) => {
31
+ const key = recordKey(model, row);
32
+ const etag = String(current.current.get(row.id)?.etag ?? row.etag);
33
+ try {
34
+ const stored = await client.update(model.name, key, { [name]: value }, etag);
35
+ patch(row.id, {
36
+ [name]: stored.record[name],
37
+ etag: stored.etag
38
+ });
39
+ queryClient.setQueryData(keys.record(model.name, encodeKey(key)), stored);
40
+ queryClient.invalidateQueries({ queryKey: ["page", model.name] });
41
+ queryClient.invalidateQueries({ queryKey: ["list", model.name] });
42
+ queryClient.invalidateQueries({ queryKey: ["map", model.name] });
43
+ } catch (error) {
44
+ if (!isApiError(error)) {
45
+ patch(row.id, { [name]: row[name] });
46
+ throw error;
47
+ }
48
+ const changed = error.status === 412;
49
+ const latest = changed ? await client.get(model.name, key) : void 0;
50
+ patch(row.id, latest ? {
51
+ [name]: latest.record[name],
52
+ etag: latest.etag
53
+ } : { [name]: row[name] });
54
+ const reason = error.errors.find((entry) => entry.field === name)?.message ?? error.message;
55
+ toast.show({
56
+ state: "error",
57
+ title: changed ? "Changed by someone else" : "Could not save",
58
+ description: reason
59
+ });
60
+ }
61
+ };
62
+ /** Saves `value` as the field `name` of `row`, as the grid shows it before the edit. */
63
+ const save = (row, name, value) => {
64
+ patch(row.id, { [name]: value });
65
+ const queued = (queues.current.get(row.id) ?? Promise.resolve()).then(() => send(row, name, value));
66
+ queues.current.set(row.id, queued.catch(() => void 0));
67
+ return queued;
68
+ };
69
+ return {
70
+ patches,
71
+ save
72
+ };
73
+ };
74
+ //#endregion
75
+ export { useListEdits };
@@ -9,7 +9,7 @@ import { useProjectUi } from "./pages/project-ui.js";
9
9
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
10
  import { Sheet } from "../primitives/sheet.js";
11
11
  import { ErrorBanner } from "./error-banner.js";
12
- import { loadedFields, shownDisplay } from "./displays/list-displays.js";
12
+ import { displayRow, loadedFields, shownDisplay } from "./displays/list-displays.js";
13
13
  import { parseSort, sortToOrderBy } from "./list-order.js";
14
14
  import { listedResource } from "./current-address.js";
15
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
@@ -26,7 +26,11 @@ import { FilterPanel } from "../filter-panel/filter-panel.js";
26
26
  import { stateToText, textToState } from "../live/filter-string.js";
27
27
  import { widgetFields } from "../live/view-fields.js";
28
28
  import { facetNames, filterChips } from "./searches/saved-search.js";
29
+ import { useSeedRecord } from "./record-preview.js";
29
30
  import { SignInPrompt } from "../auth/sign-in-prompt.js";
31
+ import { relatedColumnLoads } from "./related-dots/dot-card.js";
32
+ import { useDisplayRows } from "./displays/use-display-rows.js";
33
+ import { BoardDisplay } from "./displays/board-display.js";
30
34
  import { CustomDisplay } from "./displays/custom-display.js";
31
35
  import { MapDisplay } from "./displays/map-display.js";
32
36
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
@@ -39,22 +43,21 @@ import { ImportButton } from "./import/import-button.js";
39
43
  import { exportOffered, exportsSelection } from "./export/export-options.js";
40
44
  import { useExportFlow } from "./export/use-export-flow.js";
41
45
  import { ListCards } from "./displays/list-cards.js";
42
- import { useDisplayRows } from "./displays/use-display-rows.js";
43
46
  import { useListScroll } from "./list-scroll.js";
44
47
  import { ListChart } from "./list-chart.js";
45
48
  import { ListSearch } from "./list-search.js";
46
- import { relatedColumnLoads } from "./related-dots/dot-card.js";
47
49
  import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
48
50
  import { SavedList } from "./saved/saved-list.js";
49
51
  import { SaveSearchButton } from "./searches/save-search-button.js";
50
52
  import { SavedSearches } from "./searches/saved-searches.js";
53
+ import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
51
54
  import { useMemo, useState } from "react";
52
55
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
53
56
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
54
57
  import { createWhere } from "@protobase/schema";
55
58
  import { tableDisplay } from "@protobase/schema/internal";
56
59
  //#region src/app/list-page.tsx
57
- const where = createWhere();
60
+ const where$1 = createWhere();
58
61
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
59
62
  /**
60
63
  * A resource's list: its rows, with `listOf` the records its relation points at (`SavedList`), or with `searchesOf` the
@@ -169,6 +172,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
169
172
  });
170
173
  const singular = view?.names?.singular ?? humanize(resource);
171
174
  const permissions = usePermissions(resource);
175
+ const cellEditing = useListCellEditor(model, view, permissions);
172
176
  const { state: auth } = useAuth();
173
177
  const restore = useRestore(model);
174
178
  const remove = useDeleteFlow({
@@ -185,7 +189,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
185
189
  const search = searchText(extras);
186
190
  const commitSearch = (text) => {
187
191
  const others = extras.filter((clause) => clause.kind !== "search");
188
- setFilter(state, text ? [...others, where.search(text)] : others);
192
+ setFilter(state, text ? [...others, where$1.search(text)] : others);
189
193
  };
190
194
  const plural = view?.names?.plural ?? humanize(resource);
191
195
  const sortMenu = display && /* @__PURE__ */ jsx(SortMenu, {
@@ -309,17 +313,23 @@ const LoadedList = ({ model, address, scope, heading }) => {
309
313
  onChange: (name) => set({ display: name }),
310
314
  menu
311
315
  });
312
- const openRecord = (row) => go({
313
- kind: "record",
314
- resource,
315
- key: recordKey(model, row)
316
- });
316
+ const seedRecord = useSeedRecord(model);
317
+ const openRecord = (row) => {
318
+ seedRecord(displayRow(model, row).record);
319
+ go({
320
+ kind: "record",
321
+ resource,
322
+ key: recordKey(model, row)
323
+ });
324
+ };
317
325
  const table = rows.source ? /* @__PURE__ */ jsx(DataGrid, {
318
326
  columns,
319
327
  source: rows.source,
320
328
  sort: parseSort(orderBy),
321
329
  onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
322
330
  onOpenRow: openRecord,
331
+ cellEditor: cellEditing.editor,
332
+ rowPatches: cellEditing.patches,
323
333
  initialScrollIndex: scroll.initialIndex,
324
334
  onScrollIndex: scroll.remember,
325
335
  rowActions: [{
@@ -368,6 +378,14 @@ const LoadedList = ({ model, address, scope, heading }) => {
368
378
  onMove: (map) => set({ map }),
369
379
  flush: true
370
380
  });
381
+ if (display.kind === "board") return /* @__PURE__ */ jsx(BoardDisplay, {
382
+ model,
383
+ view,
384
+ display,
385
+ fields: loaded,
386
+ filter: loadFilter,
387
+ orderBy
388
+ });
371
389
  return /* @__PURE__ */ jsx(CustomDisplay, {
372
390
  display,
373
391
  list: displayRows
@@ -1,6 +1,6 @@
1
1
  import { createWhere, printFilter } from "@protobase/schema";
2
2
  //#region src/app/map/map-area.ts
3
- const where = createWhere();
3
+ const where$1 = createWhere();
4
4
  /** The whole world from as far out as a map goes: what a map asks for before it knows where its records are. */
5
5
  const worldArea = {
6
6
  bounds: {
@@ -20,11 +20,11 @@ const wrapped = (longitude) => ((longitude + 180) % 360 + 360) % 360 - 180;
20
20
  * A view across the antimeridian takes the longitudes on both sides of it, and one around the whole world takes them all.
21
21
  */
22
22
  const boundsFilter = (fields, bounds) => {
23
- const latitude = [where.gte(fields.latitude, down(Math.max(bounds.south, -90))), where.lte(fields.latitude, up(Math.min(bounds.north, 90)))];
24
- if (bounds.east - bounds.west >= 360) return where.and(...latitude);
25
- const west = where.gte(fields.longitude, down(wrapped(bounds.west)));
26
- const east = where.lte(fields.longitude, up(wrapped(bounds.east)));
27
- return wrapped(bounds.west) <= wrapped(bounds.east) ? where.and(...latitude, west, east) : where.and(...latitude, where.or(west, east));
23
+ const latitude = [where$1.gte(fields.latitude, down(Math.max(bounds.south, -90))), where$1.lte(fields.latitude, up(Math.min(bounds.north, 90)))];
24
+ if (bounds.east - bounds.west >= 360) return where$1.and(...latitude);
25
+ const west = where$1.gte(fields.longitude, down(wrapped(bounds.west)));
26
+ const east = where$1.lte(fields.longitude, up(wrapped(bounds.east)));
27
+ return wrapped(bounds.west) <= wrapped(bounds.east) ? where$1.and(...latitude, west, east) : where$1.and(...latitude, where$1.or(west, east));
28
28
  };
29
29
  /** The list's filter, its search included, narrowed to the records inside the map's bounds. */
30
30
  const areaFilter = (listFilter, fields, bounds) => {
@@ -25,12 +25,14 @@ const openLink = (target) => {
25
25
  else window.location.assign(target);
26
26
  };
27
27
  const hostOf = (target) => URL.canParse(target) ? new URL(target).hostname.replace(/^www\./, "") : "";
28
+ const holdsAll = (record, values) => Object.entries(values).every(([name, value]) => Object.is(record[name], value));
28
29
  /**
29
- * `action` as it shows for `record`: a link whose `href` has no target there is left out, and `{host}` in a link's label
30
- * becomes the host its target opens, without `www.`.
30
+ * `action` as it shows for `record`: an update that would change nothing there, and a link whose `href` has no target
31
+ * there, are left out, and `{host}` in a link's label becomes the host its target opens, without `www.`.
31
32
  */
32
33
  const actionFor = (action, record) => {
33
34
  const run = action.run;
35
+ if (run?.kind === "update") return record && holdsAll(record, run.values) ? void 0 : action;
34
36
  if (run?.kind !== "link" || !("href" in run)) return action;
35
37
  const target = linkTarget(run.href, record);
36
38
  if (target === void 0) return void 0;
@@ -12,9 +12,10 @@ import { fillAddress } from "@protobase/schema/internal";
12
12
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
13
  /**
14
14
  * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
15
- * (`update`, `delete`, `link`), else the handler the project registered under the action's name. A link to a URL the
16
- * record holds is left out while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action
17
- * that succeeded. `submit` runs one with the answers to its inputs and throws its failure, for the dialog to show.
15
+ * (`update`, `delete`, `link`), else the handler the project registered under the action's name. An update whose values
16
+ * the record holds already is left out, and so is a link to a URL the record holds while it holds none. `blocked` says
17
+ * why one cannot run; `afterRun` hears about every action that succeeded. `submit` runs one with the answers to its
18
+ * inputs and throws its failure, for the dialog to show.
18
19
  */
19
20
  const useActionRunner = (resource, scope, afterRun) => {
20
21
  const { views } = useAdminMeta();
@@ -1,11 +1,13 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { Skeleton } from "../primitives/skeleton.js";
2
3
  import { inApp } from "../app-shell/breadcrumb.js";
3
4
  import { useAuth } from "../auth/auth-provider.js";
4
5
  import { Button, buttonClass } from "../primitives/button.js";
6
+ import { usePauseFocusRefresh } from "../data/focus-refresh.js";
5
7
  import { keys } from "../data/query-keys.js";
6
8
  import { useClient } from "../data/api-provider.js";
7
9
  import { useNavigationGuard, useRouter } from "./router.js";
8
- import { useRecord } from "../data/use-record.js";
10
+ import { useOpenedRecord } from "../data/use-record.js";
9
11
  import { recordTitle } from "../live/model-helpers.js";
10
12
  import { humanize } from "../live/naming.js";
11
13
  import { useProjectUi } from "./pages/project-ui.js";
@@ -16,6 +18,7 @@ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record
16
18
  import { RecordHeader } from "../record-view/record-header.js";
17
19
  import { SpecTable } from "../record-view/spec-table.js";
18
20
  import { KeyFacts } from "../record-view/key-facts.js";
21
+ import { isRequiredOnEdit } from "../live/field-values.js";
19
22
  import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
20
23
  import { ErrorBanner } from "./error-banner.js";
21
24
  import { displayValue } from "../live/display-value.js";
@@ -70,7 +73,7 @@ const layoutFor = (model, view, read) => {
70
73
  const RecordPage = ({ resource, recordKey }) => {
71
74
  const { resources } = useAdminMeta();
72
75
  const model = resources[resource];
73
- const record = useRecord(resource, recordKey);
76
+ const record = useOpenedRecord(resource, recordKey);
74
77
  if (!model) return /* @__PURE__ */ jsxs(Notice, {
75
78
  title: "Not found",
76
79
  children: [
@@ -96,10 +99,11 @@ const RecordPage = ({ resource, recordKey }) => {
96
99
  return /* @__PURE__ */ jsx(LoadedRecord, {
97
100
  model,
98
101
  recordKey,
99
- stored: record.data
100
- });
102
+ stored: record.data,
103
+ preview: record.isPlaceholderData
104
+ }, record.isPlaceholderData ? "preview" : "loaded");
101
105
  };
102
- const LoadedRecord = ({ model, recordKey, stored }) => {
106
+ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
103
107
  const meta = useAdminMeta();
104
108
  const { basePath } = useRouter();
105
109
  const { address, go, open } = useAppAddress();
@@ -115,7 +119,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
115
119
  recordKey,
116
120
  mode: view?.saveFeedback ?? "toast",
117
121
  subject: title,
118
- draftKey: auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
122
+ draftKey: !preview && auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
119
123
  lines: linesDraft
120
124
  });
121
125
  const queryClient = useQueryClient();
@@ -125,6 +129,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
125
129
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
126
130
  const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
127
131
  useNavigationGuard(editor.dirty && shown === "form");
132
+ usePauseFocusRefresh(editor.dirty);
128
133
  const readAddress = {
129
134
  kind: "record",
130
135
  resource: model.name,
@@ -156,6 +161,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
156
161
  const updatedAt = auditField(model, "updated");
157
162
  const hint = (name) => view?.fields[name];
158
163
  const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden" && whenShown(hint(name)?.when, editing);
164
+ const known = (name) => !preview || Object.hasOwn(stored.record, name);
159
165
  const fieldView = (name) => {
160
166
  const field = model.fields[name];
161
167
  const access = fieldAccess(stored.permissions, name);
@@ -163,6 +169,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
163
169
  const conflict = editor.conflicts[name];
164
170
  return /* @__PURE__ */ jsx(Field, {
165
171
  label: hint(name)?.label ?? humanize(name),
172
+ required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
166
173
  help: fieldHelp(hint(name), view, editing),
167
174
  error: editor.errors[name],
168
175
  wide: Boolean(hint(name)?.multiline || hint(name)?.markdown),
@@ -177,7 +184,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
177
184
  onKeepMine: () => editor.keepMine(name),
178
185
  onUseTheirs: () => editor.useTheirs(name)
179
186
  },
180
- children: /* @__PURE__ */ jsx(FieldEditor, {
187
+ children: known(name) ? /* @__PURE__ */ jsx(FieldEditor, {
181
188
  field,
182
189
  hints: hint(name),
183
190
  value: editor.value(name),
@@ -189,12 +196,12 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
189
196
  reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
190
197
  resource: model.name,
191
198
  onChange: (next) => editor.change(name, next)
192
- })
199
+ }) : /* @__PURE__ */ jsx(Skeleton, { className: "h-9" })
193
200
  }, field.sensitive ? `${name}:${stored.etag}` : name);
194
201
  };
195
202
  const readRow = (name) => {
196
203
  const field = model.fields[name];
197
- const value = field.sensitive ? /* @__PURE__ */ jsx(FieldEditor, {
204
+ const value = !known(name) ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-32" }) : field.sensitive ? /* @__PURE__ */ jsx(FieldEditor, {
198
205
  field,
199
206
  hints: hint(name),
200
207
  value: "",
@@ -222,6 +229,10 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
222
229
  rows: sidebarFields.flatMap((name) => {
223
230
  const field = model.fields[name];
224
231
  if (!field || !visible(name)) return [];
232
+ if (!known(name)) return [{
233
+ label: hint(name)?.label ?? humanize(name),
234
+ value: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
235
+ }];
225
236
  const text = displayValue(field, stored.record[name], hint(name));
226
237
  const value = stored.record[name];
227
238
  const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
@@ -284,6 +295,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
284
295
  ] });
285
296
  return /* @__PURE__ */ jsxs("div", {
286
297
  className: "flex flex-1 flex-col",
298
+ inert: preview,
287
299
  children: [
288
300
  remove.dialogs,
289
301
  /* @__PURE__ */ jsx("div", {
@@ -355,6 +367,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
355
367
  recordKey,
356
368
  owner: model.name
357
369
  }, `related:${item.title}`)];
370
+ if (preview && (item.kind === "custom" || item.kind === "map" || item.kind === "gallery")) return [];
358
371
  if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
359
372
  item,
360
373
  scope
@@ -385,7 +398,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
385
398
  children: fields.map(fieldView)
386
399
  })
387
400
  }, item.title)];
388
- }), linesModel && /* @__PURE__ */ jsx(RecordLines, {
401
+ }), linesModel && !preview && /* @__PURE__ */ jsx(RecordLines, {
389
402
  lines: linesModel,
390
403
  draft: linesDraft,
391
404
  vatRate: Number(stored.record.vatRate ?? 21) / 100,
@@ -399,7 +412,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
399
412
  className: "hidden flex-col items-center gap-2 md:flex",
400
413
  children: formControls
401
414
  }),
402
- /* @__PURE__ */ jsx(RecordActions, {
415
+ !preview && /* @__PURE__ */ jsx(RecordActions, {
403
416
  model,
404
417
  stored,
405
418
  recordKey,
@@ -408,11 +421,11 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
408
421
  }),
409
422
  firstSidebar === -1 && summary,
410
423
  layout.map((item, index) => {
411
- if (item.kind === "custom" && item.sidebar) return /* @__PURE__ */ jsx(CustomSection, {
424
+ if (item.kind === "custom" && item.sidebar && !preview) return /* @__PURE__ */ jsx(CustomSection, {
412
425
  item,
413
426
  scope
414
427
  }, `custom:${index}`);
415
- if (item.kind === "map" && item.sidebar) return /* @__PURE__ */ jsx(MapSection, {
428
+ if (item.kind === "map" && item.sidebar && !preview) return /* @__PURE__ */ jsx(MapSection, {
416
429
  item,
417
430
  record: stored.record
418
431
  }, `map:${index}`);
@@ -422,10 +435,11 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
422
435
  title: "Details",
423
436
  rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
424
437
  const field = name ? model.fields[name] : void 0;
425
- return name && field && visible(name) ? [{
438
+ if (!name || !field || !visible(name)) return [];
439
+ return [{
426
440
  label: hint(name)?.label ?? humanize(name),
427
- value: displayValue(field, stored.record[name], hint(name))
428
- }] : [];
441
+ value: known(name) ? displayValue(field, stored.record[name], hint(name)) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
442
+ }];
429
443
  })
430
444
  }),
431
445
  related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
@@ -0,0 +1,25 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { recordKey } from "../live/model-helpers.js";
3
+ import { useCallback } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { encodeKey } from "@protobase/schema/internal";
6
+ //#region src/app/record-preview.ts
7
+ /**
8
+ * A record as far as a list row knows it: the fields the row loaded, its ETag and permissions as the list gives them.
9
+ * `row` holds relations as their keys, as `displayRow` splits a loaded row.
10
+ */
11
+ const recordPreview = (model, row) => ({
12
+ record: Object.fromEntries(Object.entries(row).filter(([name]) => Object.hasOwn(model.fields, name))),
13
+ etag: typeof row.etag === "string" ? row.etag : "",
14
+ ...typeof row.permissions === "object" && row.permissions !== null && { permissions: row.permissions }
15
+ });
16
+ /**
17
+ * Keeps what a list row knows of its record for the record's page, which shows it until the record has loaded
18
+ * (`useRecord`), so a record opened from a list shows its fields at once.
19
+ */
20
+ const useSeedRecord = (model) => {
21
+ const queryClient = useQueryClient();
22
+ return useCallback((row) => queryClient.setQueryData(keys.recordPreview(model.name, encodeKey(recordKey(model, row))), recordPreview(model, row)), [queryClient, model]);
23
+ };
24
+ //#endregion
25
+ export { recordPreview, useSeedRecord };
@@ -16,12 +16,12 @@ import { ArrowRight } from "lucide-react";
16
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
17
  import { createWhere, parseFilter, printFilter } from "@protobase/schema";
18
18
  //#region src/app/record-row/record-row-body.tsx
19
- const where = createWhere();
19
+ const where$1 = createWhere();
20
20
  const scoped = (filter, scope) => {
21
21
  if (!scope) return filter;
22
22
  const parsed = parseFilter(filter);
23
23
  if (!parsed.ok) throw new Error(`The filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
24
- return printFilter(parsed.ast ? where.and(parsed.ast, scope) : scope);
24
+ return printFilter(parsed.ast ? where$1.and(parsed.ast, scope) : scope);
25
25
  };
26
26
  /** The display a row draws its cards with: the one named, else the view's first cards display, else a card of the title. */
27
27
  const rowDisplay = (view, name) => {
@@ -26,6 +26,7 @@ const RecordRow = ({ model, view, display, source, label, isNew }) => {
26
26
  total,
27
27
  rowAt: source.rowAt,
28
28
  loadRows: source.loadRows,
29
+ revision: source.revision,
29
30
  latencyMs: 0
30
31
  }, scroll.start, Math.max(scroll.start, scroll.end - 1));
31
32
  const indexes = Array.from({ length: scroll.end - scroll.start }, (_, offset) => scroll.start + offset);
@@ -0,0 +1,16 @@
1
+ //#region src/app/related-dots/card-dots-lines.ts
2
+ /**
3
+ * The lines of dots a card of `display` shows for a row, in the order it names their columns: those of the list's
4
+ * related columns that were loaded with the row and hold a record. A row loaded without them, as a map's or a saved
5
+ * search's, shows none.
6
+ */
7
+ const cardDotsLines = (view, display, row) => (display.dots ?? []).flatMap((name) => {
8
+ const column = view?.list?.relatedColumns?.find((candidate) => candidate.name === name);
9
+ const records = row.record[name];
10
+ return column && Array.isArray(records) && records.length > 0 ? [{
11
+ column,
12
+ records
13
+ }] : [];
14
+ });
15
+ //#endregion
16
+ export { cardDotsLines };
@@ -0,0 +1,34 @@
1
+ import { useAdminMeta } from "../meta-gate.js";
2
+ import { DotsRoom, RelatedDots } from "./related-dots.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/app/related-dots/card-dots.tsx
5
+ const lines = "flex flex-col gap-1.5";
6
+ /** The room a card's lines of dots take at its bottom, inside its link, which the dots are drawn over. */
7
+ const CardDotsRoom = ({ dots }) => /* @__PURE__ */ jsx("span", {
8
+ className: lines,
9
+ children: dots.map(({ column, records }) => /* @__PURE__ */ jsx(DotsRoom, { count: records.length }, column.name))
10
+ });
11
+ /**
12
+ * A card's lines of dots, over the room its link holds for them at its bottom, inside its border and padding: beside
13
+ * the link in the document, so a dot is a link of its own and the pointer between the dots reaches the card's.
14
+ * The card's list item must be `relative`.
15
+ */
16
+ const CardDots = ({ dots, Card }) => {
17
+ const { resources, views } = useAdminMeta();
18
+ return /* @__PURE__ */ jsx("div", {
19
+ className: `pointer-events-none absolute inset-x-[calc(0.75rem+1px)] bottom-[calc(0.75rem+1px)] ${lines}`,
20
+ children: dots.map(({ column, records }) => {
21
+ const target = resources[column.resource];
22
+ return target && /* @__PURE__ */ jsx(RelatedDots, {
23
+ column,
24
+ records,
25
+ target,
26
+ view: views[target.name],
27
+ Card,
28
+ wrap: true
29
+ }, column.name);
30
+ })
31
+ });
32
+ };
33
+ //#endregion
34
+ export { CardDots, CardDotsRoom };
@@ -1,8 +1,8 @@
1
1
  import { displaysOf, shownFields } from "../displays/list-displays.js";
2
2
  //#region src/app/related-dots/dot-card.ts
3
3
  /**
4
- * The card a dot shows of its record: the first cards display of the record's view, without the photos and favorite
5
- * button a small card has no room for, else the record's title with its status as the badge.
4
+ * The card a dot shows of its record: the first cards display of the record's view, without the photos, favorite
5
+ * button and dots a small card has no room for, else the record's title with its status as the badge.
6
6
  */
7
7
  const dotCard = (view, status) => {
8
8
  const cards = displaysOf(view).find((display) => display.kind === "cards");
@@ -11,7 +11,7 @@ const dotCard = (view, status) => {
11
11
  name: "dot",
12
12
  badge: status
13
13
  };
14
- const { photos: _, favorite: __, ...card } = cards;
14
+ const { photos: _, favorite: __, dots: ___, ...card } = cards;
15
15
  return card;
16
16
  };
17
17
  /** What the list loads for the related columns of its view among `columns`: each with the fields its dots' cards show. */
@@ -1,6 +1,7 @@
1
1
  import { humanize } from "../../live/naming.js";
2
2
  import { headerWidth, listColumnNames } from "../../live/columns-from-model.js";
3
3
  import { RelatedDots } from "./related-dots.js";
4
+ import { RecordCard } from "../displays/record-card.js";
4
5
  import { jsx } from "react/jsx-runtime";
5
6
  //#region src/app/related-dots/related-dots-columns.tsx
6
7
  /**
@@ -29,7 +30,8 @@ const withRelatedDotsColumns = (columns, model, view, resources, views) => {
29
30
  column,
30
31
  records: row[column.name] ?? [],
31
32
  target,
32
- view: views[target.name]
33
+ view: views[target.name],
34
+ Card: RecordCard
33
35
  })
34
36
  }];
35
37
  });