@rebasepro/admin 0.13.0 → 0.13.1-canary.g249daa1

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 (154) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-DgfCYDmF.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-DgfCYDmF.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CCrruunW.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CCrruunW.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-C_EwDSi1.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-C_EwDSi1.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  12. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  13. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +92 -0
  14. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  18. package/dist/components/DetailViewBinding.d.ts +15 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  20. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  21. package/dist/components/EditViewBinding.d.ts +30 -1
  22. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  23. package/dist/components/EntityIdentityBar.d.ts +43 -1
  24. package/dist/components/EntityViewBinding.d.ts +60 -6
  25. package/dist/components/RebaseLayout.d.ts +1 -1
  26. package/dist/components/RebaseShell.d.ts +1 -1
  27. package/dist/components/RelationSelector.d.ts +8 -1
  28. package/dist/components/UserSelector.d.ts +8 -1
  29. package/dist/components/app/Scaffold.d.ts +9 -3
  30. package/dist/components/index.d.ts +2 -0
  31. package/dist/{export-J79rMoNj.js → export-CV-qQfxK.js} +2 -2
  32. package/dist/{export-J79rMoNj.js.map → export-CV-qQfxK.js.map} +1 -1
  33. package/dist/form/components/FieldBlock.d.ts +58 -4
  34. package/dist/form/components/FormRail.d.ts +13 -0
  35. package/dist/form/components/FormSections.d.ts +15 -1
  36. package/dist/form/components/useRailVisible.d.ts +5 -4
  37. package/dist/form/form_utils.d.ts +38 -1
  38. package/dist/{history-D7L_JlLO.js → history-By7QT3V0.js} +4 -4
  39. package/dist/{history-D7L_JlLO.js.map → history-By7QT3V0.js.map} +1 -1
  40. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  41. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  42. package/dist/{import-BvYl--U4.js → import-CYbvYa_x.js} +3 -3
  43. package/dist/{import-BvYl--U4.js.map → import-CYbvYa_x.js.map} +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +152 -57
  46. package/dist/index.js.map +1 -1
  47. package/dist/preview/compact_text.d.ts +23 -0
  48. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  49. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  51. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  52. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  53. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  54. package/dist/routes/RebaseRoute.d.ts +1 -1
  55. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  56. package/dist/util/number_format.d.ts +14 -0
  57. package/dist/util/previews.d.ts +9 -2
  58. package/dist/util/view_constants.d.ts +24 -0
  59. package/dist/util/view_mode.d.ts +18 -0
  60. package/dist/{util-GiwPgxnL.js → util-B8hurkxt.js} +3569 -1692
  61. package/dist/util-B8hurkxt.js.map +1 -0
  62. package/package.json +9 -9
  63. package/src/collection_editor/serializable_types.ts +1 -0
  64. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  65. package/src/components/AdditionalFieldValue.tsx +68 -0
  66. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  67. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  68. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  69. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  70. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  71. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  72. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  73. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  74. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +51 -12
  75. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  76. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  77. package/src/components/CollectionViewBinding/SearchHighlight.tsx +285 -0
  78. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  82. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  83. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  84. package/src/components/CollectionViewBinding/utils.ts +7 -4
  85. package/src/components/DefaultDrawer.tsx +19 -23
  86. package/src/components/DetailViewBinding.tsx +109 -62
  87. package/src/components/DrawerNavigationGroup.tsx +16 -39
  88. package/src/components/DrawerNavigationItem.tsx +44 -16
  89. package/src/components/EditViewBinding.tsx +233 -50
  90. package/src/components/EntityDisplayHeader.tsx +170 -0
  91. package/src/components/EntityIdentityBar.tsx +163 -23
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityPreviewBinding.tsx +58 -18
  94. package/src/components/EntityViewBinding.tsx +344 -41
  95. package/src/components/InlineEntityPreview.tsx +10 -10
  96. package/src/components/RebaseLayout.tsx +2 -2
  97. package/src/components/RebaseShell.tsx +2 -2
  98. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  99. package/src/components/RelationSelector.tsx +23 -9
  100. package/src/components/SideDialogs.tsx +15 -3
  101. package/src/components/SidePanelBinding.tsx +122 -66
  102. package/src/components/UserSelector.tsx +20 -8
  103. package/src/components/app/Scaffold.tsx +112 -10
  104. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  105. package/src/components/index.ts +3 -0
  106. package/src/data_import/utils/data.ts +9 -1
  107. package/src/form/EntityForm.tsx +74 -53
  108. package/src/form/EntityFormBinding.tsx +24 -11
  109. package/src/form/PropertyFieldBinding.tsx +2 -2
  110. package/src/form/components/FieldBlock.tsx +80 -10
  111. package/src/form/components/FormRail.tsx +19 -2
  112. package/src/form/components/FormSections.tsx +32 -5
  113. package/src/form/components/StorageUploadProgress.tsx +9 -3
  114. package/src/form/components/useRailVisible.tsx +5 -4
  115. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  116. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  117. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  118. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  119. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  121. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  122. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  123. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  124. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  125. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  126. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  127. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  128. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  129. package/src/form/form_utils.ts +78 -0
  130. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  131. package/src/hooks/useEntityDisplay.tsx +262 -0
  132. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  133. package/src/index.ts +9 -1
  134. package/src/preview/PropertyPreview.tsx +25 -5
  135. package/src/preview/compact_text.ts +68 -0
  136. package/src/preview/components/BooleanPreview.tsx +5 -2
  137. package/src/preview/components/StorageThumbnail.tsx +5 -2
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  140. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  141. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  142. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  143. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  144. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  145. package/src/routes/RebaseRoute.tsx +62 -4
  146. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  147. package/src/util/navigation_utils.ts +4 -2
  148. package/src/util/number_format.ts +46 -0
  149. package/src/util/previews.ts +8 -55
  150. package/src/util/property_utils.tsx +6 -1
  151. package/src/util/view_constants.ts +27 -0
  152. package/src/util/view_mode.ts +36 -0
  153. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  154. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -0,0 +1,285 @@
1
+ import React, { useMemo } from "react";
2
+ import type { SearchMatch } from "@rebasepro/types";
3
+
4
+ /**
5
+ * Showing a searcher *why* a row is in their results.
6
+ *
7
+ * A ranked list answers "which rows" and never "why this one". When the term is
8
+ * in the title that is self-evident; when it is in a field the list does not
9
+ * show — a certification, a nested answer, a note — the row looks arbitrary and
10
+ * the only way to find out is to open it. That is the whole reason this exists.
11
+ *
12
+ * Two jobs, and they are deliberately separate:
13
+ *
14
+ * 1. {@link Highlighted} marks the term inside a value the list already shows.
15
+ * 2. {@link offSlotMatch} finds a match in a field that has no slot, so the
16
+ * caller can put it where the subtitle would have gone.
17
+ */
18
+
19
+ /** `<mark>` is Postgres's delimiter, and what `ts_headline` emits. */
20
+ const MARK = /(<mark>.*?<\/mark>)/g;
21
+
22
+ /**
23
+ * Render a `ts_headline` snippet: text with `<mark>` around each hit.
24
+ *
25
+ * Split-and-render rather than `dangerouslySetInnerHTML`. The text around the
26
+ * marks is whatever a user typed into their own record, and the only markup it
27
+ * is allowed to carry is the highlight Postgres put there. Anything else stays
28
+ * inert text.
29
+ */
30
+ export function Snippet({ text, className }: { text: string; className?: string }) {
31
+ const parts = useMemo(() => text.split(MARK), [text]);
32
+ return (
33
+ <span className={className}>
34
+ {parts.map((part, i) =>
35
+ part.startsWith("<mark>")
36
+ ? (
37
+ <mark
38
+ key={i}
39
+ className="bg-amber-200/70 dark:bg-amber-400/30 text-inherit rounded-[3px] px-[1px]"
40
+ >
41
+ {part.slice(6, -7)}
42
+ </mark>
43
+ )
44
+ : <React.Fragment key={i}>{part}</React.Fragment>
45
+ )}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Mark every occurrence of the searched terms inside a plain value.
52
+ *
53
+ * Used for values the list already renders, where the server's snippet would be
54
+ * the wrong thing to show: the snippet is truncated to a fragment and folded to
55
+ * the indexed form, while the cell should keep the record's real text. So the
56
+ * terms are re-matched here, on the display value.
57
+ *
58
+ * Deliberately dumber than the server's matching, and it has to be: this cannot
59
+ * stem or unaccent without reimplementing the text search configuration in the
60
+ * browser and getting it subtly different. It marks what it can find as a
61
+ * prefix, case- and accent-insensitively, and marks nothing when it cannot —
62
+ * a highlight that is merely absent reads as "no hit in this field", which is
63
+ * the honest answer for a stem the browser cannot reproduce.
64
+ */
65
+ export function Highlighted({ text, terms, className }: { text: string; terms: string[]; className?: string }) {
66
+ const segments = useMemo(() => splitOnTerms(text, terms), [text, terms]);
67
+ if (segments.length <= 1) return <span className={className}>{text}</span>;
68
+ return (
69
+ <span className={className}>
70
+ {segments.map((seg, i) =>
71
+ seg.hit
72
+ ? (
73
+ <mark
74
+ key={i}
75
+ className="bg-amber-200/70 dark:bg-amber-400/30 text-inherit rounded-[3px] px-[1px]"
76
+ >
77
+ {seg.text}
78
+ </mark>
79
+ )
80
+ : <React.Fragment key={i}>{seg.text}</React.Fragment>
81
+ )}
82
+ </span>
83
+ );
84
+ }
85
+
86
+ /** Strip diacritics so `gestion` marks `gestión`, mirroring the index's unaccent. */
87
+ const fold = (value: string): string =>
88
+ value.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
89
+
90
+ interface Segment { text: string; hit: boolean }
91
+
92
+ /**
93
+ * Split `text` into hit / non-hit runs.
94
+ *
95
+ * Matches on the *folded* form but slices the original, so the marked text
96
+ * keeps its accents and case — `Gestión`, not `gestion`. Folding is
97
+ * length-preserving for the diacritics this handles, which is what makes the
98
+ * indices transferable; a fold that changed length would misalign the slices.
99
+ */
100
+ export function splitOnTerms(text: string, terms: string[]): Segment[] {
101
+ const haystack = fold(text);
102
+ const needles = terms.map(fold).filter(t => t.length >= 2);
103
+ if (needles.length === 0) return [{ text, hit: false }];
104
+
105
+ // Word-start matches only. A search for "iso" marking the "iso" inside
106
+ // "revisor" is noise, and reads as a bug rather than a feature.
107
+ const bounds: [number, number][] = [];
108
+ for (const needle of needles) {
109
+ let from = 0;
110
+ for (;;) {
111
+ const at = haystack.indexOf(needle, from);
112
+ if (at === -1) break;
113
+ const before = at === 0 ? " " : haystack[at - 1];
114
+ if (!/[\p{L}\p{N}]/u.test(before)) bounds.push([at, at + needle.length]);
115
+ from = at + needle.length;
116
+ }
117
+ }
118
+ if (bounds.length === 0) return [{ text, hit: false }];
119
+
120
+ // Merge overlaps so two terms hitting the same run mark it once.
121
+ bounds.sort((a, b) => a[0] - b[0]);
122
+ const merged: [number, number][] = [];
123
+ for (const [start, end] of bounds) {
124
+ const last = merged[merged.length - 1];
125
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
126
+ else merged.push([start, end]);
127
+ }
128
+
129
+ const segments: Segment[] = [];
130
+ let cursor = 0;
131
+ for (const [start, end] of merged) {
132
+ if (start > cursor) segments.push({ text: text.slice(cursor, start), hit: false });
133
+ segments.push({ text: text.slice(start, end), hit: true });
134
+ cursor = end;
135
+ }
136
+ if (cursor < text.length) segments.push({ text: text.slice(cursor), hit: false });
137
+ return segments;
138
+ }
139
+
140
+ /**
141
+ * The terms a searcher typed, as things to look for in a rendered value.
142
+ *
143
+ * Quoted phrases stay whole; `-excluded` terms are dropped, since marking a
144
+ * word the user asked *not* to see would be actively wrong. `websearch_to_tsquery`
145
+ * reads the same syntax on the server.
146
+ */
147
+ export function searchTerms(searchString: string | undefined): string[] {
148
+ if (!searchString) return [];
149
+ const terms: string[] = [];
150
+ for (const [, phrase, bare] of searchString.matchAll(/"([^"]+)"|(\S+)/g)) {
151
+ const term = phrase ?? bare;
152
+ if (!term || term.startsWith("-")) continue;
153
+ if (/^(or|and)$/i.test(term)) continue;
154
+ terms.push(term);
155
+ }
156
+ return terms;
157
+ }
158
+
159
+ /**
160
+ * The best match to show when the term is nowhere the list already displays.
161
+ *
162
+ * `shownKeys` are the property paths the row is already rendering, so a match
163
+ * there needs no explaining — {@link Highlighted} marks it in place and the
164
+ * subtitle keeps its own job. Anything else is invisible, and one of those is
165
+ * what the subtitle slot gets replaced with.
166
+ *
167
+ * The first such match wins, and the server returns them in the order the
168
+ * collection declared its `search` fields — so the field an author put first is
169
+ * the one a reader sees, rather than whichever the database aggregated first.
170
+ */
171
+ export function offSlotMatch(
172
+ matches: SearchMatch[] | undefined,
173
+ shownKeys: (string | undefined)[]
174
+ ): SearchMatch | undefined {
175
+ if (!matches || matches.length === 0) return undefined;
176
+ const shown = new Set(shownKeys.filter((k): k is string => Boolean(k)));
177
+ return matches.find(m => !shown.has(m.field));
178
+ }
179
+
180
+ // ── Finding the match locally ───────────────────────────────────────────────
181
+
182
+ /**
183
+ * Where a search term appears in a row, computed from the row itself.
184
+ *
185
+ * The server can say this too — `_matches`, from `ts_headline` — and its answer
186
+ * is better, because it knows the stemming the index used. This exists because
187
+ * the admin cannot rely on getting it: rows reach the panel over three
188
+ * different transports (REST, the realtime socket, the offline cache) and only
189
+ * one of them carries query metadata. A feature that explains a result on some
190
+ * loads and not others is worse than one that always gives a slightly simpler
191
+ * answer.
192
+ *
193
+ * So: use the server's when it is there, fall back to this. Both produce the
194
+ * same `{ field, snippet }` shape, and this one marks with the same `<mark>`.
195
+ */
196
+ export function localRowMatch(
197
+ values: Record<string, unknown> | undefined,
198
+ properties: Record<string, unknown> | undefined,
199
+ terms: string[],
200
+ shownKeys: (string | undefined)[]
201
+ ): SearchMatch | undefined {
202
+ if (!values || !properties || terms.length === 0) return undefined;
203
+ const shown = new Set(shownKeys.filter((k): k is string => Boolean(k)));
204
+
205
+ // Declared order, so the field an author put first wins — the same rule the
206
+ // server's `_matches` follows.
207
+ for (const [key, property] of Object.entries(properties)) {
208
+ if (shown.has(key)) continue;
209
+ const found = findInValue(values[key], terms, key, property as { type?: string });
210
+ if (found) return found;
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ /**
216
+ * Context kept before and after a hit, in characters.
217
+ *
218
+ * Asymmetric on purpose. The snippet renders on one truncated line, so anything
219
+ * before the hit competes with the hit itself for the width available — with a
220
+ * generous lead-in the mark is pushed off the right edge and the reader sees a
221
+ * fragment with nothing highlighted in it, which is worse than no snippet at
222
+ * all. Lead with just enough to not start abruptly, then run on.
223
+ */
224
+ const SNIPPET_LEAD = 16;
225
+ const SNIPPET_TRAIL = 90;
226
+
227
+ function findInValue(
228
+ value: unknown,
229
+ terms: string[],
230
+ path: string,
231
+ property: { type?: string } | undefined
232
+ ): SearchMatch | undefined {
233
+ // An enum renders as a chip from a fixed vocabulary; marking inside one
234
+ // reads as noise, and `where` is the right tool for those anyway.
235
+ if (property && "enum" in property && property.enum) return undefined;
236
+
237
+ if (typeof value === "string") {
238
+ const snippet = snippetAround(value, terms);
239
+ return snippet ? { field: path, snippet } : undefined;
240
+ }
241
+ if (Array.isArray(value)) {
242
+ for (const item of value) {
243
+ const found = findInValue(item, terms, path, undefined);
244
+ if (found) return found;
245
+ }
246
+ return undefined;
247
+ }
248
+ if (value && typeof value === "object") {
249
+ // Nested maps: report the path the author would recognise, which is the
250
+ // dotted one the `search` block uses.
251
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
252
+ const found = findInValue(nested, terms, `${path}.${key}`, undefined);
253
+ if (found) return found;
254
+ }
255
+ }
256
+ return undefined;
257
+ }
258
+
259
+ /**
260
+ * The text around the first hit, with every hit in view marked.
261
+ *
262
+ * Trimmed to a readable window rather than the whole field: a match in a long
263
+ * body should show the sentence it is in, not the first line of the document,
264
+ * and certainly not the document.
265
+ */
266
+ export function snippetAround(text: string, terms: string[]): string | undefined {
267
+ const segments = splitOnTerms(text, terms);
268
+ const firstHit = segments.findIndex(s => s.hit);
269
+ if (firstHit === -1) return undefined;
270
+
271
+ const before = segments.slice(0, firstHit).map(s => s.text).join("");
272
+ const head = before.length > SNIPPET_LEAD
273
+ // Start at a word boundary so the snippet does not open mid-word.
274
+ ? "…" + before.slice(before.length - SNIPPET_LEAD).replace(/^\S*\s/, "")
275
+ : before;
276
+
277
+ let out = head;
278
+ for (let i = firstHit; i < segments.length; i++) {
279
+ const seg = segments[i];
280
+ out += seg.hit ? `<mark>${seg.text}</mark>` : seg.text;
281
+ // Enough context after the hit to read it, then stop.
282
+ if (out.length > head.length + SNIPPET_TRAIL) { out += "…"; break; }
283
+ }
284
+ return out.trim();
285
+ }
@@ -0,0 +1,156 @@
1
+ import React from "react";
2
+ import type { PreviewSize } from "../../types/components/PropertyPreviewProps";
3
+ import { PropertyPreview } from "../../preview";
4
+ import { StorageThumbnail } from "../../preview/components/StorageThumbnail";
5
+ import { UrlComponentPreview } from "../../preview/components/UrlComponentPreview";
6
+ import { Chip } from "@rebasepro/ui";
7
+ import type { PreviewSlot } from "./usePreviewSlots";
8
+
9
+ export interface SlotValueProps {
10
+ slot: PreviewSlot | undefined;
11
+ size: PreviewSize;
12
+ /**
13
+ * Relations and references render as one line of linked text rather than as
14
+ * a card. Defaults to true: a slot is a line in a row or a card, and a
15
+ * relation that renders its own bordered card there — with its own id line
16
+ * and its own side-panel button — is a card inside a card.
17
+ *
18
+ * Pass `false` only for a slot with room for one, which in practice is
19
+ * none of them.
20
+ */
21
+ textOnly?: boolean;
22
+ /** Images only: fill the container rather than fitting inside it. */
23
+ fill?: boolean;
24
+ }
25
+
26
+ /**
27
+ * One display slot, rendered.
28
+ *
29
+ * A slot's value arrives one of two ways and they cannot be rendered the same:
30
+ *
31
+ * - **From a property** — render it with that property's own preview, so an
32
+ * enum status keeps its colour, a date keeps its format and a storage path
33
+ * becomes a thumbnail. This is why a declared path beats a resolver whenever
34
+ * the value is actually on the record.
35
+ * - **From a resolver** — there is no property, because the value was computed
36
+ * or fetched. All that can be said about it is that it is text.
37
+ *
38
+ * The branch lives here, once, rather than at each of the fourteen places the
39
+ * list row, the card and the board card render a slot. That count is the reason:
40
+ * the last time a rule like this was inlined per call site, it drifted into
41
+ * seven versions.
42
+ */
43
+ export function SlotValue({ slot, size, textOnly = true, fill }: SlotValueProps) {
44
+
45
+ if (!slot || slot.value === undefined || slot.value === null) return null;
46
+
47
+ if (!slot.property || !slot.propertyKey) {
48
+ // A computed image is still an image. "There is no property, so all that
49
+ // can be said about it is that it is text" holds for a title and fails
50
+ // here: `display.image` accepts "a storage path or a URL … so a resolver
51
+ // may return either", and both used to be printed as their own string
52
+ // inside the image frame.
53
+ if (slot.role === "image" && typeof slot.value === "string" && slot.value.trim()) {
54
+ return <ComputedImage value={slot.value.trim()} size={size} fill={fill}/>;
55
+ }
56
+
57
+ // Computed. Arrays (a `tags` resolver) join rather than rendering as
58
+ // "[object Object]" via String().
59
+ const text = Array.isArray(slot.value)
60
+ ? slot.value.map(item => String(item)).join(", ")
61
+ : String(slot.value);
62
+ return <>{text}</>;
63
+ }
64
+
65
+ return <PropertyPreview
66
+ propertyKey={slot.propertyKey}
67
+ value={slot.value as never}
68
+ property={slot.property}
69
+ size={size}
70
+ textOnly={textOnly}
71
+ fill={fill}/>;
72
+ }
73
+
74
+ /**
75
+ * The `tags` slot, as chips beside the status.
76
+ *
77
+ * Honours both arms the same way the rest of the file does. A declared **path**
78
+ * goes through {@link SlotValue}, so an array of enum values keeps its own
79
+ * colours — that is the stated advantage of naming a property. A **resolver**
80
+ * returns bare strings with no property to render them as, so they become plain
81
+ * chips; a single string is accepted as one tag, which the role's type documents.
82
+ *
83
+ * Lives here rather than in the list, the card and the board card because that is
84
+ * three copies of the same decision, and this file exists to hold exactly one.
85
+ */
86
+ export function TagChips({ slot, max = 4 }: { slot: PreviewSlot, max?: number }) {
87
+
88
+ if (slot.property && slot.propertyKey) {
89
+ return <SlotValue slot={slot} size="small"/>;
90
+ }
91
+
92
+ const values = (Array.isArray(slot.value) ? slot.value : [slot.value])
93
+ .filter(item => item !== undefined && item !== null && String(item).trim() !== "")
94
+ .map(item => String(item));
95
+
96
+ if (values.length === 0) return null;
97
+
98
+ const shown = values.slice(0, max);
99
+ const hidden = values.length - shown.length;
100
+
101
+ return <>
102
+ {shown.map(value => (
103
+ <Chip key={value}
104
+ size="smallest"
105
+ className="!text-[10px] !leading-tight !py-0 shrink-0 max-w-[100px] truncate">
106
+ {value}
107
+ </Chip>
108
+ ))}
109
+ {hidden > 0 && (
110
+ <span className="text-[10px] text-surface-400 dark:text-surface-500 shrink-0">+{hidden}</span>
111
+ )}
112
+ </>;
113
+ }
114
+
115
+ /**
116
+ * Whether a computed image string can be fetched as-is, or has to be resolved
117
+ * through storage first.
118
+ *
119
+ * Exported because it is the whole decision: rendering either branch needs a
120
+ * provider (a storage source, a DOM), and the thing worth pinning is which
121
+ * branch a given string takes. `//host/x` is protocol-relative and equally
122
+ * fetchable; `data:` and `blob:` are already the bytes.
123
+ */
124
+ export function isFetchableImageUrl(value: string): boolean {
125
+ return /^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value);
126
+ }
127
+
128
+ /**
129
+ * A string a resolver returned for the `image` role, rendered as a picture.
130
+ *
131
+ * Two things can arrive here and they need different handling, which is the whole
132
+ * reason a property-path image is preferable: a storage property knows which
133
+ * backend holds it, and a bare string does not.
134
+ *
135
+ * - An **absolute URL** is already fetchable. Rendering it through the storage
136
+ * layer would ask a bucket to sign a path that was never in it.
137
+ * - Anything else is read as a **storage path** on the default source, which is
138
+ * the only source a value with no property can be attributed to. A resolver
139
+ * pointing at a named source should name the property instead.
140
+ */
141
+ function ComputedImage({ value, size, fill }: { value: string, size: PreviewSize, fill?: boolean }) {
142
+ const isAbsolute = isFetchableImageUrl(value);
143
+
144
+ if (isAbsolute) {
145
+ return <UrlComponentPreview previewType="image"
146
+ url={value}
147
+ size={size}
148
+ fill={fill}
149
+ hint={value}/>;
150
+ }
151
+
152
+ return <StorageThumbnail storagePathOrDownloadUrl={value}
153
+ storeUrl={false}
154
+ size={size}
155
+ fill={fill}/>;
156
+ }
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- import { IconButton, iconSize, Tooltip, XIcon } from "@rebasepro/ui";
3
+ import { IconButton, iconSize, PanelLeftCloseIcon, Tooltip } from "@rebasepro/ui";
4
4
  import { useTranslation } from "@rebasepro/app";
5
5
 
6
6
  import { useSplitView } from "./SplitViewContext";
@@ -9,11 +9,24 @@ import { useSplitView } from "./SplitViewContext";
9
9
  * Closes the list of a split view, leaving the open record on its own.
10
10
  *
11
11
  * It leads the record's own app bar, where the breadcrumb starts — this is the
12
- * entity pane's old "Open full screen" button, moved to that edge and wearing an
13
- * X: closing the list and opening the record full screen are the same thing, so
14
- * there is one control and one route (`#full`) rather than two.
12
+ * entity pane's old "Open full screen" button, moved to that edge: closing the
13
+ * list and opening the record full screen are the same thing, so there is one
14
+ * control and one route (`#full`) rather than two. Full screen carries the
15
+ * mirrored icon to bring the list back.
16
+ *
17
+ * A pane glyph rather than the double chevron it wore first. Now that the bar
18
+ * ends in a ✕ that dismisses the record, an arrow pointing off the left edge of
19
+ * the record's own bar was the other thing on it that read as "close me" — and
20
+ * it is the one control here that does not act on the record at all.
15
21
  */
16
- export function SplitListCloseButton() {
22
+ export function SplitListCloseButton({ onBeforeHide }: {
23
+ /**
24
+ * Run before the layout changes, while this form is still mounted: the edit
25
+ * view uses it to hand its edit in progress to the full-screen form that is
26
+ * about to show the same record.
27
+ */
28
+ onBeforeHide?: () => void;
29
+ } = {}) {
17
30
 
18
31
  const { t } = useTranslation();
19
32
  const splitView = useSplitView();
@@ -24,9 +37,12 @@ export function SplitListCloseButton() {
24
37
  <Tooltip title={t("hide_list")}>
25
38
  <IconButton
26
39
  size="small"
27
- onClick={splitView.openFullScreen}
40
+ onClick={() => {
41
+ onBeforeHide?.();
42
+ splitView.openFullScreen();
43
+ }}
28
44
  aria-label={t("hide_list")}>
29
- <XIcon size={iconSize.smallest}/>
45
+ <PanelLeftCloseIcon size={iconSize.smallest}/>
30
46
  </IconButton>
31
47
  </Tooltip>
32
48
  );
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+
3
+ import { IconButton, iconSize, PanelLeftOpenIcon, Tooltip } from "@rebasepro/ui";
4
+ import { useTranslation } from "@rebasepro/app";
5
+
6
+ /**
7
+ * Brings the list back beside a record that was opened full screen.
8
+ *
9
+ * The mirror of {@link SplitListCloseButton}: the same edge of the same bar,
10
+ * the same pane glyph opening rather than closing, and the same single route —
11
+ * showing the list is dropping the `#full` hash. It replaces the back arrow
12
+ * where it applies, because the list it unfolds already holds the record.
13
+ */
14
+ export function SplitListShowButton({ onClick }: { onClick: () => void }) {
15
+
16
+ const { t } = useTranslation();
17
+
18
+ return (
19
+ <Tooltip title={t("show_list")}>
20
+ <IconButton
21
+ size="small"
22
+ onClick={onClick}
23
+ aria-label={t("show_list")}>
24
+ <PanelLeftOpenIcon size={iconSize.smallest}/>
25
+ </IconButton>
26
+ </Tooltip>
27
+ );
28
+ }
@@ -79,6 +79,21 @@ function isListShortcutTarget(target: HTMLElement | null, detailPanel: HTMLEleme
79
79
  return !detailPanel?.contains(target);
80
80
  }
81
81
 
82
+ /**
83
+ * The list's share of the split, as a percentage, before anyone drags it.
84
+ *
85
+ * The list only has to identify a record — a title, a subtitle and a status
86
+ * chip — while the detail beside it carries the whole record and its rail. At
87
+ * 30% the list was taking width from the side that had four columns of fields
88
+ * to lay out; 26% still was, by about the width of one field.
89
+ *
90
+ * A default, not a constraint: {@link savePanelSize} remembers the drag per
91
+ * collection, so anyone who has already sized this list keeps their size. The
92
+ * drag floor is `minPanelSizePx`, and it holds below ~1100px of split — at that
93
+ * width this percentage is already the floor.
94
+ */
95
+ const DEFAULT_PANEL_SIZE = 22;
96
+
82
97
  function getSavedPanelSize(path: string): number {
83
98
  try {
84
99
  const saved = localStorage.getItem(`${PANEL_SIZE_KEY}_${path}`);
@@ -87,7 +102,7 @@ function getSavedPanelSize(path: string): number {
87
102
  if (!isNaN(val) && val > 0 && val < 100) return val;
88
103
  }
89
104
  } catch { /* ignore */ }
90
- return 30;
105
+ return DEFAULT_PANEL_SIZE;
91
106
  }
92
107
 
93
108
  function savePanelSize(path: string, size: number) {
@@ -150,8 +165,20 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
150
165
  dirty.current = modified;
151
166
  }, []);
152
167
 
153
- // The form remounts per entity, so its dirty state must not leak into the next one.
168
+ // The form remounts per entity, so its dirty state must not leak into the
169
+ // next one — and it has to be cleared here rather than left to the next
170
+ // form's own first report, because that form spends the load in a skeleton
171
+ // and a navigation during it would prompt about the record before it.
172
+ //
173
+ // On a *change* of record only, never on this view's own mount: a record
174
+ // arriving from full screen carries its edit back in and opens dirty, and
175
+ // the form says so from its first effect — which runs before this one, being
176
+ // a child's. Clearing unconditionally overwrote that report with `false`,
177
+ // and the way out of the split stopped asking about a live edit.
178
+ const previousEntityId = useRef(selectedEntityId);
154
179
  useEffect(() => {
180
+ if (previousEntityId.current === selectedEntityId) return;
181
+ previousEntityId.current = selectedEntityId;
155
182
  dirty.current = false;
156
183
  }, [selectedEntityId]);
157
184
 
@@ -170,12 +197,19 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
170
197
  return true;
171
198
  }, []));
172
199
 
173
- // Panel size state with persistence
200
+ // Panel size state with persistence.
201
+ //
202
+ // Written on drag, not on mount. An effect that saved on mount stored
203
+ // {@link DEFAULT_PANEL_SIZE} under this collection's key the first time
204
+ // anyone opened the split — after which the default was frozen in every
205
+ // browser that had ever seen it, and changing it here reached nobody but a
206
+ // new visitor. Only a size someone chose is a size worth remembering.
174
207
  const [panelSize, setPanelSize] = useState(() => getSavedPanelSize(path));
175
208
 
176
- useEffect(() => {
177
- savePanelSize(path, panelSize);
178
- }, [panelSize, path]);
209
+ const handlePanelSizeChange = useCallback((size: number) => {
210
+ setPanelSize(size);
211
+ savePanelSize(path, size);
212
+ }, [path]);
179
213
 
180
214
  // ── Animation state ──
181
215
  // We track the "rendered" entity to keep the detail panel mounted during the exit animation.
@@ -366,6 +400,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
366
400
  parentEntityIds={usedParentEntityIds}
367
401
  selectedTab={selectedTab}
368
402
  layout="split"
403
+ onCloseRequest={handleCloseDetail}
369
404
  onEditClick={() => {
370
405
  navigate(buildUrl(`${path}/${renderedEntityId}/edit`));
371
406
  }}
@@ -387,6 +422,11 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
387
422
  parentEntityIds={usedParentEntityIds}
388
423
  selectedTab={selectedTab}
389
424
  layout="split"
425
+ // The ✕, and where "save and close" lands. A plain
426
+ // navigation: `useNavigationBlocker` above already
427
+ // stands between it and an unsaved edit, so the ✕ asks
428
+ // exactly what Escape and the browser's Back ask.
429
+ onCloseRequest={handleCloseDetail}
390
430
  onValuesModified={setDirty}
391
431
  onSaved={() => {
392
432
  setDirty(false);
@@ -431,7 +471,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
431
471
  secondPanel={detailPanel}
432
472
  showSecondPanel={isDetailVisible}
433
473
  panelSizePercent={animationPhase === "entering" ? 100 : panelSize}
434
- onPanelSizeChange={setPanelSize}
474
+ onPanelSizeChange={handlePanelSizeChange}
435
475
  minPanelSizePx={240}
436
476
  />
437
477
 
@@ -408,7 +408,8 @@ values: { ...e.values,
408
408
  where: whereFilter,
409
409
  limit: itemCount,
410
410
  orderBy: orderByParam,
411
- include: includeParams
411
+ include: includeParams,
412
+ searchString: currentSearchString
412
413
  });
413
414
 
414
415
  const onError = (error: Error) => {
@@ -446,7 +447,12 @@ values: { ...e.values,
446
447
  where: whereFilter,
447
448
  limit: itemCount,
448
449
  orderBy: orderByParam,
449
- include: includeParams
450
+ include: includeParams,
451
+ // Read into a local at the top of this function and then used
452
+ // nowhere, in both places it was read. The board took a search
453
+ // term, re-subscribed every column when it changed, and
454
+ // returned the same rows.
455
+ searchString: currentSearchString
450
456
  }, res => {
451
457
  liveDataReceived = true;
452
458
  onUpdate(res.data as Entity<M>[]);
@@ -539,7 +545,10 @@ values: { ...e.values,
539
545
  };
540
546
 
541
547
  accessor.count({
542
- where: whereFilter
548
+ where: whereFilter,
549
+ // Or the column header counts the unsearched collection
550
+ // while the column beneath it shows searched rows.
551
+ searchString: currentSearchString
543
552
  }).then(count => {
544
553
  if (isCleaningUpRef.current) return;
545
554
  setColumnData(prev => ({