@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
@@ -0,0 +1,25 @@
1
+ import { isApiError } from "@protobase/client";
2
+ //#region src/app/pages/action-inputs/input-errors.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
+ var ActionInputError = class extends Error {
8
+ errors;
9
+ constructor(errors) {
10
+ super(Object.values(errors).join("; "));
11
+ this.errors = errors;
12
+ this.name = "ActionInputError";
13
+ }
14
+ };
15
+ /**
16
+ * The messages of a failed action about its inputs, by input name: an `ActionInputError`'s, or those of an API error
17
+ * whose field is an input's name, as a function the handler invokes reports them. Empty when none is about an input.
18
+ */
19
+ const inputErrors = (error, inputs) => {
20
+ const names = new Set(inputs.map((input) => input.name));
21
+ const entries = error instanceof ActionInputError ? Object.entries(error.errors) : isApiError(error) ? error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : []) : [];
22
+ return Object.fromEntries(entries.filter(([name]) => names.has(name)));
23
+ };
24
+ //#endregion
25
+ export { ActionInputError, inputErrors };
@@ -0,0 +1,29 @@
1
+ import { ActionInputsDialog } from "./action-inputs/action-inputs-dialog.js";
2
+ import { ConfirmAction } from "./confirm-action.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/app/pages/action-prompt.tsx
5
+ /** Whether pressing the action's button opens a dialog first: to ask its question, or for its inputs. */
6
+ const asksFirst = (action) => Boolean(action.confirm || action.inputs?.length);
7
+ /** The dialog an action that asks first opens: its inputs, or else its question. */
8
+ const ActionPrompt = ({ action, label, danger, onCancel, onConfirm, onSubmit }) => {
9
+ const { inputs } = action;
10
+ if (inputs?.length) return /* @__PURE__ */ jsx(ActionInputsDialog, {
11
+ action: {
12
+ ...action,
13
+ inputs
14
+ },
15
+ label,
16
+ danger,
17
+ onCancel,
18
+ onSubmit
19
+ });
20
+ return /* @__PURE__ */ jsx(ConfirmAction, {
21
+ action,
22
+ label,
23
+ danger,
24
+ onCancel,
25
+ onConfirm
26
+ });
27
+ };
28
+ //#endregion
29
+ export { ActionPrompt, asksFirst };
@@ -1,12 +1,12 @@
1
1
  import { Button } from "../../../primitives/button.js";
2
- import { ConfirmAction } from "../confirm-action.js";
2
+ import { ActionPrompt, asksFirst } from "../action-prompt.js";
3
3
  import { useLayoutRecord } from "../record-scope.js";
4
4
  import { useRunAction } from "../use-run-action.js";
5
5
  import { propsOf } from "./block-props.js";
6
6
  import { useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app/pages/blocks/action.tsx
9
- /** A button for a named action of the resource's view, asking first when the action has `confirm`. */
9
+ /** A button for a named action of the resource's view, asking first when the action has `confirm` or `inputs`. */
10
10
  const ActionBlock = ({ node }) => {
11
11
  const { name, resource, label, variant } = propsOf(node);
12
12
  const scope = useLayoutRecord();
@@ -14,7 +14,7 @@ const ActionBlock = ({ node }) => {
14
14
  const [asking, setAsking] = useState(false);
15
15
  if (!action.action) return null;
16
16
  const text = label ?? action.action.label;
17
- const start = () => action.action.confirm ? setAsking(true) : void action.run();
17
+ const start = () => asksFirst(action.action) ? setAsking(true) : void action.run();
18
18
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
19
19
  size: "sm",
20
20
  variant: variant ?? "secondary",
@@ -23,7 +23,7 @@ const ActionBlock = ({ node }) => {
23
23
  title: action.blocked,
24
24
  onClick: start,
25
25
  children: text
26
- }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
26
+ }), asking && /* @__PURE__ */ jsx(ActionPrompt, {
27
27
  action: action.action,
28
28
  label: text,
29
29
  danger: variant === "danger",
@@ -31,6 +31,10 @@ const ActionBlock = ({ node }) => {
31
31
  onConfirm: () => {
32
32
  setAsking(false);
33
33
  action.run();
34
+ },
35
+ onSubmit: async (values) => {
36
+ await action.submit(values);
37
+ setAsking(false);
34
38
  }
35
39
  })] });
36
40
  };
@@ -1,10 +1,10 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
+ import { ErrorBanner } from "../../error-banner.js";
3
4
  import { RecordScope } from "../record-scope.js";
4
5
  import { scopeOf, useListPage } from "../use-layout-data.js";
5
6
  import { propsOf } from "./block-props.js";
6
7
  import { CardFrame, Quiet } from "./card-frame.js";
7
- import { ErrorBanner } from "../../error-banner.js";
8
8
  import { jsx } from "react/jsx-runtime";
9
9
  //#region src/app/pages/blocks/card-row.tsx
10
10
  const LoadedCardRow = ({ model, filter, sort, limit = 12, title, description, empty, actions, children }) => {
@@ -1,5 +1,5 @@
1
- import { useLayoutRecord } from "../record-scope.js";
2
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
5
  import { appPath } from "@protobase/schema";
@@ -4,15 +4,15 @@ 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
6
  import { useAdminMeta, usePermissions } from "../../meta-gate.js";
7
+ import { Field } from "../../../record-view/field.js";
8
+ import { differs, toDraft, toPatchValue } from "../../../live/field-values.js";
9
+ import { createBody, isChosen } from "../../create/create-values.js";
10
+ import { ErrorBanner } from "../../error-banner.js";
11
+ import { FieldEditor } from "../../field-editor.js";
7
12
  import { useLayoutRecord } from "../record-scope.js";
8
13
  import { can } from "../../permissions.js";
9
14
  import { useFirstRecord, useRefreshResource } from "../use-layout-data.js";
10
15
  import { propsOf } from "./block-props.js";
11
- import { ErrorBanner } from "../../error-banner.js";
12
- import { differs, toDraft, toPatchValue } from "../../../live/field-values.js";
13
- import { Field } from "../../../record-view/field.js";
14
- import { createBody, isChosen } from "../../create/create-values.js";
15
- import { FieldEditor } from "../../field-editor.js";
16
16
  import { useState } from "react";
17
17
  import { isApiError } from "@protobase/client";
18
18
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -1,10 +1,10 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
+ import { ErrorBanner } from "../../error-banner.js";
3
4
  import { RecordScope } from "../record-scope.js";
4
5
  import { useFirstRecord } from "../use-layout-data.js";
5
6
  import { propsOf } from "./block-props.js";
6
7
  import { CardFrame, Quiet } from "./card-frame.js";
7
- import { ErrorBanner } from "../../error-banner.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app/pages/blocks/record-card.tsx
10
10
  const LoadedRecordCard = ({ model, filter, sort, recordKey, title, description, empty, actions, children }) => {
@@ -1,7 +1,7 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
- import { useLayoutRecord } from "../record-scope.js";
4
3
  import { AppLink } from "../../use-app-address.js";
4
+ import { useLayoutRecord } from "../record-scope.js";
5
5
  import { useCount } from "../use-layout-data.js";
6
6
  import { propsOf } from "./block-props.js";
7
7
  import { CardFrame } from "./card-frame.js";
@@ -6,12 +6,12 @@ import { fileShown, shownImage } from "../../live/file-values.js";
6
6
  import { displayValue } from "../../live/display-value.js";
7
7
  import { AppLink } from "../use-app-address.js";
8
8
  import { FileImage } from "../../live/file-image.js";
9
- import { fetchLabels } from "../../live/relation-labels.js";
10
9
  import { useShownWidget } from "../field-types/shown-widget.js";
11
10
  import { WidgetPart } from "../field-types/widget-part.js";
11
+ import { Markdown } from "../../markdown/markdown.js";
12
12
  import { isMarkdown } from "../../markdown/markdown-hint.js";
13
+ import { fetchLabels } from "../../live/relation-labels.js";
13
14
  import { ValueBadge } from "../value-badge.js";
14
- import { Markdown } from "../../markdown/markdown.js";
15
15
  import { jsx, jsxs } from "react/jsx-runtime";
16
16
  import { useQuery, useQueryClient } from "@tanstack/react-query";
17
17
  //#region src/app/pages/field-value.tsx
@@ -18,6 +18,11 @@ export type ActionContext = {
18
18
  client: Client;
19
19
  /** Moves the app to an address, such as `{ kind: 'record', resource: 'orders', key: 42 }`. */
20
20
  navigate: (to: AppAddress) => void;
21
+ /**
22
+ * The answers to the action's inputs, by input name, for an action with `inputs`: those that show, null when left
23
+ * empty. Throwing `ActionInputError` refuses them, each message under its input.
24
+ */
25
+ values?: Record<string, unknown>;
21
26
  };
22
27
  export type ActionHandler = (context: ActionContext) => void | Promise<void>;
23
28
  /** What a plugin's `beforeSignIn` component gets from the app, drawn around the pages someone signed out sees. */
@@ -1,7 +1,7 @@
1
- import { ConfirmAction } from "./confirm-action.js";
1
+ import { ActionRail } from "../../record-view/action-rail.js";
2
2
  import { AppLink, useAppAddress } from "../use-app-address.js";
3
+ import { ActionPrompt, asksFirst } from "./action-prompt.js";
3
4
  import { useActionRunner } from "./use-run-action.js";
4
- import { ActionRail } from "../../record-view/action-rail.js";
5
5
  import { actionIcon } from "../action-icon.js";
6
6
  import { useState } from "react";
7
7
  import { Pencil, Trash2 } from "lucide-react";
@@ -32,7 +32,7 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
32
32
  blocked: runner.blocked(action),
33
33
  running: runner.running === action.name,
34
34
  ...action.run?.kind === "delete" && { tone: "danger" },
35
- onRun: () => action.confirm ? setAsking(action) : void runner.run(action)
35
+ onRun: () => asksFirst(action) ? setAsking(action) : void runner.run(action)
36
36
  }));
37
37
  const deletes = runner.actions.some((action) => action.run?.kind === "delete");
38
38
  const actions = [
@@ -54,7 +54,7 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
54
54
  }] : []
55
55
  ];
56
56
  if (actions.length === 0) return null;
57
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
57
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ActionPrompt, {
58
58
  action: asking,
59
59
  label: asking.label,
60
60
  danger: asking.run?.kind === "delete",
@@ -62,6 +62,10 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
62
62
  onConfirm: () => {
63
63
  setAsking(void 0);
64
64
  runner.run(asking);
65
+ },
66
+ onSubmit: async (values) => {
67
+ await runner.submit(asking, values);
68
+ setAsking(void 0);
65
69
  }
66
70
  })] });
67
71
  };
@@ -2,8 +2,8 @@ import { useClient, useResourceClient } from "../../data/api-provider.js";
2
2
  import { useToast } from "../../toasts/toast-provider.js";
3
3
  import { useProjectUi } from "./project-ui.js";
4
4
  import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
- import { can } from "../permissions.js";
6
5
  import { useAppAddress } from "../use-app-address.js";
6
+ import { can } from "../permissions.js";
7
7
  import { actionFor, linkTarget, openLink } from "./action-href.js";
8
8
  import { useRefreshResource } from "./use-layout-data.js";
9
9
  import { useState } from "react";
@@ -12,13 +12,14 @@ 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.
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();
21
- const { actions: handlers } = useProjectUi();
22
+ const { actions: handlers, components } = useProjectUi();
22
23
  const permissions = usePermissions(resource);
23
24
  const client = useClient();
24
25
  const handlerClient = useResourceClient(resource);
@@ -33,8 +34,10 @@ const useActionRunner = (resource, scope, afterRun) => {
33
34
  if (kind === "update" && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
34
35
  if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return "You cannot delete this record";
35
36
  if (!action.run && !handlers?.[action.name]) return `The app has no handler for "${action.name}"`;
37
+ const custom = action.inputs?.find((input) => input.kind === "custom" && !components?.[input.component ?? ""]);
38
+ if (custom) return `The app has no component "${custom.component}" for input "${custom.name}"`;
36
39
  };
37
- const perform = async (action) => {
40
+ const perform = async (action, values) => {
38
41
  const { run } = action;
39
42
  if (run?.kind === "link") {
40
43
  if ("to" in run) go(fillAddress(run.to, scope?.record));
@@ -49,7 +52,8 @@ const useActionRunner = (resource, scope, afterRun) => {
49
52
  recordKey: scope?.key,
50
53
  etag: scope?.etag,
51
54
  client: handlerClient,
52
- navigate: go
55
+ navigate: go,
56
+ ...values && { values }
53
57
  });
54
58
  await refresh(resource);
55
59
  toast.show({
@@ -68,11 +72,17 @@ const useActionRunner = (resource, scope, afterRun) => {
68
72
  }));
69
73
  setRunning(void 0);
70
74
  };
75
+ const submit = async (action, values) => {
76
+ if (blocked(action)) return;
77
+ setRunning(action.name);
78
+ await perform(action, values).finally(() => setRunning(void 0));
79
+ };
71
80
  return {
72
81
  actions,
73
82
  blocked,
74
83
  running,
75
- run
84
+ run,
85
+ submit
76
86
  };
77
87
  };
78
88
  /** One named action of `resource`'s view, for a button. */
@@ -83,7 +93,8 @@ const useRunAction = (resource, name, scope) => {
83
93
  action,
84
94
  blocked: action ? runner.blocked(action) : `No action "${name}"`,
85
95
  running: runner.running === name,
86
- run: () => action ? runner.run(action) : Promise.resolve()
96
+ run: () => action ? runner.run(action) : Promise.resolve(),
97
+ submit: (values) => action ? runner.submit(action, values) : Promise.resolve()
87
98
  };
88
99
  };
89
100
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { usePermissions } from "./meta-gate.js";
2
- import { can } from "./permissions.js";
3
2
  import { InlineChildTable } from "../inline-child-table/inline-child-table.js";
4
3
  import { Section } from "../record-view/section.js";
4
+ import { can } from "./permissions.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
  //#region src/app/record-lines.tsx
7
7
  /** A resource that holds numbered lines of this one: it points at the parent and has `position` and `quantity`. */
@@ -1,35 +1,38 @@
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";
12
14
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
13
- import { can, fieldAccess } from "./permissions.js";
15
+ import { Field } from "../record-view/field.js";
16
+ import { Section } from "../record-view/section.js";
17
+ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
18
+ import { RecordHeader } from "../record-view/record-header.js";
19
+ import { SpecTable } from "../record-view/spec-table.js";
20
+ import { KeyFacts } from "../record-view/key-facts.js";
21
+ import { isRequiredOnEdit } from "../live/field-values.js";
22
+ import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
23
+ import { ErrorBanner } from "./error-banner.js";
14
24
  import { displayValue } from "../live/display-value.js";
15
25
  import { filledFields } from "./filled-fields.js";
16
26
  import { AppLink, useAppAddress } from "./use-app-address.js";
17
- import { ErrorBanner } from "./error-banner.js";
18
27
  import { shownWidget } from "./field-types/shown-widget.js";
19
28
  import { WidgetPart } from "./field-types/widget-part.js";
29
+ import { Markdown } from "../markdown/markdown.js";
20
30
  import { isMarkdown } from "../markdown/markdown-hint.js";
31
+ import { FieldEditor } from "./field-editor.js";
32
+ import { can, fieldAccess } from "./permissions.js";
21
33
  import { toneFor } from "../live/columns-from-model.js";
22
34
  import { ValueBadge } from "./value-badge.js";
23
- import { Markdown } from "../markdown/markdown.js";
24
35
  import { FieldValue } from "./pages/field-value.js";
25
- import { Field } from "../record-view/field.js";
26
- import { Section } from "../record-view/section.js";
27
- import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
28
- import { RecordHeader } from "../record-view/record-header.js";
29
- import { SpecTable } from "../record-view/spec-table.js";
30
- import { KeyFacts } from "../record-view/key-facts.js";
31
- import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
32
- import { FieldEditor } from "./field-editor.js";
33
36
  import { namedIcon } from "./named-icon.js";
34
37
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
35
38
  import { useRestore } from "./delete/use-restore.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 };
@@ -2,10 +2,10 @@ import { useIsXs } from "../../lib/use-media-query.js";
2
2
  import { Skeleton } from "../../primitives/skeleton.js";
3
3
  import { humanize } from "../../live/naming.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
+ import { ErrorBanner } from "../error-banner.js";
5
6
  import { displaysOf, loadedFields } from "../displays/list-displays.js";
6
7
  import { defaultOrderBy } from "../list-order.js";
7
8
  import { AppLink } from "../use-app-address.js";
8
- import { ErrorBanner } from "../error-banner.js";
9
9
  import { useLiveRows } from "../../live/use-live-rows.js";
10
10
  import { viewAllAddress } from "../embedded-list/embedded-display.js";
11
11
  import { FilterChipList } from "../searches/filter-chip-list.js";
@@ -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);
@@ -1,8 +1,8 @@
1
1
  import { Skeleton } from "../../primitives/skeleton.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { useAdminMeta } from "../meta-gate.js";
4
- import { listedResource } from "../current-address.js";
5
4
  import { ErrorBanner } from "../error-banner.js";
5
+ import { listedResource } from "../current-address.js";
6
6
  import { savedScope } from "../saved/saved-records.js";
7
7
  import { useSavedRecords } from "../saved/use-saved-records.js";
8
8
  import { RecordRowBody } from "./record-row-body.js";
@@ -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 };