@protobase/ui 0.21.7 → 0.21.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 (90) hide show
  1. package/dist/app/app.js +1 -0
  2. package/dist/app/charts/chart-view.js +3 -1
  3. package/dist/app/comments/comment-body.js +13 -0
  4. package/dist/app/comments/comment-composer.js +149 -0
  5. package/dist/app/comments/comments-section.js +78 -0
  6. package/dist/app/comments/mention-text.js +45 -0
  7. package/dist/app/comments/use-mentionable.js +18 -0
  8. package/dist/app/comments/use-post-comment.js +15 -0
  9. package/dist/app/comments/use-record-comments.js +24 -0
  10. package/dist/app/current-address.d.ts +4 -3
  11. package/dist/app/current-address.js +1 -0
  12. package/dist/app/displays/calendar-controls.js +70 -0
  13. package/dist/app/displays/calendar-day.js +33 -0
  14. package/dist/app/displays/calendar-display.js +175 -0
  15. package/dist/app/displays/calendar-dnd.js +27 -0
  16. package/dist/app/displays/calendar-event.js +73 -0
  17. package/dist/app/displays/calendar-range.js +129 -0
  18. package/dist/app/displays/list-displays.js +7 -1
  19. package/dist/app/displays/use-calendar-move.js +68 -0
  20. package/dist/app/displays/use-calendar-rows.js +35 -0
  21. package/dist/app/embedded-list/embedded-list.js +25 -21
  22. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  23. package/dist/app/export/cells.js +6 -4
  24. package/dist/app/field-editor.js +13 -1
  25. package/dist/app/import/convert.js +2 -12
  26. package/dist/app/import/locale-formats.js +2 -1
  27. package/dist/app/incoming-card.js +35 -0
  28. package/dist/app/incoming-relations.js +36 -0
  29. package/dist/app/list-chart.js +3 -1
  30. package/dist/app/list-page.js +16 -5
  31. package/dist/app/pages/field-value.js +9 -1
  32. package/dist/app/pages/record-actions.js +34 -16
  33. package/dist/app/pages/use-run-action.js +8 -6
  34. package/dist/app/record-page.js +50 -28
  35. package/dist/app/related-opens.js +22 -0
  36. package/dist/app/related-section.js +4 -1
  37. package/dist/app/runtime/runtime-status.js +5 -4
  38. package/dist/app/search/navigation-hits.js +45 -0
  39. package/dist/app/search/use-global-search.js +13 -4
  40. package/dist/app/use-app-address.d.ts +3 -1
  41. package/dist/app/use-app-address.js +8 -9
  42. package/dist/app/use-incoming-counts.js +38 -0
  43. package/dist/app/use-related-records.js +3 -10
  44. package/dist/app-shell/app-shell.js +9 -2
  45. package/dist/app-shell/index.d.ts +1 -0
  46. package/dist/app-shell/logo.d.ts +5 -1
  47. package/dist/app-shell/logo.js +10 -3
  48. package/dist/app-shell/sidebar.js +3 -2
  49. package/dist/app-shell/top-bar-slot.js +11 -0
  50. package/dist/app-shell/top-bar.d.ts +3 -0
  51. package/dist/app-shell/top-bar.js +27 -19
  52. package/dist/auth/user-to-shell.js +3 -1
  53. package/dist/data/api-provider.d.ts +20 -0
  54. package/dist/data/query-keys.js +15 -0
  55. package/dist/data-grid/cells.js +8 -1
  56. package/dist/data-grid/grid-header.js +17 -16
  57. package/dist/data-grid/header-menu.js +16 -6
  58. package/dist/data-grid/row-menu.js +10 -40
  59. package/dist/format/date.js +52 -6
  60. package/dist/format/number-text.js +16 -0
  61. package/dist/format/zone.js +62 -0
  62. package/dist/live/columns-from-model.js +2 -0
  63. package/dist/live/contact-link.js +25 -0
  64. package/dist/live/contact-links.js +42 -0
  65. package/dist/live/display-value.js +14 -4
  66. package/dist/live/field-values.js +20 -9
  67. package/dist/live/filter-string.js +30 -30
  68. package/dist/package.js +1 -1
  69. package/dist/primitives/popover-menu.js +56 -0
  70. package/dist/primitives/segmented.js +8 -3
  71. package/dist/primitives/textarea.d.ts +4 -3
  72. package/dist/primitives/textarea.js +9 -4
  73. package/dist/record-view/action-menu.js +59 -0
  74. package/dist/record-view/record-header.js +2 -6
  75. package/dist/record-view/record-sidebar.js +2 -30
  76. package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
  77. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  78. package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
  79. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  80. package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
  81. package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
  82. package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
  83. package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
  84. package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
  85. package/dist/shell/index.html +4 -4
  86. package/dist/shell/protobase.shell.json +1 -1
  87. package/package.json +4 -4
  88. package/dist/shell/assets/app-BbVdHzEY.js +0 -69
  89. package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
  90. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
@@ -0,0 +1,175 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { dayIn, viewerZone } from "../../format/zone.js";
4
+ import { formatDate, formatWeekday } from "../../format/date.js";
5
+ import { ErrorBanner } from "../error-banner.js";
6
+ import { usePermissions } from "../meta-gate.js";
7
+ import { AppLink } from "../use-app-address.js";
8
+ import { can, rowAllows } from "../permissions.js";
9
+ import { collision } from "./board-dnd.js";
10
+ import { byDay, calendarView, gridDays, rangeOf } from "./calendar-range.js";
11
+ import { CalendarControls } from "./calendar-controls.js";
12
+ import { dayCoordinates, dayId } from "./calendar-dnd.js";
13
+ import { CalendarDay } from "./calendar-day.js";
14
+ import { CalendarEvent, EventChip, eventTitle } from "./calendar-event.js";
15
+ import { useCalendarRows } from "./use-calendar-rows.js";
16
+ import { movedRows, useCalendarMove } from "./use-calendar-move.js";
17
+ import { useRef, useState } from "react";
18
+ import { jsx, jsxs } from "react/jsx-runtime";
19
+ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
20
+ //#region src/app/displays/calendar-display.tsx
21
+ /** The events a month's day shows before a link to its week, which shows them all. */
22
+ const monthDayLimit = 3;
23
+ /**
24
+ * The list's records on the days of a month or a week, loaded for those days alone through the list API; an event
25
+ * dragged, or moved with the keyboard, to another day saves the record with its dates moved by the same days.
26
+ */
27
+ const CalendarDisplay = ({ model, view, display, fields, filter, at, linkTo, now = Date.now() }) => {
28
+ const [local, setLocal] = useState();
29
+ const shown = calendarView(linkTo ? at : local, now);
30
+ const days = gridDays(shown);
31
+ const list = useCalendarRows({
32
+ model,
33
+ display,
34
+ range: rangeOf(shown),
35
+ fields,
36
+ filter
37
+ });
38
+ const { moves, move } = useCalendarMove(model, display);
39
+ const placed = byDay(movedRows(list.rows, moves), display, model, days);
40
+ const permissions = usePermissions(model.name);
41
+ const movable = can(permissions, "update") && [display.start, display.end].every((name) => name === void 0 || !model.fields[name]?.readOnly);
42
+ const [active, setActive] = useState();
43
+ const dragged = useRef(false);
44
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
45
+ delay: 250,
46
+ tolerance: 5
47
+ } }), useSensor(KeyboardSensor, {
48
+ keyboardCodes: {
49
+ start: ["Space"],
50
+ cancel: ["Escape"],
51
+ end: ["Space", "Enter"]
52
+ },
53
+ coordinateGetter: dayCoordinates
54
+ }));
55
+ const today = dayIn(now, viewerZone());
56
+ const week = shown.scale === "week";
57
+ const dayText = (id) => formatDate(Date.parse(`${String(id).slice(dayId("").length)}T00:00:00Z`));
58
+ const title = (picked) => eventTitle(model, view, display, picked.row);
59
+ const announcements = {
60
+ onDragStart: ({ active: picked }) => `Picked up ${title(picked.data.current)}.`,
61
+ onDragOver: ({ active: picked, over }) => `${title(picked.data.current)} is over ${over ? dayText(over.id) : "no day"}.`,
62
+ onDragEnd: ({ active: picked, over }) => over ? `${title(picked.data.current)} was moved to ${dayText(over.id)}.` : `${title(picked.data.current)} was put back.`,
63
+ onDragCancel: ({ active: picked }) => `Moving ${title(picked.data.current)} was cancelled.`
64
+ };
65
+ const release = () => {
66
+ setActive(void 0);
67
+ setTimeout(() => dragged.current = false);
68
+ };
69
+ const start = ({ active: picked }) => {
70
+ dragged.current = true;
71
+ setActive(picked.data.current);
72
+ };
73
+ const end = ({ active: picked, over }) => {
74
+ release();
75
+ const { row, day } = picked.data.current;
76
+ const target = over ? String(over.id).slice(dayId("").length) : void 0;
77
+ if (target && target !== day) move(row, day, target);
78
+ };
79
+ const more = (day, hidden) => {
80
+ const to = calendarView({
81
+ scale: "week",
82
+ day
83
+ }, now);
84
+ const text = `+${hidden} more`;
85
+ const className = "self-start rounded px-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary";
86
+ return linkTo ? /* @__PURE__ */ jsx(AppLink, {
87
+ to: linkTo(to),
88
+ className,
89
+ children: text
90
+ }) : /* @__PURE__ */ jsx("button", {
91
+ type: "button",
92
+ className,
93
+ onClick: () => setLocal(to),
94
+ children: text
95
+ });
96
+ };
97
+ return /* @__PURE__ */ jsxs("div", {
98
+ className: "flex min-w-0 flex-1 flex-col gap-3",
99
+ children: [
100
+ /* @__PURE__ */ jsx(CalendarControls, {
101
+ view: shown,
102
+ now,
103
+ linkTo,
104
+ onView: setLocal
105
+ }),
106
+ list.error && /* @__PURE__ */ jsx(ErrorBanner, {
107
+ title: "Could not load rows",
108
+ error: list.error
109
+ }),
110
+ list.capped && /* @__PURE__ */ jsxs("p", {
111
+ className: "text-xs text-muted-foreground",
112
+ children: [
113
+ "Showing the first ",
114
+ 500,
115
+ " records of this ",
116
+ shown.scale,
117
+ ". Narrow the filter to see the rest."
118
+ ]
119
+ }),
120
+ /* @__PURE__ */ jsxs(DndContext, {
121
+ sensors,
122
+ collisionDetection: collision,
123
+ onDragStart: start,
124
+ onDragEnd: end,
125
+ onDragCancel: release,
126
+ accessibility: {
127
+ announcements,
128
+ screenReaderInstructions: { draggable: "To move an event to another day, press Space, move it a day with the left and right arrow keys or a week with the up and down arrow keys, and press Space again to drop it there. Escape puts it back." }
129
+ },
130
+ children: [/* @__PURE__ */ jsxs("div", {
131
+ className: "overflow-hidden rounded-lg border border-border",
132
+ children: [!week && /* @__PURE__ */ jsx("div", {
133
+ "aria-hidden": true,
134
+ className: "grid grid-cols-7 gap-px border-b border-border bg-border",
135
+ children: days.slice(0, 7).map((day) => /* @__PURE__ */ jsx("span", {
136
+ className: "bg-surface px-1.5 py-1 text-xs font-medium text-muted-foreground",
137
+ children: formatWeekday(Date.parse(`${day}T00:00:00Z`))
138
+ }, day))
139
+ }), /* @__PURE__ */ jsx("div", {
140
+ className: cn("grid gap-px bg-border", week ? "grid-cols-1 sm:grid-cols-7" : "grid-cols-7"),
141
+ children: days.map((day) => {
142
+ const events = placed.get(day);
143
+ const visible = week ? events : events.slice(0, monthDayLimit);
144
+ return /* @__PURE__ */ jsx(CalendarDay, {
145
+ day,
146
+ count: events.length,
147
+ inMonth: week || day.startsWith(shown.day.slice(0, 7)),
148
+ today: day === today,
149
+ week,
150
+ more: visible.length < events.length && more(day, events.length - visible.length),
151
+ children: list.loading ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4" }) : visible.map((row) => /* @__PURE__ */ jsx(CalendarEvent, {
152
+ model,
153
+ view,
154
+ display,
155
+ row,
156
+ day,
157
+ movable: movable && rowAllows(row.record, "update"),
158
+ dragged: () => dragged.current
159
+ }, row.key))
160
+ }, day);
161
+ })
162
+ })]
163
+ }), /* @__PURE__ */ jsx(DragOverlay, { children: active && /* @__PURE__ */ jsx(EventChip, {
164
+ model,
165
+ view,
166
+ display,
167
+ row: active.row,
168
+ day: active.day
169
+ }) })]
170
+ })
171
+ ]
172
+ });
173
+ };
174
+ //#endregion
175
+ export { CalendarDisplay };
@@ -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,73 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { dayIn, viewerZone } from "../../format/zone.js";
3
+ import { formatTime } from "../../format/date.js";
4
+ import { recordTitleField } from "../../live/model-helpers.js";
5
+ import { displayValue } from "../../live/display-value.js";
6
+ import { AppLink } from "../use-app-address.js";
7
+ import { useSeedRecord } from "../record-preview.js";
8
+ import { eventId } from "./calendar-dnd.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { useDraggable } from "@dnd-kit/core";
11
+ //#region src/app/displays/calendar-event.tsx
12
+ /** The text an event shows: its `title` field, or the view's title, with the names of the records relations point at. */
13
+ const eventTitle = (model, view, display, row) => {
14
+ const name = display.title ?? recordTitleField(model, view);
15
+ const field = model.fields[name];
16
+ const text = row.labels[name] ?? (field ? displayValue(field, row.record[name], view?.fields[name]) : "");
17
+ return text === "" ? row.key : text;
18
+ };
19
+ const startTime = (model, display, row, day) => {
20
+ const value = row.record[display.start];
21
+ if (model.fields[display.start]?.type !== "timestamp" || typeof value !== "string") return void 0;
22
+ const ms = Date.parse(value);
23
+ return dayIn(ms, viewerZone()) === day ? formatTime(ms) : void 0;
24
+ };
25
+ /** An event as a one-line link to its record, its time first for a timestamp, styled as a small record card. */
26
+ const EventChip = ({ model, view, display, row, day }) => {
27
+ const seedRecord = useSeedRecord(model);
28
+ const time = startTime(model, display, row, day);
29
+ return /* @__PURE__ */ jsxs(AppLink, {
30
+ to: {
31
+ kind: "record",
32
+ resource: model.name,
33
+ key: row.key
34
+ },
35
+ onClick: () => seedRecord(row.record),
36
+ 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",
37
+ children: [time && /* @__PURE__ */ jsx("span", {
38
+ className: "shrink-0 tabular-nums text-muted-foreground",
39
+ children: time
40
+ }), /* @__PURE__ */ jsx("span", {
41
+ className: "min-w-0 truncate",
42
+ children: eventTitle(model, view, display, row)
43
+ })]
44
+ });
45
+ };
46
+ /** 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. */
47
+ const CalendarEvent = ({ movable, dragged, ...props }) => {
48
+ const { setNodeRef, setActivatorNodeRef, listeners, isDragging } = useDraggable({
49
+ id: eventId(props.row.key, props.day),
50
+ data: {
51
+ row: props.row,
52
+ day: props.day
53
+ },
54
+ disabled: !movable
55
+ });
56
+ return /* @__PURE__ */ jsx("div", {
57
+ ref: (element) => {
58
+ setNodeRef(element);
59
+ setActivatorNodeRef(element?.querySelector("a") ?? element);
60
+ },
61
+ ...listeners,
62
+ onDragStart: (event) => event.preventDefault(),
63
+ onClickCapture: (event) => {
64
+ if (!dragged()) return;
65
+ event.preventDefault();
66
+ event.stopPropagation();
67
+ },
68
+ className: cn("min-w-0", movable && "cursor-grab touch-manipulation", isDragging && "opacity-40"),
69
+ children: /* @__PURE__ */ jsx(EventChip, { ...props })
70
+ });
71
+ };
72
+ //#endregion
73
+ export { CalendarEvent, EventChip, eventTitle };
@@ -0,0 +1,129 @@
1
+ import { dayIn, instantAt, startOfDayIn, viewerZone, zoneOffset } from "../../format/zone.js";
2
+ import { printFilter, where } from "@protobase/schema";
3
+ //#region src/app/displays/calendar-range.ts
4
+ const dayMs = 864e5;
5
+ const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
6
+ const startOf = (day) => Date.parse(`${day}T00:00:00Z`);
7
+ /** The day `count` days after `day`, or before it for a negative count. */
8
+ const addDays = (day, count) => dayOf(startOf(day) + count * dayMs);
9
+ const monday = (day) => addDays(day, -((new Date(startOf(day)).getUTCDay() + 6) % 7));
10
+ const monthOf = (day, step = 0) => {
11
+ const date = new Date(startOf(day));
12
+ return dayOf(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + step, 1));
13
+ };
14
+ const normalised = ({ scale, day }) => ({
15
+ scale,
16
+ day: scale === "month" ? monthOf(day) : monday(day)
17
+ });
18
+ /** 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`. */
19
+ const calendarView = (at, now, zone = viewerZone()) => normalised(at ?? {
20
+ scale: "month",
21
+ day: dayIn(now, zone)
22
+ });
23
+ /** The month or week of `scale` that holds `now`. */
24
+ const todayView = (scale, now, zone = viewerZone()) => normalised({
25
+ scale,
26
+ day: dayIn(now, zone)
27
+ });
28
+ /** The month or week before (-1) or after (1) `view`. */
29
+ const stepView = (view, step) => view.scale === "month" ? {
30
+ scale: "month",
31
+ day: monthOf(view.day, step)
32
+ } : {
33
+ scale: "week",
34
+ day: addDays(monday(view.day), 7 * step)
35
+ };
36
+ /** 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. */
37
+ const gridDays = (view) => {
38
+ const first = monday(view.scale === "month" ? monthOf(view.day) : view.day);
39
+ const last = view.scale === "month" ? addDays(monday(addDays(monthOf(view.day, 1), -1)), 6) : addDays(first, 6);
40
+ const count = (startOf(last) - startOf(first)) / dayMs + 1;
41
+ return Array.from({ length: count }, (_, index) => addDays(first, index));
42
+ };
43
+ /** The days `view` shows, as a range from its first day to the day after its last. */
44
+ const rangeOf = (view) => {
45
+ const days = gridDays(view);
46
+ return {
47
+ from: days[0],
48
+ to: addDays(days.at(-1), 1)
49
+ };
50
+ };
51
+ const isTimestamp = (display, model) => model.fields[display.start]?.type === "timestamp";
52
+ /**
53
+ * The records on the days of `range`, as a list filter: those starting in it, and with an `end` those starting before
54
+ * 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
55
+ * not on the day it starts. Each branch reads one indexed field.
56
+ */
57
+ const rangeFilter = (display, model, range, zone = viewerZone()) => {
58
+ const timestamp = isTimestamp(display, model);
59
+ const instant = (day) => new Date(startOfDayIn(day, zone)).toISOString().replace(".000Z", "Z");
60
+ const bound = (day) => timestamp ? {
61
+ kind: "timestamp",
62
+ value: instant(day),
63
+ span: {
64
+ start: 0,
65
+ end: 0
66
+ }
67
+ } : {
68
+ kind: "string",
69
+ value: day,
70
+ span: {
71
+ start: 0,
72
+ end: 0
73
+ }
74
+ };
75
+ const within = where.and(where.gte(display.start, bound(range.from)), where.lt(display.start, bound(range.to)));
76
+ if (display.end === void 0) return printFilter(within);
77
+ const ending = timestamp ? where.gt(display.end, bound(range.from)) : where.gte(display.end, bound(range.from));
78
+ return printFilter(where.or(within, where.and(where.lt(display.start, bound(range.from)), ending)));
79
+ };
80
+ /** The list's filter, its search included, narrowed to the calendar's days. */
81
+ const withRange = (filter, range) => filter === "" ? range : `(${filter}) AND (${range})`;
82
+ const instantOf = (value) => {
83
+ if (typeof value !== "string") return void 0;
84
+ const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
85
+ return Number.isNaN(ms) ? void 0 : ms;
86
+ };
87
+ /**
88
+ * The days of `days` a record is on: from its start's day through its end's, both included, where a timestamp `end`
89
+ * at midnight is over before that day starts; its start day alone without an end or with one before its start.
90
+ */
91
+ const eventDays = (record, display, model, days, zone = viewerZone()) => {
92
+ const start = instantOf(record[display.start]);
93
+ if (start === void 0) return [];
94
+ const timestamp = isTimestamp(display, model);
95
+ const day = (ms) => timestamp ? dayIn(ms, zone) : dayOf(ms);
96
+ const first = day(start);
97
+ const end = display.end === void 0 ? void 0 : instantOf(record[display.end]);
98
+ const ending = end === void 0 ? first : day(timestamp ? end - 1 : end);
99
+ const last = ending < first ? first : ending;
100
+ return days.filter((day) => day >= first && day <= last);
101
+ };
102
+ /** The rows on each day of `days`, each day's in start order. */
103
+ const byDay = (rows, display, model, days, zone = viewerZone()) => {
104
+ const placed = new Map(days.map((day) => [day, []]));
105
+ const ordered = rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
106
+ for (const row of ordered) for (const day of eventDays(row.record, display, model, days, zone)) placed.get(day).push(row);
107
+ return placed;
108
+ };
109
+ /**
110
+ * The update that moves a record shown on `from` to `to`: its `start`, and its `end` when it has one, moved by the same
111
+ * number of days, a timestamp keeping its time of day on the clock of `zone`.
112
+ */
113
+ const movedPatch = (record, display, model, from, to, zone = viewerZone()) => {
114
+ const days = (startOf(to) - startOf(from)) / dayMs;
115
+ const timestamp = isTimestamp(display, model);
116
+ const moved = (value) => {
117
+ const ms = instantOf(value);
118
+ if (ms === void 0) return void 0;
119
+ return timestamp ? new Date(instantAt(ms + zoneOffset(ms, zone) + days * dayMs, zone)).toISOString() : addDays(dayOf(ms), days);
120
+ };
121
+ const start = moved(record[display.start]);
122
+ const end = display.end === void 0 ? void 0 : moved(record[display.end]);
123
+ return {
124
+ ...start !== void 0 && { [display.start]: start },
125
+ ...end !== void 0 && { [display.end]: end }
126
+ };
127
+ };
128
+ //#endregion
129
+ 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