@protobase/ui 0.26.6 → 0.26.8

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.
@@ -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,
@@ -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 };
@@ -5,7 +5,9 @@ import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
5
  import { useAppAddress } from "../use-app-address.js";
6
6
  import { useProjectUi } from "./project-ui.js";
7
7
  import { can } from "../permissions.js";
8
+ import { actionApplies } from "./action-when.js";
8
9
  import { actionFor, linkTarget, openLink } from "./action-href.js";
10
+ import { runEach } from "./run-each.js";
9
11
  import { useRefreshResource } from "./use-layout-data.js";
10
12
  import { useState } from "react";
11
13
  import { fillAddress, updateValues } from "@protobase/schema/internal";
@@ -14,9 +16,11 @@ const message = (error) => error instanceof Error ? error.message : text("someth
14
16
  /**
15
17
  * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
16
18
  * (`update`, `delete`, `link`, and `ai`, which the server runs), else the handler the project registered under the
17
- * action's name. An update whose values the record holds already is left out, and so is a link to a URL the record
18
- * holds while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action that succeeded.
19
+ * action's name. One whose `when` does not hold for the record is left out, or blocked with its `reason`; an update
20
+ * whose values the record holds already is left out, and so is a link to a URL the record holds while it holds none.
21
+ * `blocked` says why one cannot run; `afterRun` hears about every action that succeeded.
19
22
  * `submit` runs one with the answers to its inputs (a handler gets them, an update writes them) and throws its failure, for the dialog to show.
23
+ * `runEach` runs one on each of several records it applies to, as a list's selection does, and says how that went.
20
24
  */
21
25
  const useActionRunner = (resource, scope, afterRun) => {
22
26
  const { views } = useAdminMeta();
@@ -29,35 +33,41 @@ const useActionRunner = (resource, scope, afterRun) => {
29
33
  const { go } = useAppAddress();
30
34
  const [running, setRunning] = useState();
31
35
  const actions = (views[resource]?.actions ?? []).flatMap((action) => actionFor(action, scope?.record) ?? []);
32
- const blocked = (action) => {
36
+ const blockedOn = (action, on) => {
33
37
  const kind = action.run?.kind;
34
- if ((kind === "update" || kind === "delete" || kind === "ai") && !scope) return text("needsRecord");
35
- if ((kind === "update" || kind === "ai") && (!can(permissions, "update") || scope?.permissions?.update === false)) return text("cannotChange");
36
- if (action.run?.kind === "ai" && scope?.permissions?.fields?.[action.run.writes] === "read") return text("cannotChange");
37
- if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return text("cannotDelete");
38
+ if ((kind === "update" || kind === "delete" || kind === "ai") && !on) return text("needsRecord");
39
+ if (!actionApplies(action, on?.record)) return action.reason ?? text("doesNotApply");
40
+ if ((kind === "update" || kind === "ai") && (!can(permissions, "update") || on?.permissions?.update === false)) return text("cannotChange");
41
+ if (action.run?.kind === "ai" && on?.permissions?.fields?.[action.run.writes] === "read") return text("cannotChange");
42
+ if (kind === "delete" && (!can(permissions, "delete") || on?.permissions?.delete === false)) return text("cannotDelete");
38
43
  if (!action.run && !handlers?.[action.name]) return text("noHandler", action.name);
39
44
  const custom = action.inputs?.find((input) => input.kind === "custom" && !components?.[input.component ?? ""]);
40
45
  if (custom) return text("noInputComponent", String(custom.component), custom.name);
41
46
  };
42
- const perform = async (action, values) => {
47
+ const blocked = (action) => blockedOn(action, scope);
48
+ const execute = async (action, on, values) => {
43
49
  const { run } = action;
44
50
  if (run?.kind === "link") {
45
- if ("to" in run) go(fillAddress(run.to, scope?.record));
46
- else openLink(linkTarget(run.href, scope?.record));
51
+ if ("to" in run) go(fillAddress(run.to, on?.record));
52
+ else openLink(linkTarget(run.href, on?.record));
47
53
  return;
48
54
  }
49
- if (run?.kind === "update") await client.update(resource, scope.key, updateValues(run.values, values ?? {}), scope.etag ?? "*");
50
- else if (run?.kind === "delete") await client.remove(resource, scope.key, { etag: scope.etag });
51
- else if (run?.kind === "ai") await client.generate(resource, scope.key, action.name);
55
+ if (run?.kind === "update") await client.update(resource, on.key, updateValues(run.values, values ?? {}), on.etag ?? "*");
56
+ else if (run?.kind === "delete") await client.remove(resource, on.key, { etag: on.etag });
57
+ else if (run?.kind === "ai") await client.generate(resource, on.key, action.name);
52
58
  else await handlers[action.name]({
53
59
  resource,
54
- record: scope?.record,
55
- recordKey: scope?.key,
56
- etag: scope?.etag,
60
+ record: on?.record,
61
+ recordKey: on?.key,
62
+ etag: on?.etag,
57
63
  client: handlerClient,
58
64
  navigate: go,
59
65
  ...values && { values }
60
66
  });
67
+ };
68
+ const perform = async (action, on, values) => {
69
+ await execute(action, on, values);
70
+ if (action.run?.kind === "link") return;
61
71
  await refresh(resource);
62
72
  toast.show({
63
73
  state: "success",
@@ -65,10 +75,10 @@ const useActionRunner = (resource, scope, afterRun) => {
65
75
  });
66
76
  afterRun?.(action);
67
77
  };
68
- const run = async (action) => {
69
- if (blocked(action)) return;
78
+ const run = async (action, on = scope) => {
79
+ if (blockedOn(action, on)) return;
70
80
  setRunning(action.name);
71
- await perform(action).catch((error) => toast.show({
81
+ await perform(action, on).catch((error) => toast.show({
72
82
  state: "error",
73
83
  title: text("failed", action.label),
74
84
  description: message(error)
@@ -78,14 +88,35 @@ const useActionRunner = (resource, scope, afterRun) => {
78
88
  const submit = async (action, values) => {
79
89
  if (blocked(action)) return;
80
90
  setRunning(action.name);
81
- await perform(action, values).finally(() => setRunning(void 0));
91
+ await perform(action, scope, values).finally(() => setRunning(void 0));
92
+ };
93
+ /** Whether `action` shows and can run on `on`. */
94
+ const appliesOn = (action, on) => actionFor(action, on.record) !== void 0 && !blockedOn(action, on);
95
+ const runOnEach = async (action, records, skipped = 0) => {
96
+ setRunning(action.name);
97
+ const outcome = await runEach(records, (on) => appliesOn(action, on), (on) => execute(action, on));
98
+ setRunning(void 0);
99
+ await refresh(resource);
100
+ const title = text("doneOn", action.label, outcome.done, outcome.skipped + skipped, outcome.failed);
101
+ toast.show(outcome.failed > 0 ? {
102
+ state: "error",
103
+ title,
104
+ description: message(outcome.error)
105
+ } : {
106
+ state: "success",
107
+ title
108
+ });
109
+ if (outcome.done > 0) afterRun?.(action);
82
110
  };
83
111
  return {
84
112
  actions,
85
113
  blocked,
114
+ blockedOn,
115
+ appliesOn,
86
116
  running,
87
117
  run,
88
- submit
118
+ submit,
119
+ runEach: runOnEach
89
120
  };
90
121
  };
91
122
  /** One named action of `resource`'s view, for a button. */
@@ -0,0 +1,12 @@
1
+ //#region src/data-grid/bulk-actions.ts
2
+ /** The actions offered for `selected`, each with the rows it runs on and how many it skips. */
3
+ const offeredBulkActions = (actions, selected) => actions.flatMap((action) => {
4
+ const rows = action.available ? selected.filter(action.available) : selected;
5
+ return rows.length === 0 ? [] : [{
6
+ action,
7
+ rows,
8
+ skipped: selected.length - rows.length
9
+ }];
10
+ });
11
+ //#endregion
12
+ export { offeredBulkActions };
@@ -2,6 +2,7 @@ import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
3
  import { RouteLink } from "../primitives/route-link.js";
4
4
  import { buildColumns, features } from "./columns.js";
5
+ import { offeredBulkActions } from "./bulk-actions.js";
5
6
  import { GridFooter } from "./grid-footer.js";
6
7
  import { RowMenu, availableActions } from "./row-menu.js";
7
8
  import { desktopMinScale, gridMinWidth, gutterWidthFor } from "./grid-template.js";
@@ -260,11 +261,11 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
260
261
  total,
261
262
  className: own ? void 0 : "sticky bottom-[var(--pb-sticky-bottom,0px)] z-20",
262
263
  selectedCount,
263
- bulkActions: bulkActions?.map((action) => ({
264
- label: action.label(selectedCount),
264
+ bulkActions: offeredBulkActions(bulkActions ?? [], Object.keys(rowSelection).flatMap((id) => selectedRows.current.get(id) ?? [])).map(({ action, rows, skipped }) => ({
265
+ label: action.label(rows.length),
265
266
  destructive: action.destructive,
266
267
  onRun: () => {
267
- action.onRun(Object.keys(rowSelection).flatMap((id) => selectedRows.current.get(id) ?? []));
268
+ action.onRun(rows, skipped);
268
269
  setRowSelection({});
269
270
  selectedRows.current.clear();
270
271
  }
@@ -6,6 +6,7 @@ import { MoreHorizontal } from "lucide-react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
7
7
  import { createPortal } from "react-dom";
8
8
  //#region src/data-grid/row-menu.tsx
9
+ const labelOf = (action, row) => typeof action.label === "string" ? action.label : action.label(row);
9
10
  /** The actions that apply to a row; a row with none gets no menu at all. */
10
11
  const availableActions = (actions, row) => actions.filter((action) => action.available?.(row) ?? true);
11
12
  /**
@@ -33,25 +34,32 @@ const RowMenu = ({ row, actions }) => {
33
34
  onClick: (event) => event.stopPropagation(),
34
35
  style: place ?? { visibility: "hidden" },
35
36
  className: "fixed z-50 min-w-36 rounded-md border bg-background py-1 shadow-pop",
36
- children: actions.map((action) => /* @__PURE__ */ jsx("li", {
37
- role: "none",
38
- children: action.link ? /* @__PURE__ */ jsx(RouteLink, {
39
- role: "menuitem",
40
- to: action.link(row),
41
- onClick: () => setOpen(false),
42
- className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
43
- children: action.label
44
- }) : /* @__PURE__ */ jsx("button", {
45
- type: "button",
46
- role: "menuitem",
47
- className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
48
- onClick: () => {
49
- setOpen(false);
50
- action.onSelect(row);
51
- },
52
- children: action.label
53
- })
54
- }, action.label))
37
+ children: actions.map((action, index) => {
38
+ const label = labelOf(action, row);
39
+ const blocked = action.blocked?.(row);
40
+ const itemClass = cn("block w-full px-3 py-1.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent", action.destructive && "text-danger-text");
41
+ return /* @__PURE__ */ jsx("li", {
42
+ role: "none",
43
+ children: action.link ? /* @__PURE__ */ jsx(RouteLink, {
44
+ role: "menuitem",
45
+ to: action.link(row),
46
+ onClick: () => setOpen(false),
47
+ className: itemClass,
48
+ children: label
49
+ }) : /* @__PURE__ */ jsx("button", {
50
+ type: "button",
51
+ role: "menuitem",
52
+ disabled: blocked !== void 0,
53
+ title: blocked,
54
+ className: itemClass,
55
+ onClick: () => {
56
+ setOpen(false);
57
+ action.onSelect(row);
58
+ },
59
+ children: label
60
+ })
61
+ }, index);
62
+ })
55
63
  }), document.body)]
56
64
  });
57
65
  };