@protobase/ui 0.26.7 → 0.26.9

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 (32) hide show
  1. package/dist/app/displays/card-layout.js +11 -1
  2. package/dist/app/displays/virtual-cards.js +5 -4
  3. package/dist/app/list-actions/row-record.js +15 -0
  4. package/dist/app/list-actions/use-list-actions.js +95 -0
  5. package/dist/app/list-messages.js +2 -0
  6. package/dist/app/list-page.js +29 -17
  7. package/dist/app/list-places.js +48 -0
  8. package/dist/app/list-scroll.js +31 -12
  9. package/dist/app/named-icon.js +20 -2
  10. package/dist/app/pages/action-href.js +5 -2
  11. package/dist/app/pages/action-prompt.js +3 -1
  12. package/dist/app/pages/action-when.js +14 -0
  13. package/dist/app/pages/blocks/action.js +5 -3
  14. package/dist/app/pages/messages.js +12 -0
  15. package/dist/app/pages/record-actions.js +4 -4
  16. package/dist/app/pages/run-each.js +24 -0
  17. package/dist/app/pages/use-run-action.js +52 -21
  18. package/dist/app/router.js +42 -13
  19. package/dist/data-grid/bulk-actions.js +12 -0
  20. package/dist/data-grid/data-grid.js +8 -8
  21. package/dist/data-grid/row-menu.js +27 -19
  22. package/dist/data-grid/use-virtual-rows.js +11 -8
  23. package/dist/package.js +1 -1
  24. package/dist/shell/assets/app-CKIJTg2U.js +69 -0
  25. package/dist/shell/assets/{index-D1-1uonk.js → index-nDDDN3FR.js} +1 -1
  26. package/dist/shell/assets/{records-map-CYm8mlKL.js → records-map-DG0dbm9k.js} +1 -1
  27. package/dist/shell/assets/{shared-protobase-ui-DDDmYXGd.js → shared-protobase-ui-CvcWMNa3.js} +1 -1
  28. package/dist/shell/index.html +3 -3
  29. package/dist/shell/protobase.shell.json +1 -1
  30. package/dist/shell/protobase.sw.js +2 -2
  31. package/package.json +4 -4
  32. package/dist/shell/assets/app-CNDa8IHl.js +0 -69
@@ -15,5 +15,15 @@ const anchoredTop = (top, before, after) => {
15
15
  if (before.columns === after.columns) return row * after.rowHeight;
16
16
  return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
17
17
  };
18
+ /**
19
+ * Where the cards are at the offset `top`, as a list remembers it: the first record of the row at the top, plus how far
20
+ * into that row as a fraction of its height, so it comes back to the same records whatever the columns then.
21
+ */
22
+ const cardPlace = (top, layout) => {
23
+ const row = top / layout.rowHeight;
24
+ return Math.floor(row) * layout.columns + row % 1;
25
+ };
26
+ /** The row, with how far into it, that a place `cardPlace` gave falls in with `columns` cards a row. */
27
+ const placeRow = (place, columns) => Math.floor(Math.floor(place) / columns) + place % 1;
18
28
  //#endregion
19
- export { anchoredTop, cardColumns, cardWidth, recordRange };
29
+ export { anchoredTop, cardColumns, cardPlace, cardWidth, placeRow, recordRange };
@@ -2,7 +2,7 @@ import { Skeleton } from "../../primitives/skeleton.js";
2
2
  import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
3
  import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
4
  import { createFlipGuard } from "../../lib/flip-guard.js";
5
- import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
+ import { anchoredTop, cardColumns, cardPlace, cardWidth, placeRow, recordRange } from "./card-layout.js";
6
6
  import { withMeasuredHeight } from "./measured-heights.js";
7
7
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
8
8
  import { jsx } from "react/jsx-runtime";
@@ -33,12 +33,14 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
33
33
  const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
34
34
  const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
35
35
  const ready = itemWidth > 0 && measured.has(itemWidth);
36
+ const onScroll = (top) => onScrollIndex(cardPlace(top, layout));
36
37
  const scroll = useVirtualRows({
37
38
  total: Math.ceil(total / columns),
38
39
  rowHeight,
39
- initialIndex: Math.floor(startIndex / columns),
40
+ initialIndex: placeRow(startIndex, columns),
40
41
  settle,
41
- ready
42
+ ready,
43
+ onScroll
42
44
  });
43
45
  useLayoutEffect(() => {
44
46
  if (changed) scroll.scrollTo(scroll.top);
@@ -46,7 +48,6 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
46
48
  layout,
47
49
  top: scroll.top
48
50
  };
49
- if (!changed) onScrollIndex(Math.floor(Math.max(0, scroll.top) / rowHeight) * columns);
50
51
  });
51
52
  const range = recordRange(scroll.first, scroll.last, columns, total);
52
53
  const getRow = usePagedRows({
@@ -0,0 +1,15 @@
1
+ import { displayRow } from "../displays/list-displays.js";
2
+ //#region src/app/list-actions/row-record.ts
3
+ /**
4
+ * A list's row as the record a named action acts on: its fields as the API gives them, without what the list loads
5
+ * beside them, with its key, ETag and permissions.
6
+ */
7
+ const rowRecord = (model, row) => ({
8
+ model,
9
+ record: Object.fromEntries(Object.entries(displayRow(model, row).record).filter(([name]) => Object.hasOwn(model.fields, name))),
10
+ key: row.id,
11
+ ...typeof row.etag === "string" && { etag: row.etag },
12
+ ...typeof row.permissions === "object" && row.permissions !== null && { permissions: row.permissions }
13
+ });
14
+ //#endregion
15
+ export { rowRecord };
@@ -0,0 +1,95 @@
1
+ import { useAdminMeta } from "../meta-gate.js";
2
+ import { useAppAddress } from "../use-app-address.js";
3
+ import { ConfirmAction } from "../pages/confirm-action.js";
4
+ import { isDanger } from "../pages/action-prompt.js";
5
+ import { actionApplies } from "../pages/action-when.js";
6
+ import { actionFor, linkTarget, openLink } from "../pages/action-href.js";
7
+ import { useActionRunner } from "../pages/use-run-action.js";
8
+ import { text } from "../list-messages.js";
9
+ import { rowRecord } from "./row-record.js";
10
+ import { useState } from "react";
11
+ import { fillAddress } from "@protobase/schema/internal";
12
+ import { jsx } from "react/jsx-runtime";
13
+ //#region src/app/list-actions/use-list-actions.tsx
14
+ /**
15
+ * The named actions of `model`'s view for its list, those without inputs (which ask in a dialog of their own): each
16
+ * in the row menu of the rows it shows for, disabled with its reason where `when` disables it, and in red when it is a
17
+ * delete or a danger action; a link is a link there. Updates and handlers marked `bulk` are offered for a selection
18
+ * while one selected row applies, and run on those that do. `prompt` is the question one asks before it runs.
19
+ */
20
+ const useListActions = (model) => {
21
+ const { views } = useAdminMeta();
22
+ const runner = useActionRunner(model.name, void 0);
23
+ const { go, href } = useAppAddress();
24
+ const [asking, setAsking] = useState();
25
+ const listed = (views[model.name]?.actions ?? []).filter((action) => !action.inputs?.length);
26
+ const perform = (run) => "row" in run ? runner.run(run.action, run.row) : runner.runEach(run.action, run.selection, run.skipped);
27
+ const start = (run) => {
28
+ if (run.action.confirm) setAsking(run);
29
+ else perform(run);
30
+ };
31
+ return {
32
+ rowActions: listed.map((action) => {
33
+ const shown = (row) => actionFor(action, rowRecord(model, row).record);
34
+ const base = {
35
+ label: (row) => shown(row)?.label ?? action.label,
36
+ destructive: isDanger(action)
37
+ };
38
+ const run = action.run;
39
+ if (run?.kind === "link") {
40
+ const available = (row) => shown(row) !== void 0 && actionApplies(action, rowRecord(model, row).record);
41
+ if ("to" in run) {
42
+ const address = (row) => fillAddress(run.to, rowRecord(model, row).record);
43
+ return {
44
+ ...base,
45
+ available,
46
+ link: (row) => ({
47
+ href: href(address(row)),
48
+ onFollow: () => go(address(row))
49
+ })
50
+ };
51
+ }
52
+ const target = (row) => linkTarget(run.href, rowRecord(model, row).record) ?? "";
53
+ return {
54
+ ...base,
55
+ available,
56
+ link: (row) => ({
57
+ href: target(row),
58
+ onFollow: () => openLink(target(row))
59
+ })
60
+ };
61
+ }
62
+ return {
63
+ ...base,
64
+ available: (row) => shown(row) !== void 0,
65
+ blocked: (row) => runner.blockedOn(action, rowRecord(model, row)),
66
+ onSelect: (row) => start({
67
+ action,
68
+ row: rowRecord(model, row)
69
+ })
70
+ };
71
+ }),
72
+ bulkActions: listed.filter((action) => action.bulk && action.run?.kind !== "link").map((action) => ({
73
+ label: (count) => text("actionCount", action.label, count),
74
+ destructive: isDanger(action),
75
+ available: (row) => runner.appliesOn(action, rowRecord(model, row)),
76
+ onRun: (rows, skipped) => start({
77
+ action,
78
+ selection: rows.map((row) => rowRecord(model, row)),
79
+ skipped
80
+ })
81
+ })),
82
+ prompt: asking && /* @__PURE__ */ jsx(ConfirmAction, {
83
+ action: asking.action,
84
+ label: asking.action.label,
85
+ danger: isDanger(asking.action),
86
+ onCancel: () => setAsking(void 0),
87
+ onConfirm: () => {
88
+ setAsking(void 0);
89
+ perform(asking);
90
+ }
91
+ })
92
+ };
93
+ };
94
+ //#endregion
95
+ export { useListActions };
@@ -14,6 +14,7 @@ const text = defineMessages({
14
14
  delete: "Delete",
15
15
  exportCount: (count) => `Export ${count}`,
16
16
  deleteCount: (count) => `Delete ${count}`,
17
+ actionCount: (label, count) => `${label} (${count})`,
17
18
  dragToSort: "Drag a row by its handle to move it. Every move is saved as you drop it.",
18
19
  unreadableFilter: "The filter in the address could not be read",
19
20
  couldNotLoadRows: "Could not load rows",
@@ -38,6 +39,7 @@ const text = defineMessages({
38
39
  delete: "Verwijderen",
39
40
  exportCount: (count) => `${count} exporteren`,
40
41
  deleteCount: (count) => `${count} verwijderen`,
42
+ actionCount: (label, count) => `${label} (${count})`,
41
43
  dragToSort: "Sleep een rij aan de greep om hem te verplaatsen. Elke verplaatsing wordt bewaard zodra je loslaat.",
42
44
  unreadableFilter: "Het filter in het adres kon niet worden gelezen",
43
45
  couldNotLoadRows: "Kon de rijen niet laden",
@@ -61,6 +61,7 @@ import { SavedList } from "./saved/saved-list.js";
61
61
  import { SaveSearchButton } from "./searches/save-search-button.js";
62
62
  import { SavedSearches } from "./searches/saved-searches.js";
63
63
  import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
64
+ import { useListActions } from "./list-actions/use-list-actions.js";
64
65
  import { HandSortedList } from "./hand-order/hand-sorted-list.js";
65
66
  import { ReorderButton } from "./hand-order/reorder-button.js";
66
67
  import { useMemo, useState } from "react";
@@ -202,6 +203,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
202
203
  key: deleted.key
203
204
  })
204
205
  });
206
+ const named = useListActions(model);
207
+ const namedDelete = view?.actions.some((action) => action.run?.kind === "delete") ?? false;
205
208
  const setFilter = (next, nextExtras = extras) => set({ filter: stateToText(fields, next, nextExtras) });
206
209
  const search = searchText(extras);
207
210
  const commitSearch = (text) => {
@@ -357,23 +360,31 @@ const LoadedList = ({ model, address, scope, heading }) => {
357
360
  rowPatches: cellEditing.patches,
358
361
  initialScrollIndex: scroll.initialIndex,
359
362
  onScrollIndex: scroll.remember,
360
- rowActions: [{
361
- label: text$1("openRecord", singular),
362
- onSelect: openRecord
363
- }, ...can(permissions, "delete") ? [{
364
- label: text$1("delete"),
365
- destructive: true,
366
- available: (row) => rowAllows(row, "delete"),
367
- onSelect: (row) => void remove.start(target(row))
368
- }] : []],
369
- bulkActions: [...exportOffered(view) && exportsSelection(model) ? [{
370
- label: (count) => text$1("exportCount", count),
371
- onRun: (rows) => exportFlow.open(rows)
372
- }] : [], ...can(permissions, "delete") ? [{
373
- label: (count) => text$1("deleteCount", count),
374
- destructive: true,
375
- onRun: (rows) => remove.startBulk(rows.map(target))
376
- }] : []],
363
+ rowActions: [
364
+ {
365
+ label: text$1("openRecord", singular),
366
+ onSelect: openRecord
367
+ },
368
+ ...named.rowActions,
369
+ ...can(permissions, "delete") && !namedDelete ? [{
370
+ label: text$1("delete"),
371
+ destructive: true,
372
+ available: (row) => rowAllows(row, "delete"),
373
+ onSelect: (row) => void remove.start(target(row))
374
+ }] : []
375
+ ],
376
+ bulkActions: [
377
+ ...exportOffered(view) && exportsSelection(model) ? [{
378
+ label: (count) => text$1("exportCount", count),
379
+ onRun: (rows) => exportFlow.open(rows)
380
+ }] : [],
381
+ ...named.bulkActions,
382
+ ...can(permissions, "delete") ? [{
383
+ label: (count) => text$1("deleteCount", count),
384
+ destructive: true,
385
+ onRun: (rows) => remove.startBulk(rows.map(target))
386
+ }] : []
387
+ ],
377
388
  rowNumbers: false,
378
389
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, navItemId(resource, address.list)) : void 0,
379
390
  scroll: "parent",
@@ -477,6 +488,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
477
488
  children: [
478
489
  remove.dialogs,
479
490
  exportFlow.dialogs,
491
+ named.prompt,
480
492
  /* @__PURE__ */ jsx(PageHeader, {
481
493
  title: heading?.title ?? plural,
482
494
  subtitle: heading ? heading.subtitle : view?.help,
@@ -0,0 +1,48 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/app/list-places.ts
3
+ const storageKey = "protobase:list-places";
4
+ /** The places kept, the most recent ones, so the storage stays small however long the tab is used. */
5
+ const kept = 50;
6
+ const keyOf = (entry, url) => `${entry} ${url}`;
7
+ const isSaved = (value) => {
8
+ if (typeof value !== "object" || value === null) return false;
9
+ const { entry, url, place } = value;
10
+ return typeof entry === "string" && typeof url === "string" && typeof place === "number" && Number.isFinite(place) && place >= 0;
11
+ };
12
+ const read = (storage) => guarded(() => {
13
+ const raw = storage.getItem(storageKey);
14
+ if (raw === null) return [];
15
+ const parsed = JSON.parse(raw);
16
+ return Array.isArray(parsed) ? parsed.filter(isSaved) : [];
17
+ }, []);
18
+ /**
19
+ * The places of the lists of a tab, by history entry and list URL, kept in `storage` (the tab's session storage) when
20
+ * `persist` is called, so they outlast a reload of the page. A list starts where it was in its history entry, as on
21
+ * Back; in an entry it has no place in yet, as after a link to the same list, where it last was in any entry.
22
+ */
23
+ const createListPlaces = (storage) => {
24
+ const places = new Map(read(storage).map((saved) => [keyOf(saved.entry, saved.url), saved]));
25
+ const placeOf = (entry, url) => {
26
+ const own = places.get(keyOf(entry, url));
27
+ if (own) return own.place;
28
+ return [...places.values()].findLast((saved) => saved.url === url)?.place ?? 0;
29
+ };
30
+ const save = (entry, url, place) => {
31
+ const key = keyOf(entry, url);
32
+ places.delete(key);
33
+ places.set(key, {
34
+ entry,
35
+ url,
36
+ place
37
+ });
38
+ for (const oldest of [...places.keys()].slice(0, Math.max(0, places.size - kept))) places.delete(oldest);
39
+ };
40
+ const persist = () => guarded(() => storage.setItem(storageKey, JSON.stringify([...places.values()])), void 0);
41
+ return {
42
+ placeOf,
43
+ save,
44
+ persist
45
+ };
46
+ };
47
+ //#endregion
48
+ export { createListPlaces };
@@ -1,23 +1,42 @@
1
+ import { guarded } from "../lib/guarded.js";
1
2
  import { useRouter } from "./router.js";
2
- import { useCallback } from "react";
3
+ import { createListPlaces } from "./list-places.js";
4
+ import { useCallback, useEffect } from "react";
3
5
  //#region src/app/list-scroll.ts
4
- /** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
5
- const remembered = /* @__PURE__ */ new Map();
6
+ const noStorage = {
7
+ getItem: () => null,
8
+ setItem: () => {}
9
+ };
10
+ /** The places of this tab's lists, read from its session storage on first use. */
11
+ let places;
12
+ const tabPlaces = () => places ??= createListPlaces(guarded(() => window.sessionStorage, void 0) ?? noStorage);
6
13
  /**
7
- * Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
8
- * history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
9
- * the top again.
14
+ * Where a list starts and how it remembers where it is, so going back to it, or following a link to it, shows the same
15
+ * records at the same height. After a change of sort or filter, a list of another URL, it starts at the top. The places
16
+ * are stored when the list goes and when the page is hidden or left, so a reload or a discarded tab keeps them.
10
17
  */
11
18
  const useListScroll = () => {
12
19
  const { entry, path, params } = useRouter();
13
20
  const url = `${path}?${params.toString()}`;
14
- const last = remembered.get(entry);
15
- const remember = useCallback((index) => void remembered.set(entry, {
16
- url,
17
- index
18
- }), [entry, url]);
21
+ const tab = tabPlaces();
22
+ useEffect(() => {
23
+ const persist = () => tab.persist();
24
+ const hidden = () => document.visibilityState === "hidden" && tab.persist();
25
+ window.addEventListener("pagehide", persist);
26
+ document.addEventListener("visibilitychange", hidden);
27
+ return () => {
28
+ window.removeEventListener("pagehide", persist);
29
+ document.removeEventListener("visibilitychange", hidden);
30
+ tab.persist();
31
+ };
32
+ }, [tab]);
33
+ const remember = useCallback((place) => tab.save(entry, url, place), [
34
+ tab,
35
+ entry,
36
+ url
37
+ ]);
19
38
  return {
20
- initialIndex: last?.url === url ? last.index : 0,
39
+ initialIndex: tab.placeOf(entry, url),
21
40
  remember
22
41
  };
23
42
  };
@@ -1,4 +1,4 @@
1
- import { Ban, Bath, Bed, Bell, Bookmark, Calendar, DoorOpen, ExternalLink, FileText, Heart, House, KeyRound, LandPlot, Leaf, RulerDimensionLine, Send, Tag, Wallet } from "lucide-react";
1
+ import { Archive, ArrowRightLeft, Ban, Bath, Bed, Bell, Bookmark, Calendar, Camera, Copy, Cpu, DoorOpen, Download, ExternalLink, FileText, HardDrive, Heart, House, KeyRound, LandPlot, Leaf, MemoryStick, Network, Play, Plug, Power, RotateCw, RulerDimensionLine, Send, Server, Square, Tag, Terminal, Upload, Wallet, Wrench } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -19,7 +19,25 @@ const byName = {
19
19
  bell: Bell,
20
20
  bookmark: Bookmark,
21
21
  "external-link": ExternalLink,
22
- ban: Ban
22
+ ban: Ban,
23
+ play: Play,
24
+ stop: Square,
25
+ power: Power,
26
+ restart: RotateCw,
27
+ terminal: Terminal,
28
+ camera: Camera,
29
+ "hard-drive": HardDrive,
30
+ server: Server,
31
+ network: Network,
32
+ archive: Archive,
33
+ copy: Copy,
34
+ "arrow-right-left": ArrowRightLeft,
35
+ download: Download,
36
+ upload: Upload,
37
+ cpu: Cpu,
38
+ memory: MemoryStick,
39
+ plug: Plug,
40
+ wrench: Wrench
23
41
  };
24
42
  /** The icon a name stands for, or nothing for a name the app does not know. */
25
43
  const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
@@ -1,3 +1,4 @@
1
+ import { actionApplies } from "./action-when.js";
1
2
  import { hrefField } from "@protobase/schema/internal";
2
3
  //#region src/app/pages/action-href.ts
3
4
  /** `href` with every `{field}` replaced by the record's value, percent-encoded; a missing value becomes empty. */
@@ -27,10 +28,12 @@ const openLink = (target) => {
27
28
  const hostOf = (target) => URL.canParse(target) ? new URL(target).hostname.replace(/^www\./, "") : "";
28
29
  const holdsAll = (record, values) => Object.entries(values).every(([name, value]) => Object.is(record[name], value));
29
30
  /**
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
+ * `action` as it shows for `record`: one whose `when` does not hold there is left out, or shown as it is when it
32
+ * disables instead; an update that would change nothing there, and a link whose `href` has no target there, are left
33
+ * out, and `{host}` in a link's label becomes the host its target opens, without `www.`.
32
34
  */
33
35
  const actionFor = (action, record) => {
36
+ if (!actionApplies(action, record)) return action.otherwise === "disable" ? action : void 0;
34
37
  const run = action.run;
35
38
  if (run?.kind === "update") return record && !action.inputs?.length && holdsAll(record, run.values) ? void 0 : action;
36
39
  if (run?.kind !== "link" || !("href" in run)) return action;
@@ -2,6 +2,8 @@ import { ActionInputsDialog } from "./action-inputs/action-inputs-dialog.js";
2
2
  import { ConfirmAction } from "./confirm-action.js";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
  //#region src/app/pages/action-prompt.tsx
5
+ /** Whether the action is drawn in red, as one that destroys or interrupts something: a delete, or one with `variant: 'danger'`. */
6
+ const isDanger = (action) => action.variant === "danger" || action.run?.kind === "delete";
5
7
  /** Whether pressing the action's button opens a dialog first: to ask its question, or for its inputs. */
6
8
  const asksFirst = (action) => Boolean(action.confirm || action.inputs?.length);
7
9
  /** The dialog an action that asks first opens: its inputs, or else its question. */
@@ -26,4 +28,4 @@ const ActionPrompt = ({ action, label, danger, onCancel, onConfirm, onSubmit })
26
28
  });
27
29
  };
28
30
  //#endregion
29
- export { ActionPrompt, asksFirst };
31
+ export { ActionPrompt, asksFirst, isDanger };
@@ -0,0 +1,14 @@
1
+ import { evaluateCondition, parseCondition } from "@protobase/layout";
2
+ //#region src/app/pages/action-when.ts
3
+ /**
4
+ * Whether `action` applies to `record`: always without a `when`, else when its condition holds for the record's own
5
+ * fields. With a `when` there is nothing to apply to without a record, and a condition that does not parse never holds.
6
+ */
7
+ const actionApplies = (action, record) => {
8
+ if (action.when === void 0) return true;
9
+ if (!record) return false;
10
+ const condition = parseCondition(action.when);
11
+ return condition.ok && evaluateCondition(condition, record);
12
+ };
13
+ //#endregion
14
+ export { actionApplies };
@@ -8,7 +8,8 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app/pages/blocks/action.tsx
9
9
  /**
10
10
  * A button for a named action of `resource`'s view, by default of the record around it, on that record, asking first
11
- * when the action has `confirm` or `inputs`; nothing when the view has no such action for the user.
11
+ * when the action has `confirm` or `inputs`; nothing when the view has no such action for the user, or none for the
12
+ * record. It is drawn as the action's `variant` unless the block names its own.
12
13
  */
13
14
  const ActionButton = ({ name, resource, label, variant }) => {
14
15
  const scope = useLayoutRecord();
@@ -16,10 +17,11 @@ const ActionButton = ({ name, resource, label, variant }) => {
16
17
  const [asking, setAsking] = useState(false);
17
18
  if (!action.action) return null;
18
19
  const text = label ?? action.action.label;
20
+ const shown = variant ?? action.action.variant ?? "secondary";
19
21
  const start = () => asksFirst(action.action) ? setAsking(true) : void action.run();
20
22
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
21
23
  size: "sm",
22
- variant: variant ?? "secondary",
24
+ variant: shown,
23
25
  loading: action.running,
24
26
  disabled: Boolean(action.blocked),
25
27
  title: action.blocked,
@@ -28,7 +30,7 @@ const ActionButton = ({ name, resource, label, variant }) => {
28
30
  }), asking && /* @__PURE__ */ jsx(ActionPrompt, {
29
31
  action: action.action,
30
32
  label: text,
31
- danger: variant === "danger",
33
+ danger: shown === "danger",
32
34
  onCancel: () => setAsking(false),
33
35
  onConfirm: () => {
34
36
  setAsking(false);
@@ -12,9 +12,15 @@ const text = defineMessages({
12
12
  needsRecord: "Needs a record",
13
13
  cannotChange: "You cannot change this record",
14
14
  cannotDelete: "You cannot delete this record",
15
+ doesNotApply: "Does not apply to this record",
15
16
  noHandler: (name) => `The app has no handler for "${name}"`,
16
17
  noInputComponent: (component, input) => `The app has no component "${component}" for input "${input}"`,
17
18
  done: (label) => `${label}: done`,
19
+ doneOn: (label, done, skipped, failed) => [
20
+ `${label}: done on ${done}`,
21
+ ...skipped > 0 ? [`skipped ${skipped} it does not apply to`] : [],
22
+ ...failed > 0 ? [`failed on ${failed}`] : []
23
+ ].join(", "),
18
24
  failed: (label) => `${label} failed`,
19
25
  noAction: (name) => `No action "${name}"`,
20
26
  couldNotLoad: "Could not load",
@@ -41,9 +47,15 @@ const text = defineMessages({
41
47
  needsRecord: "Vraagt een record",
42
48
  cannotChange: "Je kunt dit record niet wijzigen",
43
49
  cannotDelete: "Je kunt dit record niet verwijderen",
50
+ doesNotApply: "Geldt niet voor dit record",
44
51
  noHandler: (name) => `De app heeft geen handler voor "${name}"`,
45
52
  noInputComponent: (component, input) => `De app heeft geen component "${component}" voor invoer "${input}"`,
46
53
  done: (label) => `${label}: klaar`,
54
+ doneOn: (label, done, skipped, failed) => [
55
+ `${label}: klaar voor ${done}`,
56
+ ...skipped > 0 ? [`${skipped} overgeslagen waarvoor het niet geldt`] : [],
57
+ ...failed > 0 ? [`mislukt voor ${failed}`] : []
58
+ ].join(", "),
47
59
  failed: (label) => `${label} mislukt`,
48
60
  noAction: (name) => `Geen actie "${name}"`,
49
61
  couldNotLoad: "Kon niet laden",
@@ -3,7 +3,7 @@ import "../../app-shell/top-bar.js";
3
3
  import { ActionRail } from "../../record-view/action-rail.js";
4
4
  import { AppLink, useAppAddress } from "../use-app-address.js";
5
5
  import { ActionMenu } from "../../record-view/action-menu.js";
6
- import { ActionPrompt, asksFirst } from "./action-prompt.js";
6
+ import { ActionPrompt, asksFirst, isDanger } from "./action-prompt.js";
7
7
  import { useActionRunner } from "./use-run-action.js";
8
8
  import { text } from "../record-messages.js";
9
9
  import { actionIcon } from "../action-icon.js";
@@ -14,7 +14,7 @@ import { createPortal } from "react-dom";
14
14
  //#region src/app/pages/record-actions.tsx
15
15
  /**
16
16
  * The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
17
- * actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
17
+ * actions, run like the Action block runs them, a delete among them returning to the list, a delete and a danger one in red; and the record's own Delete,
18
18
  * `onDelete`, unless a named action deletes already. On a mobile screen they sit in the top bar's menu instead, at hand
19
19
  * however long the record is, when the shell has a top bar.
20
20
  */
@@ -38,7 +38,7 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
38
38
  icon: actionIcon(action.icon),
39
39
  blocked: runner.blocked(action),
40
40
  running: runner.running === action.name,
41
- ...action.run?.kind === "delete" && { tone: "danger" },
41
+ ...isDanger(action) && { tone: "danger" },
42
42
  onRun: () => asksFirst(action) ? setAsking(action) : void runner.run(action)
43
43
  }));
44
44
  const deletes = runner.actions.some((action) => action.run?.kind === "delete");
@@ -75,7 +75,7 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
75
75
  asking && /* @__PURE__ */ jsx(ActionPrompt, {
76
76
  action: asking,
77
77
  label: asking.label,
78
- danger: asking.run?.kind === "delete",
78
+ danger: isDanger(asking),
79
79
  onCancel: () => setAsking(void 0),
80
80
  onConfirm: () => {
81
81
  setAsking(void 0);
@@ -0,0 +1,24 @@
1
+ //#region src/app/pages/run-each.ts
2
+ /**
3
+ * Runs `run` on each of `records` that `applies` to, one after another, so a failure on one leaves the others to run:
4
+ * it is counted, and the first is kept for the caller to show.
5
+ */
6
+ const runEach = async (records, applies, run) => {
7
+ const applying = records.filter(applies);
8
+ const outcome = {
9
+ done: 0,
10
+ skipped: records.length - applying.length,
11
+ failed: 0
12
+ };
13
+ for (const record of applying) {
14
+ const failure = await run(record).then(() => void 0, (error) => ({ error }));
15
+ if (!failure) outcome.done += 1;
16
+ else {
17
+ outcome.failed += 1;
18
+ if (outcome.failed === 1) outcome.error = failure.error;
19
+ }
20
+ }
21
+ return outcome;
22
+ };
23
+ //#endregion
24
+ export { runEach };