@protobase/ui 0.19.14 → 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 (110) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/create/create-page.js +3 -3
  3. package/dist/app/custom-section.js +1 -1
  4. package/dist/app/displays/board-column.js +114 -0
  5. package/dist/app/displays/board-columns.js +45 -0
  6. package/dist/app/displays/board-display.js +96 -0
  7. package/dist/app/displays/board-dnd.js +32 -0
  8. package/dist/app/displays/list-cards.js +1 -1
  9. package/dist/app/displays/list-displays.js +13 -1
  10. package/dist/app/displays/map-display.js +1 -1
  11. package/dist/app/displays/record-card.js +18 -7
  12. package/dist/app/displays/record-link.js +1 -1
  13. package/dist/app/displays/use-board-column.js +23 -0
  14. package/dist/app/displays/use-board-move.js +60 -0
  15. package/dist/app/displays/virtual-cards.js +1 -0
  16. package/dist/app/embedded-list/embedded-list.js +10 -1
  17. package/dist/app/export/export-options.js +2 -2
  18. package/dist/app/field-editor.js +6 -6
  19. package/dist/app/gallery/gallery-section.js +1 -1
  20. package/dist/app/gallery/lightbox.js +1 -1
  21. package/dist/app/gallery/photo-details.js +3 -3
  22. package/dist/app/gallery/use-gallery-photos.js +2 -2
  23. package/dist/app/import/import-dialog.js +1 -1
  24. package/dist/app/import/lookups.js +2 -2
  25. package/dist/app/list-edit/cell-field-editor.js +61 -0
  26. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  27. package/dist/app/list-edit/use-list-edits.js +75 -0
  28. package/dist/app/list-page.js +31 -13
  29. package/dist/app/map/map-area.js +6 -6
  30. package/dist/app/pages/action-href.js +4 -2
  31. package/dist/app/pages/action-inputs/action-inputs-dialog.d.ts +13 -0
  32. package/dist/app/pages/action-inputs/action-inputs-dialog.js +111 -0
  33. package/dist/app/pages/action-inputs/input-answers.js +88 -0
  34. package/dist/app/pages/action-inputs/input-errors.d.ts +11 -0
  35. package/dist/app/pages/action-inputs/input-errors.js +25 -0
  36. package/dist/app/pages/action-prompt.js +29 -0
  37. package/dist/app/pages/blocks/action.js +8 -4
  38. package/dist/app/pages/blocks/card-row.js +1 -1
  39. package/dist/app/pages/blocks/link.js +1 -1
  40. package/dist/app/pages/blocks/modal-form.js +5 -5
  41. package/dist/app/pages/blocks/record-card.js +1 -1
  42. package/dist/app/pages/blocks/stat.js +1 -1
  43. package/dist/app/pages/field-value.js +2 -2
  44. package/dist/app/pages/project-ui.d.ts +5 -0
  45. package/dist/app/pages/record-actions.js +8 -4
  46. package/dist/app/pages/use-run-action.js +20 -9
  47. package/dist/app/record-lines.js +1 -1
  48. package/dist/app/record-page.js +41 -27
  49. package/dist/app/record-preview.js +25 -0
  50. package/dist/app/record-row/record-row-body.js +3 -3
  51. package/dist/app/record-row/record-row.js +1 -0
  52. package/dist/app/record-row/saved-record-row.js +1 -1
  53. package/dist/app/related-dots/card-dots-lines.js +16 -0
  54. package/dist/app/related-dots/card-dots.js +34 -0
  55. package/dist/app/related-dots/dot-card.js +3 -3
  56. package/dist/app/related-dots/related-dots-columns.js +3 -1
  57. package/dist/app/related-dots/related-dots.js +22 -9
  58. package/dist/app/related-section.js +5 -5
  59. package/dist/app/relation-picker.js +2 -2
  60. package/dist/app/saved/saved-records.js +2 -2
  61. package/dist/app/search/search-model.js +2 -2
  62. package/dist/app/searches/saved-search-rows.js +1 -1
  63. package/dist/app/searches/saved-search.js +3 -3
  64. package/dist/app/searches/saved-searches.js +2 -2
  65. package/dist/app/searches/search-controls.js +1 -1
  66. package/dist/app/searches/search-diff.js +8 -8
  67. package/dist/app/searches/search-name-dialog.js +1 -1
  68. package/dist/app/searches/update-search-dialog.js +1 -1
  69. package/dist/app/searches/use-save-search.js +1 -1
  70. package/dist/app/sensitive-field.js +2 -2
  71. package/dist/app/use-nav-recent.js +1 -2
  72. package/dist/app/use-record-editor.js +1 -1
  73. package/dist/auth/sign-in-prompt.js +1 -1
  74. package/dist/data/api-provider.d.ts +2 -1
  75. package/dist/data/api-provider.js +12 -5
  76. package/dist/data/focus-refresh.js +44 -0
  77. package/dist/data/query-keys.js +13 -0
  78. package/dist/data/use-record.js +17 -2
  79. package/dist/data-grid/cell-moves.js +26 -0
  80. package/dist/data-grid/cells.js +1 -1
  81. package/dist/data-grid/data-grid.js +32 -7
  82. package/dist/data-grid/grid-header.js +1 -1
  83. package/dist/data-grid/grid-row.js +17 -13
  84. package/dist/data-grid/page-cache.js +3 -0
  85. package/dist/data-grid/use-cell-editing.js +113 -0
  86. package/dist/data-grid/use-paged-rows.js +25 -4
  87. package/dist/index.d.ts +3 -1
  88. package/dist/index.js +5 -4
  89. package/dist/live/columns-from-model.js +1 -1
  90. package/dist/live/field-values.js +3 -1
  91. package/dist/live/related-column-rows.js +3 -3
  92. package/dist/live/relation-labels.js +2 -2
  93. package/dist/live/use-live-rows.js +17 -4
  94. package/dist/live/use-reference-search.js +2 -2
  95. package/dist/markdown/markdown-editor.js +1 -1
  96. package/dist/package.js +1 -1
  97. package/dist/shell/assets/app-xQmLJM07.js +69 -0
  98. package/dist/shell/assets/index-CmJl5dIO.css +2 -0
  99. package/dist/shell/assets/{index-DF5WCYEC.js → index-CmmYBR7Y.js} +1 -1
  100. package/dist/shell/assets/{maplibre-DYbWlNbK.js → maplibre-Siom2Zwj.js} +2 -2
  101. package/dist/shell/assets/{markdown-content-o9fxfOx0.js → markdown-content-ChyGEubR.js} +1 -1
  102. package/dist/shell/assets/{record-map-CmVH3wtS.js → record-map-B_QnHF99.js} +1 -1
  103. package/dist/shell/assets/{records-map-XibwyiR4.js → records-map-CcHhEDBC.js} +1 -1
  104. package/dist/shell/assets/shared-protobase-ui-zRmreKZS.js +1 -0
  105. package/dist/shell/index.html +4 -4
  106. package/dist/shell/protobase.shell.json +1 -1
  107. package/package.json +4 -4
  108. package/dist/shell/assets/app-Bet1bgM_.js +0 -69
  109. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
  110. package/dist/shell/assets/shared-protobase-ui-DNhP23mn.js +0 -1
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
2
2
  import { Skeleton } from "../../primitives/skeleton.js";
3
3
  import { humanize } from "../../live/naming.js";
4
4
  import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
+ import { Section } from "../../record-view/section.js";
5
6
  import { shownImage } from "../../live/file-values.js";
6
7
  import { ErrorBanner } from "../error-banner.js";
7
8
  import { FileImage } from "../../live/file-image.js";
8
- import { Section } from "../../record-view/section.js";
9
9
  import { galleryTiles } from "./gallery-layout.js";
10
10
  import { Lightbox } from "./lightbox.js";
11
11
  import { detailFields } from "./detail-fields.js";
@@ -1,9 +1,9 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { useIsDesktop } from "../../lib/use-media-query.js";
3
3
  import { useFocusTrap } from "../../lib/use-focus-trap.js";
4
+ import { Sheet } from "../../primitives/sheet.js";
4
5
  import { shownImage } from "../../live/file-values.js";
5
6
  import { FileImage } from "../../live/file-image.js";
6
- import { Sheet } from "../../primitives/sheet.js";
7
7
  import { keyStep, steppedPhoto, swipeStep, togglesDetails } from "./gallery-layout.js";
8
8
  import { readDetailsOpen, writeDetailsOpen } from "./details-open-storage.js";
9
9
  import { useEffect, useId, useRef, useState } from "react";
@@ -1,11 +1,11 @@
1
1
  import { Skeleton } from "../../primitives/skeleton.js";
2
2
  import { useRecord } from "../../data/use-record.js";
3
3
  import { humanize } from "../../live/naming.js";
4
- import { fieldAccess } from "../permissions.js";
5
- import { AppLink } from "../use-app-address.js";
4
+ import { SpecTable } from "../../record-view/spec-table.js";
6
5
  import { ErrorBanner } from "../error-banner.js";
6
+ import { AppLink } from "../use-app-address.js";
7
+ import { fieldAccess } from "../permissions.js";
7
8
  import { FieldValue } from "../pages/field-value.js";
8
- import { SpecTable } from "../../record-view/spec-table.js";
9
9
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app/gallery/photo-details.tsx
11
11
  /**
@@ -5,7 +5,7 @@ import { galleryItems } from "./gallery-items.js";
5
5
  import { useQuery } from "@tanstack/react-query";
6
6
  import { createWhere, printFilter } from "@protobase/schema";
7
7
  //#region src/app/gallery/use-gallery-photos.ts
8
- const where = createWhere();
8
+ const where$1 = createWhere();
9
9
  /**
10
10
  * A gallery's photos: the record's own image, then the photo records that point at it, read with the list API under
11
11
  * the user's own access, so its row filters and readable fields apply. Without the photos' resource in `/meta`, the
@@ -15,7 +15,7 @@ const useGalleryPhotos = (item, record, recordKey) => {
15
15
  const client = useClient();
16
16
  const { resources } = useAdminMeta();
17
17
  const holder = item.photos ? resources[item.photos.resource] : void 0;
18
- const filter = item.photos ? printFilter(where.eq(item.photos.field, recordKey)) : "";
18
+ const filter = item.photos ? printFilter(where$1.eq(item.photos.field, recordKey)) : "";
19
19
  const sort = item.photos?.sort ?? "";
20
20
  const rows = useQuery({
21
21
  queryKey: keys.list(holder?.name ?? "", filter, sort, 500, "gallery"),
@@ -1,7 +1,7 @@
1
1
  import { Button } from "../../primitives/button.js";
2
2
  import { humanize } from "../../live/naming.js";
3
- import { ErrorBanner } from "../error-banner.js";
4
3
  import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
4
+ import { ErrorBanner } from "../error-banner.js";
5
5
  import { unmappedRequired } from "./mapping.js";
6
6
  import { ImportCheckStep, ImportChecking } from "./import-check-step.js";
7
7
  import { ImportFileStep } from "./import-file-step.js";
@@ -1,6 +1,6 @@
1
1
  import { createWhere, printFilter } from "@protobase/schema";
2
2
  //#region src/app/import/lookups.ts
3
- const where = createWhere();
3
+ const where$1 = createWhere();
4
4
  const valuesPerRequest = 100;
5
5
  /** A value as the lookups compare it: the API answers numbers as numbers and the rest as text. */
6
6
  const lookupKey = (value) => String(value);
@@ -23,7 +23,7 @@ const createLookups = (clientFor) => {
23
23
  for (let at = 0; at < missing.length; at += valuesPerRequest) {
24
24
  const asked = missing.slice(at, at + valuesPerRequest);
25
25
  for (const value of asked) found.set(lookupKey(value), []);
26
- const filter = printFilter(where.in(field, asked));
26
+ const filter = printFilter(where$1.in(field, asked));
27
27
  let pageToken;
28
28
  do {
29
29
  const page = await clientFor(model.name).list(model.name, {
@@ -0,0 +1,61 @@
1
+ import { differs, toFieldDraft, toPatchValue } from "../../live/field-values.js";
2
+ import { FieldEditor } from "../field-editor.js";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/list-edit/cell-field-editor.tsx
6
+ /**
7
+ * A field's editor in a list cell, the one the record page uses: Enter, Tab and leaving the cell save a change, and Tab
8
+ * and Shift+Tab go on to the next or previous editable cell; Escape leaves it as it was. A built-in switch or choice
9
+ * saves as soon as it changes.
10
+ */
11
+ const CellFieldEditor = ({ field, hints, resource, stored, onSave, close }) => {
12
+ const [draft, setDraft] = useState(() => toFieldDraft(field, stored));
13
+ const ref = useRef(null);
14
+ const finished = useRef(false);
15
+ const instant = !hints?.widget && (field.type === "boolean" || field.type === "enum");
16
+ useEffect(() => {
17
+ ref.current?.querySelector("input, select, textarea, button, [tabindex]")?.focus();
18
+ }, []);
19
+ const finish = (save, move, value = draft) => {
20
+ if (finished.current) return;
21
+ finished.current = true;
22
+ if (save && differs(field, value, stored)) onSave(toPatchValue(field, value));
23
+ close(move);
24
+ };
25
+ const onKeyDown = (event) => {
26
+ if (event.key === "Enter") {
27
+ event.preventDefault();
28
+ finish(true);
29
+ } else if (event.key === "Tab") {
30
+ event.preventDefault();
31
+ finish(true, event.shiftKey ? -1 : 1);
32
+ } else if (event.key === "Escape") {
33
+ event.preventDefault();
34
+ event.stopPropagation();
35
+ finish(false);
36
+ }
37
+ };
38
+ const onBlur = (event) => {
39
+ if (!ref.current?.contains(event.relatedTarget)) finish(true);
40
+ };
41
+ const onChange = (next) => {
42
+ setDraft(next);
43
+ if (instant) finish(true, void 0, next);
44
+ };
45
+ return /* @__PURE__ */ jsx("div", {
46
+ ref,
47
+ className: "w-full min-w-0",
48
+ onKeyDown,
49
+ onBlur,
50
+ children: /* @__PURE__ */ jsx(FieldEditor, {
51
+ field,
52
+ hints,
53
+ value: draft,
54
+ stored,
55
+ resource,
56
+ onChange
57
+ })
58
+ });
59
+ };
60
+ //#endregion
61
+ export { CellFieldEditor };
@@ -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 };
@@ -7,12 +7,13 @@ import { recordKey, recordTitleField } from "../live/model-helpers.js";
7
7
  import { humanize } from "../live/naming.js";
8
8
  import { useProjectUi } from "./pages/project-ui.js";
9
9
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
- import { can, rowAllows } from "./permissions.js";
11
- import { loadedFields, shownDisplay } from "./displays/list-displays.js";
10
+ import { Sheet } from "../primitives/sheet.js";
11
+ import { ErrorBanner } from "./error-banner.js";
12
+ import { displayRow, loadedFields, shownDisplay } from "./displays/list-displays.js";
12
13
  import { parseSort, sortToOrderBy } from "./list-order.js";
13
14
  import { listedResource } from "./current-address.js";
14
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
15
- import { ErrorBanner } from "./error-banner.js";
16
+ import { can, rowAllows } from "./permissions.js";
16
17
  import { columnsFromModel } from "../live/columns-from-model.js";
17
18
  import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
18
19
  import { DataGrid } from "../data-grid/data-grid.js";
@@ -25,8 +26,11 @@ import { FilterPanel } from "../filter-panel/filter-panel.js";
25
26
  import { stateToText, textToState } from "../live/filter-string.js";
26
27
  import { widgetFields } from "../live/view-fields.js";
27
28
  import { facetNames, filterChips } from "./searches/saved-search.js";
28
- import { Sheet } from "../primitives/sheet.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;
@@ -0,0 +1,13 @@
1
+ import { ActionInputModel, JsonValue } from "@protobase/schema";
2
+ //#region src/app/pages/action-inputs/action-inputs-dialog.d.ts
3
+ /** What a custom input's component gets, as a field type's editor does: the answer, a way to set it, whether it is refused, and `props`. */
4
+ export type ActionInputProps<V = unknown, P = Record<string, JsonValue>> = {
5
+ /** The input, as the action declares it. */
6
+ input: ActionInputModel;
7
+ value: V | null;
8
+ onChange: (value: V | null) => void;
9
+ invalid: boolean;
10
+ /** What the input gives in `props`; `{}` without. */
11
+ props: P;
12
+ };
13
+ //#endregion
@@ -0,0 +1,111 @@
1
+ import { Button } from "../../../primitives/button.js";
2
+ import { useProjectUi } from "../project-ui.js";
3
+ import { useAdminMeta } from "../../meta-gate.js";
4
+ import { ResponsiveDialog } from "../../../primitives/responsive-dialog.js";
5
+ import { Field } from "../../../record-view/field.js";
6
+ import { isChosen } from "../../create/create-values.js";
7
+ import { ErrorBanner } from "../../error-banner.js";
8
+ import { FieldEditor } from "../../field-editor.js";
9
+ import { answeredValues, inputField, inputHints, refusedAnswers, startingAnswers } from "./input-answers.js";
10
+ import { inputErrors } from "./input-errors.js";
11
+ import { useId, useState } from "react";
12
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
13
+ //#region src/app/pages/action-inputs/action-inputs-dialog.tsx
14
+ const InputEditor = ({ input, held, invalid, onChange }) => {
15
+ const { resources, views } = useAdminMeta();
16
+ const { components } = useProjectUi();
17
+ if (input.kind === "custom") {
18
+ const Custom = components?.[input.component ?? ""];
19
+ if (!Custom) throw new Error(`Input "${input.name}": the app has no component "${input.component}"`);
20
+ return /* @__PURE__ */ jsx(Custom, {
21
+ input,
22
+ value: held ?? null,
23
+ onChange,
24
+ invalid,
25
+ props: input.props ?? {}
26
+ });
27
+ }
28
+ const target = input.resource === void 0 ? void 0 : resources[input.resource];
29
+ return /* @__PURE__ */ jsx(FieldEditor, {
30
+ field: inputField(input),
31
+ hints: inputHints(input),
32
+ value: isChosen(held) ? held.key : held,
33
+ stored: void 0,
34
+ relationLabel: isChosen(held) ? held.label : void 0,
35
+ relationPicker: target ? {
36
+ target,
37
+ view: views[target.name]
38
+ } : void 0,
39
+ invalid,
40
+ onChange
41
+ });
42
+ };
43
+ /** The dialog an action with inputs opens: the inputs that show, edited as on the create form, and the action's button. */
44
+ const ActionInputsDialog = ({ action, label, danger, onCancel, onSubmit }) => {
45
+ const [draft, setDraft] = useState(() => startingAnswers(action.inputs));
46
+ const [errors, setErrors] = useState({});
47
+ const [failure, setFailure] = useState();
48
+ const [running, setRunning] = useState(false);
49
+ const formId = useId();
50
+ const { shown, values } = answeredValues(action.inputs, draft);
51
+ const submit = async () => {
52
+ const refused = refusedAnswers(action.inputs, values);
53
+ setErrors(refused);
54
+ setFailure(void 0);
55
+ if (Object.keys(refused).length > 0) return;
56
+ setRunning(true);
57
+ await onSubmit(values).catch((error) => {
58
+ setErrors(inputErrors(error, action.inputs));
59
+ setFailure(error);
60
+ });
61
+ setRunning(false);
62
+ };
63
+ const change = (name, value) => {
64
+ setErrors(({ [name]: _, ...rest }) => rest);
65
+ setDraft((current) => ({
66
+ ...current,
67
+ [name]: value
68
+ }));
69
+ };
70
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
71
+ title: label,
72
+ description: action.confirm,
73
+ open: true,
74
+ onClose: onCancel,
75
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
76
+ onClick: onCancel,
77
+ children: "Cancel"
78
+ }), /* @__PURE__ */ jsx(Button, {
79
+ variant: danger ? "danger" : "primary",
80
+ type: "submit",
81
+ form: formId,
82
+ loading: running,
83
+ children: label
84
+ })] }),
85
+ children: /* @__PURE__ */ jsxs("form", {
86
+ id: formId,
87
+ className: "flex flex-col gap-4",
88
+ onSubmit: (event) => {
89
+ event.preventDefault();
90
+ submit();
91
+ },
92
+ children: [failure !== void 0 && Object.keys(errors).length === 0 && /* @__PURE__ */ jsx(ErrorBanner, {
93
+ title: `${label} failed`,
94
+ error: failure
95
+ }), action.inputs.filter((input) => shown.has(input.name)).map((input) => /* @__PURE__ */ jsx(Field, {
96
+ label: input.label,
97
+ required: input.required,
98
+ help: input.help,
99
+ error: errors[input.name],
100
+ children: /* @__PURE__ */ jsx(InputEditor, {
101
+ input,
102
+ held: draft[input.name],
103
+ invalid: Boolean(errors[input.name]),
104
+ onChange: (next) => change(input.name, next)
105
+ })
106
+ }, input.name))]
107
+ })
108
+ });
109
+ };
110
+ //#endregion
111
+ export { ActionInputsDialog };
@@ -0,0 +1,88 @@
1
+ import { toDraft, toPatchValue } from "../../../live/field-values.js";
2
+ import { isChosen } from "../../create/create-values.js";
3
+ import { shownInputs } from "@protobase/schema/internal";
4
+ //#region src/app/pages/action-inputs/input-answers.ts
5
+ const fieldTypes = {
6
+ text: "text",
7
+ integer: "integer",
8
+ decimal: "decimal",
9
+ boolean: "boolean",
10
+ date: "date"
11
+ };
12
+ /** The field an input is edited as, so the create form's editors edit it: a choice of options is an enum, of records a relation. */
13
+ const inputField = (input) => {
14
+ const base = {
15
+ name: input.name,
16
+ column: input.name,
17
+ nullable: true,
18
+ readOnly: false,
19
+ filterable: false,
20
+ sortable: false,
21
+ aliases: []
22
+ };
23
+ if (input.kind === "custom") return {
24
+ ...base,
25
+ type: "json"
26
+ };
27
+ if (input.kind !== "choice") return {
28
+ ...base,
29
+ type: fieldTypes[input.kind]
30
+ };
31
+ if (input.resource !== void 0) return {
32
+ ...base,
33
+ type: "relation",
34
+ relation: {
35
+ resource: input.resource,
36
+ columns: [input.name]
37
+ }
38
+ };
39
+ return {
40
+ ...base,
41
+ type: "enum",
42
+ enumValues: (input.options ?? []).map((option) => option.value)
43
+ };
44
+ };
45
+ /** The view hints an input's editor reads: its label, a box that grows for a multiline text, a choice's labels. */
46
+ const inputHints = (input) => ({
47
+ label: input.label,
48
+ ...input.multiline && { multiline: true },
49
+ ...input.options && { valueLabels: Object.fromEntries(input.options.map((option) => [option.value, option.label])) }
50
+ });
51
+ /** What each input holds when the dialog opens: its default, as its editor holds it. */
52
+ const startingAnswers = (inputs) => Object.fromEntries(inputs.map((input) => {
53
+ if (input.kind === "custom") return [input.name, input.default ?? null];
54
+ if (input.kind === "boolean") return [input.name, Boolean(input.default)];
55
+ return [input.name, toDraft(input.default)];
56
+ }));
57
+ const answerOf = (input, held) => {
58
+ if (input.kind === "custom") return held ?? null;
59
+ if (isChosen(held)) return held.key;
60
+ if (input.resource !== void 0) return String(held ?? "") === "" ? null : String(held);
61
+ return toPatchValue(inputField(input), held);
62
+ };
63
+ /** The answers of the inputs that show, as the handler gets them in `values`, and the names of those that show. */
64
+ const answeredValues = (inputs, draft) => {
65
+ const all = Object.fromEntries(inputs.map((input) => [input.name, answerOf(input, draft[input.name])]));
66
+ const shown = shownInputs(inputs, all);
67
+ return {
68
+ shown,
69
+ values: Object.fromEntries(Object.entries(all).filter(([name]) => shown.has(name)))
70
+ };
71
+ };
72
+ const decimal = /^-?\d+(\.\d+)?$/;
73
+ const date = /^\d{4}-\d{2}-\d{2}$/;
74
+ const refusal = (input, value) => {
75
+ if (value === null || value === "") return input.required ? "Required" : void 0;
76
+ if (input.kind === "integer" && !Number.isInteger(value)) return "A whole number";
77
+ if (input.kind === "decimal" && !decimal.test(String(value))) return "A number";
78
+ if (input.kind === "date" && !date.test(String(value))) return "A date as YYYY-MM-DD";
79
+ if (input.max !== void 0 && String(value).length > input.max) return `At most ${input.max} characters`;
80
+ };
81
+ /** What is wrong with the answers that show, by input name: one left empty that is required, a number that is none, a text too long. */
82
+ const refusedAnswers = (inputs, values) => Object.fromEntries(inputs.flatMap((input) => {
83
+ if (!Object.hasOwn(values, input.name)) return [];
84
+ const reason = refusal(input, values[input.name]);
85
+ return reason ? [[input.name, reason]] : [];
86
+ }));
87
+ //#endregion
88
+ export { answeredValues, inputField, inputHints, refusedAnswers, startingAnswers };
@@ -0,0 +1,11 @@
1
+ import { ActionInputModel } from "@protobase/schema";
2
+ //#region src/app/pages/action-inputs/input-errors.d.ts
3
+ /**
4
+ * What a handler throws to refuse answers, each message under the input it is about, such as
5
+ * `throw new ActionInputError({ email: 'No user has this address' })`: the dialog stays open and shows each under its input.
6
+ */
7
+ export declare class ActionInputError extends Error {
8
+ readonly errors: Readonly<Record<string, string>>;
9
+ constructor(errors: Readonly<Record<string, string>>);
10
+ }
11
+ //#endregion