@protobase/ui 0.21.7 → 0.21.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.
Files changed (50) hide show
  1. package/dist/app/comments/comment-body.js +13 -0
  2. package/dist/app/comments/comment-composer.js +149 -0
  3. package/dist/app/comments/comments-section.js +78 -0
  4. package/dist/app/comments/mention-text.js +45 -0
  5. package/dist/app/comments/use-mentionable.js +18 -0
  6. package/dist/app/comments/use-post-comment.js +15 -0
  7. package/dist/app/comments/use-record-comments.js +24 -0
  8. package/dist/app/current-address.d.ts +4 -3
  9. package/dist/app/current-address.js +1 -0
  10. package/dist/app/displays/calendar-controls.js +69 -0
  11. package/dist/app/displays/calendar-day.js +33 -0
  12. package/dist/app/displays/calendar-display.js +174 -0
  13. package/dist/app/displays/calendar-dnd.js +27 -0
  14. package/dist/app/displays/calendar-event.js +72 -0
  15. package/dist/app/displays/calendar-range.js +125 -0
  16. package/dist/app/displays/list-displays.js +7 -1
  17. package/dist/app/displays/use-calendar-move.js +68 -0
  18. package/dist/app/displays/use-calendar-rows.js +35 -0
  19. package/dist/app/embedded-list/embedded-list.js +25 -21
  20. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  21. package/dist/app/list-page.js +16 -5
  22. package/dist/app/pages/use-run-action.js +8 -6
  23. package/dist/app/record-page.js +24 -10
  24. package/dist/app/related-opens.js +22 -0
  25. package/dist/app/related-section.js +4 -1
  26. package/dist/app/use-app-address.d.ts +3 -1
  27. package/dist/app/use-app-address.js +8 -9
  28. package/dist/auth/user-to-shell.js +3 -1
  29. package/dist/data/api-provider.d.ts +20 -0
  30. package/dist/data/query-keys.js +15 -0
  31. package/dist/data-grid/row-menu.js +8 -1
  32. package/dist/format/date.js +27 -1
  33. package/dist/package.js +1 -1
  34. package/dist/primitives/segmented.js +8 -3
  35. package/dist/primitives/textarea.d.ts +4 -3
  36. package/dist/primitives/textarea.js +9 -4
  37. package/dist/shell/assets/app-C01lazBK.js +69 -0
  38. package/dist/shell/assets/{export-worker-CVd5saLq.js → export-worker-BnOpS_4Q.js} +1 -1
  39. package/dist/shell/assets/index-CBGigFxb.css +2 -0
  40. package/dist/shell/assets/{index-CQc-60lQ.js → index-xN6_oZU2.js} +1 -1
  41. package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-Ba9RapjY.js} +1 -1
  42. package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-BUxrbovf.js} +1 -1
  43. package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-14zotZ2s.js} +1 -1
  44. package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-wi-0pXqF.js} +1 -1
  45. package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-BVwhKZk9.js} +1 -1
  46. package/dist/shell/index.html +4 -4
  47. package/dist/shell/protobase.shell.json +1 -1
  48. package/package.json +4 -4
  49. package/dist/shell/assets/app-BbVdHzEY.js +0 -69
  50. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
@@ -0,0 +1,27 @@
1
+ import { addDays } from "./calendar-range.js";
2
+ //#region src/app/displays/calendar-dnd.ts
3
+ const dayPrefix = "day:";
4
+ /** The id a calendar's day is dropped on by. */
5
+ const dayId = (day) => `${dayPrefix}${day}`;
6
+ /** The id of an event on one of its days: a record over several days shows on each. */
7
+ const eventId = (key, day) => `${key}@${day}`;
8
+ /** The arrow keys move a picked-up event a day back or on (left and right) or a week (up and down), to that day's top. */
9
+ const dayCoordinates = (event, { context: { active, collisionRect, droppableRects, over } }) => {
10
+ const step = {
11
+ ArrowLeft: -1,
12
+ ArrowRight: 1,
13
+ ArrowUp: -7,
14
+ ArrowDown: 7
15
+ }[event.code];
16
+ const day = over ? String(over.id).slice(4) : active?.data.current?.day;
17
+ if (step === void 0 || !collisionRect || day === void 0) return void 0;
18
+ event.preventDefault();
19
+ const target = droppableRects.get(dayId(addDays(day, step)));
20
+ if (!target) return void 0;
21
+ return {
22
+ x: target.left + (target.width - collisionRect.width) / 2,
23
+ y: target.top + 4
24
+ };
25
+ };
26
+ //#endregion
27
+ export { dayCoordinates, dayId, eventId };
@@ -0,0 +1,72 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { formatTime } from "../../format/date.js";
3
+ import { recordTitleField } from "../../live/model-helpers.js";
4
+ import { displayValue } from "../../live/display-value.js";
5
+ import { AppLink } from "../use-app-address.js";
6
+ import { useSeedRecord } from "../record-preview.js";
7
+ import { eventId } from "./calendar-dnd.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { useDraggable } from "@dnd-kit/core";
10
+ //#region src/app/displays/calendar-event.tsx
11
+ /** The text an event shows: its `title` field, or the view's title, with the names of the records relations point at. */
12
+ const eventTitle = (model, view, display, row) => {
13
+ const name = display.title ?? recordTitleField(model, view);
14
+ const field = model.fields[name];
15
+ const text = row.labels[name] ?? (field ? displayValue(field, row.record[name], view?.fields[name]) : "");
16
+ return text === "" ? row.key : text;
17
+ };
18
+ const startTime = (model, display, row, day) => {
19
+ const value = row.record[display.start];
20
+ if (model.fields[display.start]?.type !== "timestamp" || typeof value !== "string") return void 0;
21
+ const ms = Date.parse(value);
22
+ return new Date(ms).toISOString().startsWith(day) ? formatTime(ms) : void 0;
23
+ };
24
+ /** An event as a one-line link to its record, its time first for a timestamp, styled as a small record card. */
25
+ const EventChip = ({ model, view, display, row, day }) => {
26
+ const seedRecord = useSeedRecord(model);
27
+ const time = startTime(model, display, row, day);
28
+ return /* @__PURE__ */ jsxs(AppLink, {
29
+ to: {
30
+ kind: "record",
31
+ resource: model.name,
32
+ key: row.key
33
+ },
34
+ onClick: () => seedRecord(row.record),
35
+ className: "flex min-w-0 items-baseline gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-xs hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
36
+ children: [time && /* @__PURE__ */ jsx("span", {
37
+ className: "shrink-0 tabular-nums text-muted-foreground",
38
+ children: time
39
+ }), /* @__PURE__ */ jsx("span", {
40
+ className: "min-w-0 truncate",
41
+ children: eventTitle(model, view, display, row)
42
+ })]
43
+ });
44
+ };
45
+ /** An event on one of its days, picked up by dragging it or by Space on its link; one the user may not update stays put. */
46
+ const CalendarEvent = ({ movable, dragged, ...props }) => {
47
+ const { setNodeRef, setActivatorNodeRef, listeners, isDragging } = useDraggable({
48
+ id: eventId(props.row.key, props.day),
49
+ data: {
50
+ row: props.row,
51
+ day: props.day
52
+ },
53
+ disabled: !movable
54
+ });
55
+ return /* @__PURE__ */ jsx("div", {
56
+ ref: (element) => {
57
+ setNodeRef(element);
58
+ setActivatorNodeRef(element?.querySelector("a") ?? element);
59
+ },
60
+ ...listeners,
61
+ onDragStart: (event) => event.preventDefault(),
62
+ onClickCapture: (event) => {
63
+ if (!dragged()) return;
64
+ event.preventDefault();
65
+ event.stopPropagation();
66
+ },
67
+ className: cn("min-w-0", movable && "cursor-grab touch-manipulation", isDragging && "opacity-40"),
68
+ children: /* @__PURE__ */ jsx(EventChip, { ...props })
69
+ });
70
+ };
71
+ //#endregion
72
+ export { CalendarEvent, EventChip, eventTitle };
@@ -0,0 +1,125 @@
1
+ import { printFilter, where } from "@protobase/schema";
2
+ //#region src/app/displays/calendar-range.ts
3
+ const dayMs = 864e5;
4
+ const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
5
+ const startOf = (day) => Date.parse(`${day}T00:00:00Z`);
6
+ /** The day `count` days after `day`, or before it for a negative count. */
7
+ const addDays = (day, count) => dayOf(startOf(day) + count * dayMs);
8
+ const monday = (day) => addDays(day, -((new Date(startOf(day)).getUTCDay() + 6) % 7));
9
+ const monthOf = (day, step = 0) => {
10
+ const date = new Date(startOf(day));
11
+ return dayOf(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + step, 1));
12
+ };
13
+ const normalised = ({ scale, day }) => ({
14
+ scale,
15
+ day: scale === "month" ? monthOf(day) : monday(day)
16
+ });
17
+ /** The month or week a calendar shows: the address's, starting on the month's 1st or the week's Monday, else the month of `now`. */
18
+ const calendarView = (at, now) => normalised(at ?? {
19
+ scale: "month",
20
+ day: dayOf(now)
21
+ });
22
+ /** The month or week of `scale` that holds `now`. */
23
+ const todayView = (scale, now) => normalised({
24
+ scale,
25
+ day: dayOf(now)
26
+ });
27
+ /** The month or week before (-1) or after (1) `view`. */
28
+ const stepView = (view, step) => view.scale === "month" ? {
29
+ scale: "month",
30
+ day: monthOf(view.day, step)
31
+ } : {
32
+ scale: "week",
33
+ day: addDays(monday(view.day), 7 * step)
34
+ };
35
+ /** The days a calendar shows: whole weeks from the Monday on or before a month's 1st to the Sunday on or after its last day, or a week's seven. */
36
+ const gridDays = (view) => {
37
+ const first = monday(view.scale === "month" ? monthOf(view.day) : view.day);
38
+ const last = view.scale === "month" ? addDays(monday(addDays(monthOf(view.day, 1), -1)), 6) : addDays(first, 6);
39
+ const count = (startOf(last) - startOf(first)) / dayMs + 1;
40
+ return Array.from({ length: count }, (_, index) => addDays(first, index));
41
+ };
42
+ /** The days `view` shows, as a range from its first day to the day after its last. */
43
+ const rangeOf = (view) => {
44
+ const days = gridDays(view);
45
+ return {
46
+ from: days[0],
47
+ to: addDays(days.at(-1), 1)
48
+ };
49
+ };
50
+ const isTimestamp = (display, model) => model.fields[display.start]?.type === "timestamp";
51
+ /**
52
+ * The records on the days of `range`, as a list filter: those starting in it, and with an `end` those starting before
53
+ * it and ending in or after its first day. A timestamp's `end` is the instant it is over, so one ending at midnight is
54
+ * not on the day it starts. Each branch reads one indexed field.
55
+ */
56
+ const rangeFilter = (display, model, range) => {
57
+ const timestamp = isTimestamp(display, model);
58
+ const bound = (day) => timestamp ? {
59
+ kind: "timestamp",
60
+ value: `${day}T00:00:00Z`,
61
+ span: {
62
+ start: 0,
63
+ end: 0
64
+ }
65
+ } : {
66
+ kind: "string",
67
+ value: day,
68
+ span: {
69
+ start: 0,
70
+ end: 0
71
+ }
72
+ };
73
+ const within = where.and(where.gte(display.start, bound(range.from)), where.lt(display.start, bound(range.to)));
74
+ if (display.end === void 0) return printFilter(within);
75
+ const ending = timestamp ? where.gt(display.end, bound(range.from)) : where.gte(display.end, bound(range.from));
76
+ return printFilter(where.or(within, where.and(where.lt(display.start, bound(range.from)), ending)));
77
+ };
78
+ /** The list's filter, its search included, narrowed to the calendar's days. */
79
+ const withRange = (filter, range) => filter === "" ? range : `(${filter}) AND (${range})`;
80
+ const instantOf = (value) => {
81
+ if (typeof value !== "string") return void 0;
82
+ const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
83
+ return Number.isNaN(ms) ? void 0 : ms;
84
+ };
85
+ /**
86
+ * The days of `days` a record is on: from its start's day through its end's, both included, where a timestamp `end`
87
+ * at midnight is over before that day starts; its start day alone without an end or with one before its start.
88
+ */
89
+ const eventDays = (record, display, model, days) => {
90
+ const start = instantOf(record[display.start]);
91
+ if (start === void 0) return [];
92
+ const first = dayOf(start);
93
+ const end = display.end === void 0 ? void 0 : instantOf(record[display.end]);
94
+ const ending = end === void 0 ? first : dayOf(isTimestamp(display, model) ? end - 1 : end);
95
+ const last = ending < first ? first : ending;
96
+ return days.filter((day) => day >= first && day <= last);
97
+ };
98
+ /** The rows on each day of `days`, each day's in start order. */
99
+ const byDay = (rows, display, model, days) => {
100
+ const placed = new Map(days.map((day) => [day, []]));
101
+ const ordered = rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
102
+ for (const row of ordered) for (const day of eventDays(row.record, display, model, days)) placed.get(day).push(row);
103
+ return placed;
104
+ };
105
+ /**
106
+ * The update that moves a record shown on `from` to `to`: its `start`, and its `end` when it has one, moved by the same
107
+ * number of days, a timestamp keeping its time of day.
108
+ */
109
+ const movedPatch = (record, display, model, from, to) => {
110
+ const days = (startOf(to) - startOf(from)) / dayMs;
111
+ const timestamp = isTimestamp(display, model);
112
+ const moved = (value) => {
113
+ const ms = instantOf(value);
114
+ if (ms === void 0) return void 0;
115
+ return timestamp ? new Date(ms + days * dayMs).toISOString() : addDays(dayOf(ms), days);
116
+ };
117
+ const start = moved(record[display.start]);
118
+ const end = display.end === void 0 ? void 0 : moved(record[display.end]);
119
+ return {
120
+ ...start !== void 0 && { [display.start]: start },
121
+ ...end !== void 0 && { [display.end]: end }
122
+ };
123
+ };
124
+ //#endregion
125
+ export { addDays, byDay, calendarView, eventDays, gridDays, movedPatch, rangeFilter, rangeOf, stepView, todayView, withRange };
@@ -62,7 +62,8 @@ const shownDisplay = (view, name) => {
62
62
  const defaultIcons = {
63
63
  cards: "cards",
64
64
  map: "map",
65
- board: "kanban"
65
+ board: "kanban",
66
+ calendar: "calendar"
66
67
  };
67
68
  const displayChoices = (view) => [{
68
69
  name: tableDisplay,
@@ -132,6 +133,11 @@ const shownFields = (model, view, display) => {
132
133
  ...card.meta ?? []
133
134
  ].filter((name) => name !== void 0);
134
135
  }
136
+ if (display.kind === "calendar") return [
137
+ display.start,
138
+ display.end,
139
+ display.title ?? recordTitleField(model, view)
140
+ ].filter((name) => name !== void 0);
135
141
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
136
142
  return [
137
143
  image,
@@ -0,0 +1,68 @@
1
+ import { formatDate } from "../../format/date.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { recordKey } from "../../live/model-helpers.js";
5
+ import { refusalReason } from "./board-columns.js";
6
+ import { useRefreshResource } from "../pages/use-layout-data.js";
7
+ import { movedPatch } from "./calendar-range.js";
8
+ import { useState } from "react";
9
+ import { useMutation } from "@tanstack/react-query";
10
+ //#region src/app/displays/use-calendar-move.ts
11
+ /** The rows with each moving record's new dates, so an event shows on its new day at once. */
12
+ const movedRows = (rows, moves) => {
13
+ const patches = new Map(moves.map((move) => [move.key, move.patch]));
14
+ return rows.map((row) => patches.has(row.key) ? {
15
+ ...row,
16
+ record: {
17
+ ...row.record,
18
+ ...patches.get(row.key)
19
+ }
20
+ } : row);
21
+ };
22
+ /**
23
+ * Moving a calendar's event to another day: one update of the record with its `start` and `end` moved by the same days
24
+ * and the ETag it was listed with, as a named update action sends, so the resource's access rules and `validate`
25
+ * decide. The event shows on its new day at once; a refusal puts it back and says why, and either way every list of
26
+ * the resource reloads.
27
+ */
28
+ const useCalendarMove = (model, display) => {
29
+ const client = useClient();
30
+ const toast = useToast();
31
+ const refresh = useRefreshResource();
32
+ const [moves, setMoves] = useState([]);
33
+ const settle = (key) => setMoves((current) => current.filter((move) => move.key !== key));
34
+ const mutation = useMutation({
35
+ mutationFn: ({ row, patch }) => {
36
+ const etag = typeof row.record.etag === "string" ? row.record.etag : "*";
37
+ return client.update(model.name, recordKey(model, row.record), patch, etag);
38
+ },
39
+ onMutate: ({ row, patch }) => setMoves((current) => [...current.filter((move) => move.key !== row.key), {
40
+ key: row.key,
41
+ patch
42
+ }]),
43
+ onError: (error, { row, to }) => {
44
+ settle(row.key);
45
+ toast.show({
46
+ state: "error",
47
+ title: `Could not move it to ${formatDate(Date.parse(`${to}T00:00:00Z`))}`,
48
+ description: refusalReason(error)
49
+ });
50
+ },
51
+ onSettled: async (_result, _error, { row }) => {
52
+ await refresh(model.name);
53
+ settle(row.key);
54
+ }
55
+ });
56
+ /** Moves the record of `row`, shown on the day `from`, to the day `to`. */
57
+ const move = (row, from, to) => mutation.mutate({
58
+ row,
59
+ patch: movedPatch(row.record, display, model, from, to),
60
+ to
61
+ });
62
+ return {
63
+ moves,
64
+ move
65
+ };
66
+ };
67
+ //#endregion
68
+ export { movedRows, useCalendarMove };
@@ -0,0 +1,35 @@
1
+ import { useAdminMeta } from "../meta-gate.js";
2
+ import { useLiveRows } from "../../live/use-live-rows.js";
3
+ import { useDisplayRows } from "./use-display-rows.js";
4
+ import { rangeFilter, withRange } from "./calendar-range.js";
5
+ import { useEffect } from "react";
6
+ /**
7
+ * The records on the days a calendar shows, through the list API: the list's filter with the days added, in start
8
+ * order, page after page up to `calendarLimit`; `capped` when more remain. A change to the resource reloads them, as it
9
+ * does the table.
10
+ */
11
+ const useCalendarRows = ({ model, display, range, fields, filter }) => {
12
+ const { resources, views } = useAdminMeta();
13
+ const rows = useLiveRows({
14
+ resources,
15
+ views,
16
+ model,
17
+ columns: fields,
18
+ filter: withRange(filter, rangeFilter(display, model, range)),
19
+ orderBy: `${display.start} asc`
20
+ });
21
+ const list = useDisplayRows(model, rows.page, rows.key, true);
22
+ const wanted = list.more && !list.loading && list.rows.length < 500;
23
+ useEffect(() => {
24
+ if (wanted) list.loadMore();
25
+ }, [wanted, list.rows.length]);
26
+ const capped = list.rows.length > 500 || list.rows.length === 500 && list.more;
27
+ return {
28
+ rows: list.rows.slice(0, 500),
29
+ capped,
30
+ loading: list.loading,
31
+ error: list.error
32
+ };
33
+ };
34
+ //#endregion
35
+ export { useCalendarRows };
@@ -2,22 +2,24 @@ import { useIsDesktop, 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 { ErrorBanner } from "../error-banner.js";
5
- import { recordKey } from "../../live/model-helpers.js";
6
5
  import { listColumnNames } from "../../live/columns-from-model.js";
7
6
  import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
8
7
  import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
9
- import { useAdminMeta } from "../meta-gate.js";
8
+ import { useAdminMeta, usePermissions } from "../meta-gate.js";
10
9
  import { AppLink, useAppAddress } from "../use-app-address.js";
11
10
  import { useProjectUi } from "../pages/project-ui.js";
11
+ import { can } from "../permissions.js";
12
12
  import { DataGrid } from "../../data-grid/data-grid.js";
13
13
  import { viewAllAddress } from "./embedded-display.js";
14
14
  import { BoardDisplay } from "../displays/board-display.js";
15
+ import { CalendarDisplay } from "../displays/calendar-display.js";
15
16
  import { CardsDisplay } from "../displays/cards-display.js";
16
17
  import { CustomDisplay } from "../displays/custom-display.js";
17
18
  import { MapDisplay } from "../displays/map-display.js";
18
19
  import { DisplaySwitch } from "../displays/display-controls.js";
19
20
  import { embeddedColumns } from "./embedded-columns.js";
20
21
  import { cutOffNote } from "./cut-off-note.js";
22
+ import { embeddedRowTargets } from "./embedded-row-targets.js";
21
23
  import { useEmbeddedRows } from "./use-embedded-rows.js";
22
24
  import { useMemo, useState } from "react";
23
25
  import { ArrowRight } from "lucide-react";
@@ -39,8 +41,9 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
39
41
  * to the whole list with the same filter, order and display, beside a note such as "The first 10 of 37." when there are
40
42
  * more. On a phone a cards display is one row that scrolls sideways.
41
43
  */
42
- const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true }) => {
44
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true, opens }) => {
43
45
  const { resources, views } = useAdminMeta();
46
+ const canUpdate = can(usePermissions(model.name), "update");
44
47
  const { fields: fieldTypes } = useProjectUi();
45
48
  const { go, href } = useAppAddress();
46
49
  const xs = useIsXs();
@@ -122,20 +125,18 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
122
125
  more: false,
123
126
  loadMore: () => void 0
124
127
  };
125
- const openRecord = (row) => go({
126
- kind: "record",
127
- resource: model.name,
128
- key: recordKey(model, row)
128
+ const targets = embeddedRowTargets({
129
+ model,
130
+ view,
131
+ opens,
132
+ resources,
133
+ views,
134
+ canUpdate
129
135
  });
130
- const recordLink = (row) => ({
131
- href: href({
132
- kind: "record",
133
- resource: model.name,
134
- key: recordKey(model, row)
135
- }),
136
- onFollow: () => openRecord(row)
136
+ const linkTo = (to) => ({
137
+ href: href(to),
138
+ onFollow: () => go(to)
137
139
  });
138
- const singular = view?.names?.singular ?? humanize(model.name);
139
140
  const note = cutOffNote(source.total, rows.data.total, rows.data.estimated);
140
141
  return /* @__PURE__ */ jsxs("div", {
141
142
  className: "flex flex-col gap-3",
@@ -146,12 +147,9 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
146
147
  latencyMs: 0,
147
148
  sort: parseSort(orderBy),
148
149
  onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
149
- onOpenRow: openRecord,
150
- rowLink: recordLink,
151
- rowActions: [{
152
- label: `Open ${singular.toLowerCase()}`,
153
- onSelect: openRecord
154
- }],
150
+ onOpenRow: (row) => go(targets.address(row)),
151
+ rowLink: (row) => linkTo(targets.address(row)),
152
+ rowActions: targets.actions(linkTo),
155
153
  rowNumbers: false,
156
154
  selectable: false
157
155
  }) : shown.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
@@ -177,6 +175,12 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
177
175
  filter,
178
176
  orderBy,
179
177
  limit
178
+ }) : shown.kind === "calendar" ? /* @__PURE__ */ jsx(CalendarDisplay, {
179
+ model,
180
+ view,
181
+ display: shown,
182
+ fields,
183
+ filter
180
184
  }) : /* @__PURE__ */ jsx(CustomDisplay, {
181
185
  display: shown,
182
186
  list
@@ -0,0 +1,65 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { rowAllows } from "../permissions.js";
4
+ import { relationTarget } from "../related-opens.js";
5
+ //#region src/app/embedded-list/embedded-row-targets.ts
6
+ const singularOf = (model, view) => (view?.names?.singular ?? humanize(model.name)).toLowerCase();
7
+ /**
8
+ * Where a table row of an embedded list leads, and its menu's items. A row opens its own record, or with `opens` the
9
+ * record that relation points at when it holds one; its menu then opens that record and edits, or for a user who may
10
+ * not, opens the row's own.
11
+ */
12
+ const embeddedRowTargets = ({ model, view, opens, resources, views, canUpdate }) => {
13
+ const own = (row) => ({
14
+ kind: "record",
15
+ resource: model.name,
16
+ key: recordKey(model, row)
17
+ });
18
+ const farName = opens && model.fields[opens]?.relation?.resource;
19
+ const far = farName ? resources[farName] : void 0;
20
+ const other = (row) => {
21
+ const key = opens && far ? relationTarget(row, opens) : void 0;
22
+ return far && key !== void 0 ? {
23
+ kind: "record",
24
+ resource: far.name,
25
+ key
26
+ } : void 0;
27
+ };
28
+ const address = (row) => other(row) ?? own(row);
29
+ const singular = singularOf(model, view);
30
+ const actions = (linkTo) => {
31
+ const openOwn = {
32
+ label: `Open ${singular}`,
33
+ link: (row) => linkTo(own(row))
34
+ };
35
+ if (!far) return [openOwn];
36
+ const editable = (row) => canUpdate && rowAllows(row, "update");
37
+ return [
38
+ {
39
+ label: `Open ${singularOf(far, views[far.name])}`,
40
+ available: (row) => other(row) !== void 0,
41
+ link: (row) => linkTo(other(row))
42
+ },
43
+ {
44
+ label: `Edit ${singular}`,
45
+ available: editable,
46
+ link: (row) => linkTo({
47
+ kind: "record",
48
+ resource: model.name,
49
+ key: recordKey(model, row),
50
+ edit: true
51
+ })
52
+ },
53
+ {
54
+ ...openOwn,
55
+ available: (row) => !editable(row)
56
+ }
57
+ ];
58
+ };
59
+ return {
60
+ address,
61
+ actions
62
+ };
63
+ };
64
+ //#endregion
65
+ export { embeddedRowTargets };
@@ -31,6 +31,7 @@ import { SignInPrompt } from "../auth/sign-in-prompt.js";
31
31
  import { relatedColumnLoads } from "./related-dots/dot-card.js";
32
32
  import { useDisplayRows } from "./displays/use-display-rows.js";
33
33
  import { BoardDisplay } from "./displays/board-display.js";
34
+ import { CalendarDisplay } from "./displays/calendar-display.js";
34
35
  import { CustomDisplay } from "./displays/custom-display.js";
35
36
  import { MapDisplay } from "./displays/map-display.js";
36
37
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
@@ -115,7 +116,7 @@ const ListPage = ({ address }) => {
115
116
  };
116
117
  const LoadedList = ({ model, address, scope, heading }) => {
117
118
  const meta = useAdminMeta();
118
- const { set, go, href } = useAppAddress();
119
+ const { set, listTo, go, href } = useAppAddress();
119
120
  const resource = model.name;
120
121
  const view = meta.views[resource];
121
122
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
@@ -202,10 +203,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
202
203
  setFilter(state, text ? [...others, where$1.search(text)] : others);
203
204
  };
204
205
  const plural = view?.names?.plural ?? humanize(resource);
205
- const sortMenu = display && /* @__PURE__ */ jsx(SortMenu, {
206
+ const sorted = display?.kind === "calendar" ? void 0 : display;
207
+ const sortMenu = sorted && /* @__PURE__ */ jsx(SortMenu, {
206
208
  model,
207
209
  view,
208
- display,
210
+ display: sorted,
209
211
  orderBy,
210
212
  onChange: (next) => set({ orderBy: next })
211
213
  });
@@ -234,12 +236,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
234
236
  onRemove: (key) => setFilter(removeChip(state, key)),
235
237
  onClear: () => setFilter(emptyFilterState)
236
238
  });
237
- const phoneSort = display && /* @__PURE__ */ jsx(FilterSection, {
239
+ const phoneSort = sorted && /* @__PURE__ */ jsx(FilterSection, {
238
240
  title: "Sort",
239
241
  children: /* @__PURE__ */ jsx(SortMenu, {
240
242
  model,
241
243
  view,
242
- display,
244
+ display: sorted,
243
245
  orderBy,
244
246
  onChange: (next) => set({ orderBy: next }),
245
247
  className: "min-h-11 w-full"
@@ -425,6 +427,15 @@ const LoadedList = ({ model, address, scope, heading }) => {
425
427
  filter: loadFilter,
426
428
  orderBy
427
429
  });
430
+ if (display.kind === "calendar") return /* @__PURE__ */ jsx(CalendarDisplay, {
431
+ model,
432
+ view,
433
+ display,
434
+ fields: loaded,
435
+ filter: loadFilter,
436
+ at: address.calendar,
437
+ linkTo: (calendar) => listTo({ calendar })
438
+ });
428
439
  return /* @__PURE__ */ jsx(CustomDisplay, {
429
440
  display,
430
441
  list: displayRows
@@ -12,10 +12,10 @@ 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. 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.
15
+ * (`update`, `delete`, `link`, and `ai`, which the server runs), else the handler the project registered under the
16
+ * action's name. An update whose values the record holds already is left out, and so is a link to a URL the record
17
+ * holds while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action that succeeded.
18
+ * `submit` runs one with the answers to its inputs and throws its failure, for the dialog to show.
19
19
  */
20
20
  const useActionRunner = (resource, scope, afterRun) => {
21
21
  const { views } = useAdminMeta();
@@ -30,8 +30,9 @@ const useActionRunner = (resource, scope, afterRun) => {
30
30
  const actions = (views[resource]?.actions ?? []).flatMap((action) => actionFor(action, scope?.record) ?? []);
31
31
  const blocked = (action) => {
32
32
  const kind = action.run?.kind;
33
- if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
34
- if (kind === "update" && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
33
+ if ((kind === "update" || kind === "delete" || kind === "ai") && !scope) return "Needs a record";
34
+ if ((kind === "update" || kind === "ai") && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
35
+ if (action.run?.kind === "ai" && scope?.permissions?.fields?.[action.run.writes] === "read") return "You cannot change this record";
35
36
  if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return "You cannot delete this record";
36
37
  if (!action.run && !handlers?.[action.name]) return `The app has no handler for "${action.name}"`;
37
38
  const custom = action.inputs?.find((input) => input.kind === "custom" && !components?.[input.component ?? ""]);
@@ -46,6 +47,7 @@ const useActionRunner = (resource, scope, afterRun) => {
46
47
  }
47
48
  if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
48
49
  else if (run?.kind === "delete") await client.remove(resource, scope.key, { etag: scope.etag });
50
+ else if (run?.kind === "ai") await client.generate(resource, scope.key, action.name);
49
51
  else await handlers[action.name]({
50
52
  resource,
51
53
  record: scope?.record,