@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
@@ -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. */
@@ -13,14 +13,30 @@ const useRouter = () => {
13
13
  };
14
14
  const browserUrl = () => `${window.location.pathname}${window.location.search}`;
15
15
  const pathOf = (url) => new URL(url, "http://local").pathname;
16
+ const historyState = () => window.history.state ?? {};
16
17
  /** Our place in the browser history, so a back or forward press can be told apart and, if needed, undone. */
17
- const historyIndex = () => window.history.state?.protobaseIndex ?? 0;
18
+ const historyIndex = () => historyState().protobaseIndex ?? 0;
19
+ const newEntry = () => Math.random().toString(36).slice(2);
20
+ /** The id of the current history entry, given one if it has none yet, as an entry pushed before the app started. */
21
+ const historyEntry = () => {
22
+ const state = historyState();
23
+ if (state.protobaseEntry) return state.protobaseEntry;
24
+ const entry = newEntry();
25
+ window.history.replaceState({
26
+ ...state,
27
+ protobaseEntry: entry
28
+ }, "");
29
+ return entry;
30
+ };
18
31
  const Router = ({ basePath = "", initialUrl, children }) => {
19
32
  const memory = initialUrl !== void 0;
20
- const [url, setUrl] = useState(initialUrl ?? browserUrl());
33
+ const [page, setPage] = useState(() => ({
34
+ url: initialUrl ?? browserUrl(),
35
+ entry: memory ? newEntry() : historyEntry()
36
+ }));
21
37
  const guards = useRef(/* @__PURE__ */ new Set());
22
- const urlRef = useRef(url);
23
- urlRef.current = url;
38
+ const current = useRef(page);
39
+ current.current = page;
24
40
  const position = useRef(memory ? 0 : historyIndex());
25
41
  const undoing = useRef(false);
26
42
  const proceeding = useRef(false);
@@ -28,7 +44,10 @@ const Router = ({ basePath = "", initialUrl, children }) => {
28
44
  const [pending, setPending] = useState();
29
45
  useEffect(() => {
30
46
  if (memory) return;
31
- window.history.replaceState({ protobaseIndex: position.current }, "");
47
+ window.history.replaceState({
48
+ protobaseIndex: position.current,
49
+ protobaseEntry: current.current.entry
50
+ }, "");
32
51
  const onPop = () => {
33
52
  const next = historyIndex();
34
53
  if (undoing.current) {
@@ -39,7 +58,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
39
58
  return;
40
59
  }
41
60
  const delta = next - position.current;
42
- const leaving = pathOf(browserUrl()) !== pathOf(urlRef.current);
61
+ const leaving = pathOf(browserUrl()) !== pathOf(current.current.url);
43
62
  if (!proceeding.current && guards.current.size > 0 && leaving) {
44
63
  undoing.current = true;
45
64
  window.history.go(-delta);
@@ -51,21 +70,31 @@ const Router = ({ basePath = "", initialUrl, children }) => {
51
70
  }
52
71
  proceeding.current = false;
53
72
  position.current = next;
54
- setUrl(browserUrl());
73
+ setPage({
74
+ url: browserUrl(),
75
+ entry: historyEntry()
76
+ });
55
77
  };
56
78
  window.addEventListener("popstate", onPop);
57
79
  return () => window.removeEventListener("popstate", onPop);
58
80
  }, [memory]);
59
81
  const go = useCallback((full, replace) => {
82
+ const entry = replace ? current.current.entry : newEntry();
60
83
  if (!memory) {
61
84
  position.current += replace ? 0 : 1;
62
- window.history[replace ? "replaceState" : "pushState"]({ protobaseIndex: position.current }, "", full);
85
+ window.history[replace ? "replaceState" : "pushState"]({
86
+ protobaseIndex: position.current,
87
+ protobaseEntry: entry
88
+ }, "", full);
63
89
  }
64
- setUrl(full);
90
+ setPage({
91
+ url: full,
92
+ entry
93
+ });
65
94
  }, [memory]);
66
95
  const navigate = useCallback((to, { replace = false } = {}) => {
67
96
  const full = `${basePath}${to}`;
68
- if (pathOf(full) !== pathOf(urlRef.current) && guards.current.size > 0) return setPending({
97
+ if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
69
98
  kind: "link",
70
99
  full,
71
100
  replace
@@ -86,17 +115,17 @@ const Router = ({ basePath = "", initialUrl, children }) => {
86
115
  else window.history.go(pending.delta);
87
116
  };
88
117
  const value = useMemo(() => {
89
- const parsed = new URL(url, "http://local");
118
+ const parsed = new URL(page.url, "http://local");
90
119
  return {
91
120
  path: parsed.pathname.slice(basePath.length) || "/",
92
121
  params: parsed.searchParams,
93
122
  basePath,
94
- entry: position.current,
123
+ entry: page.entry,
95
124
  navigate,
96
125
  registerGuard
97
126
  };
98
127
  }, [
99
- url,
128
+ page,
100
129
  basePath,
101
130
  navigate,
102
131
  registerGuard
@@ -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";
@@ -15,7 +16,7 @@ import { usePagedRows } from "./use-paged-rows.js";
15
16
  import { useVirtualRows } from "./use-virtual-rows.js";
16
17
  import { useCellEditing } from "./use-cell-editing.js";
17
18
  import { ReorderRows, SortableRow } from "./row-reorder.js";
18
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
19
20
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
20
21
  import { useTable } from "@tanstack/react-table";
21
22
  //#region src/data-grid/data-grid.tsx
@@ -60,15 +61,14 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
60
61
  total
61
62
  ]);
62
63
  const [startIndex] = useState(initialScrollIndex);
63
- const { ref, top, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
64
+ const { ref, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
64
65
  total,
65
66
  rowHeight,
66
67
  initialIndex: startIndex,
67
- inset: headerHeight
68
+ inset: headerHeight,
69
+ onScroll: onScrollIndex && ((offset) => onScrollIndex(offset / rowHeight))
68
70
  });
69
71
  const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
70
- const topIndex = Math.floor(Math.max(0, top) / rowHeight);
71
- useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
72
72
  const getRow = usePagedRows({
73
73
  total,
74
74
  rowAt: sourceRowAt ? rowAt : void 0,
@@ -260,11 +260,11 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
260
260
  total,
261
261
  className: own ? void 0 : "sticky bottom-[var(--pb-sticky-bottom,0px)] z-20",
262
262
  selectedCount,
263
- bulkActions: bulkActions?.map((action) => ({
264
- label: action.label(selectedCount),
263
+ bulkActions: offeredBulkActions(bulkActions ?? [], Object.keys(rowSelection).flatMap((id) => selectedRows.current.get(id) ?? [])).map(({ action, rows, skipped }) => ({
264
+ label: action.label(rows.length),
265
265
  destructive: action.destructive,
266
266
  onRun: () => {
267
- action.onRun(Object.keys(rowSelection).flatMap((id) => selectedRows.current.get(id) ?? []));
267
+ action.onRun(rows, skipped);
268
268
  setRowSelection({});
269
269
  selectedRows.current.clear();
270
270
  }
@@ -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
  };
@@ -20,7 +20,7 @@ const viewRows = (view, total, rowHeight) => {
20
20
  * wheel or touch: the list follows the scroll position, and draws again only when other rows come into view.
21
21
  * `first` and `last` are the indexes of the drawn rows, overscan included.
22
22
  */
23
- const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, ready = true }) => {
23
+ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, ready = true, onScroll }) => {
24
24
  const ref = useRef(null);
25
25
  const scroller = useRef(null);
26
26
  const [view, setView] = useState({
@@ -32,14 +32,16 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
32
32
  rowHeight,
33
33
  initialIndex,
34
34
  inset,
35
- ready
35
+ ready,
36
+ onScroll
36
37
  });
37
38
  options.current = {
38
39
  total,
39
40
  rowHeight,
40
41
  initialIndex,
41
42
  inset,
42
- ready
43
+ ready,
44
+ onScroll
43
45
  };
44
46
  const read = useCallback(() => {
45
47
  const content = ref.current;
@@ -51,24 +53,25 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
51
53
  height: Math.max(0, area.height - hidden)
52
54
  };
53
55
  }, []);
56
+ const start = useRef({
57
+ pending: true,
58
+ laidOut: false
59
+ });
54
60
  const shown = useRef(view);
55
61
  shown.current = view;
56
62
  const measure = useCallback(() => {
57
63
  const next = read();
58
64
  if (!next) return;
59
65
  const current = shown.current;
60
- const { total, rowHeight } = options.current;
66
+ const { total, rowHeight, onScroll } = options.current;
61
67
  const before = viewRows(current, total, rowHeight);
62
68
  const after = viewRows(next, total, rowHeight);
69
+ if (!start.current.pending) onScroll?.(Math.max(0, after.top));
63
70
  const sameRows = !after.geometry.scaled && before.first === after.first && before.last === after.last && before.topIndex === after.topIndex;
64
71
  if (current.height === next.height && (current.position === next.position || sameRows)) return;
65
72
  shown.current = next;
66
73
  setView(next);
67
74
  }, [read]);
68
- const start = useRef({
69
- pending: true,
70
- laidOut: false
71
- });
72
75
  const begin = useCallback(() => {
73
76
  if (!start.current.pending || !start.current.laidOut || !options.current.ready) return;
74
77
  const view = read();
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.26.7";
2
+ var version = "0.26.9";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };