@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0

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 (148) hide show
  1. package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-CwTpdzWF.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/ClearFilterSortButton.d.ts +7 -1
  10. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  11. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  12. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  13. package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
  14. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
  18. package/dist/components/DetailViewBinding.d.ts +7 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +4 -2
  20. package/dist/components/DrawerNavigationItem.d.ts +13 -5
  21. package/dist/components/EditViewBinding.d.ts +22 -7
  22. package/dist/components/EntityIdentityBar.d.ts +24 -1
  23. package/dist/components/RelationSelector.d.ts +13 -0
  24. package/dist/data_export/export/export.d.ts +7 -0
  25. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  26. package/dist/data_export/export/index.d.ts +1 -0
  27. package/dist/data_import/utils/csv.d.ts +30 -0
  28. package/dist/data_import/utils/index.d.ts +2 -0
  29. package/dist/data_import/utils/save_entities.d.ts +38 -0
  30. package/dist/data_import/utils/transforms.d.ts +9 -0
  31. package/dist/editor.js +3 -0
  32. package/dist/editor.js.map +1 -1
  33. package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
  34. package/dist/export-L53WektO.js.map +1 -0
  35. package/dist/form/form_utils.d.ts +38 -1
  36. package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
  37. package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
  38. package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
  39. package/dist/import-C_4edkoD.js.map +1 -0
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +96 -80
  42. package/dist/index.js.map +1 -1
  43. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  44. package/dist/util/view_constants.d.ts +24 -0
  45. package/dist/util/view_mode.d.ts +18 -0
  46. package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
  47. package/dist/util-C-D5yD2n.js.map +1 -0
  48. package/package.json +10 -9
  49. package/src/collection_editor/serializable_types.ts +1 -0
  50. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  51. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  52. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  53. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  54. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  55. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  56. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  57. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  58. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  59. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  60. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  61. package/src/components/ClearFilterSortButton.tsx +16 -7
  62. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
  63. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  64. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  65. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  66. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  67. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
  68. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  69. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  70. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
  71. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  72. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  73. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  74. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
  75. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  76. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  77. package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
  78. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
  82. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  83. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  84. package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
  85. package/src/components/CollectionViewBinding/utils.ts +7 -4
  86. package/src/components/DefaultDrawer.tsx +7 -0
  87. package/src/components/DetailViewBinding.tsx +33 -9
  88. package/src/components/DrawerNavigationGroup.tsx +12 -32
  89. package/src/components/DrawerNavigationItem.tsx +33 -12
  90. package/src/components/EditViewBinding.tsx +175 -28
  91. package/src/components/EntityIdentityBar.tsx +109 -17
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityViewBinding.tsx +4 -2
  94. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  95. package/src/components/RelationSelector.tsx +61 -11
  96. package/src/components/SearchIconsView.tsx +14 -33
  97. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  98. package/src/components/SidePanelBinding.tsx +36 -25
  99. package/src/components/UserSelector.tsx +52 -9
  100. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  101. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  102. package/src/data_export/export/export.ts +63 -16
  103. package/src/data_export/export/fetch_export_data.ts +90 -0
  104. package/src/data_export/export/index.ts +1 -0
  105. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  106. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  107. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  108. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  109. package/src/data_import/utils/csv.ts +153 -0
  110. package/src/data_import/utils/data.ts +16 -2
  111. package/src/data_import/utils/file_to_json.ts +77 -6
  112. package/src/data_import/utils/index.ts +2 -0
  113. package/src/data_import/utils/save_entities.ts +138 -0
  114. package/src/data_import/utils/transforms.ts +23 -0
  115. package/src/editor/components/image-bubble.tsx +2 -0
  116. package/src/editor/selectors/link-selector.tsx +1 -0
  117. package/src/form/EntityForm.tsx +12 -3
  118. package/src/form/EntityFormBinding.tsx +8 -12
  119. package/src/form/PropertyFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  121. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  122. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  123. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  124. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  125. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  126. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
  127. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  128. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  129. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  130. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  131. package/src/form/form_utils.ts +78 -0
  132. package/src/index.ts +9 -1
  133. package/src/preview/PropertyPreview.tsx +7 -3
  134. package/src/preview/components/DatePreview.tsx +15 -2
  135. package/src/preview/components/ReferencePreview.tsx +5 -8
  136. package/src/preview/components/RelationPreview.tsx +8 -7
  137. package/src/preview/components/date_fns_locales.ts +125 -0
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/util/navigation_utils.ts +4 -2
  140. package/src/util/property_utils.tsx +6 -1
  141. package/src/util/view_constants.ts +27 -0
  142. package/src/util/view_mode.ts +36 -0
  143. package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
  144. package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
  145. package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
  146. package/dist/export-C-XG3aRh.js.map +0 -1
  147. package/dist/import-B0GyllB0.js.map +0 -1
  148. package/dist/util-DHNXodBo.js.map +0 -1
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
  import type { PreviewSize } from "../../types/components/PropertyPreviewProps";
3
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";
4
7
  import type { PreviewSlot } from "./usePreviewSlots";
5
8
 
6
9
  export interface SlotValueProps {
@@ -42,6 +45,15 @@ export function SlotValue({ slot, size, textOnly = true, fill }: SlotValueProps)
42
45
  if (!slot || slot.value === undefined || slot.value === null) return null;
43
46
 
44
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
+
45
57
  // Computed. Arrays (a `tags` resolver) join rather than rendering as
46
58
  // "[object Object]" via String().
47
59
  const text = Array.isArray(slot.value)
@@ -58,3 +70,87 @@ export function SlotValue({ slot, size, textOnly = true, fill }: SlotValueProps)
58
70
  textOnly={textOnly}
59
71
  fill={fill}/>;
60
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
+ }
@@ -24,6 +24,12 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
24
24
  * the options offered.
25
25
  */
26
26
  properties: Properties;
27
+ /**
28
+ * Toolbar showing icon-only controls (split view, or a layout too narrow
29
+ * for the filters button's label). Only decides the icon size, which
30
+ * follows that button — see `CollectionViewStartActions`.
31
+ */
32
+ compact?: boolean;
27
33
  };
28
34
 
29
35
  /**
@@ -40,7 +46,8 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
40
46
  */
41
47
  export function SortButton<M extends Record<string, unknown>>({
42
48
  tableController,
43
- properties
49
+ properties,
50
+ compact
44
51
  }: SortButtonProps<M>) {
45
52
 
46
53
  const { t } = useTranslation();
@@ -97,6 +104,8 @@ export function SortButton<M extends Record<string, unknown>>({
97
104
  // `asChild` — is what toggles it. A handler of our own would toggle it a
98
105
  // second time in the same click and the popover would never open; a
99
106
  // `Tooltip` in between would swallow the trigger's ref.
107
+ const arrowSize = compact ? iconSize.smallest : iconSize.small;
108
+
100
109
  const trigger = (
101
110
  <IconButton
102
111
  size={"small"}
@@ -104,11 +113,14 @@ export function SortButton<M extends Record<string, unknown>>({
104
113
  title={label}
105
114
  className={cls(activeOption && "text-primary")}
106
115
  >
116
+ {/* Same icon size as the filter controls it sits between: 16 in the
117
+ split view's narrow toolbar, 20 everywhere else. It was a flat
118
+ 16, which read as a smaller button next to them. */}
107
119
  {activeDirection === "desc"
108
- ? <ArrowDownIcon size={iconSize.smallest}/>
120
+ ? <ArrowDownIcon size={arrowSize}/>
109
121
  : activeDirection === "asc"
110
- ? <ArrowUpIcon size={iconSize.smallest}/>
111
- : <ArrowUpDownIcon size={iconSize.smallest}/>}
122
+ ? <ArrowUpIcon size={arrowSize}/>
123
+ : <ArrowUpDownIcon size={arrowSize}/>}
112
124
  </IconButton>
113
125
  );
114
126
 
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- import { ChevronsLeftIcon, IconButton, iconSize, Tooltip } 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,13 +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 a
13
- * double chevron pointing at the list it folds away: closing the list and
14
- * opening the record full screen are the same thing, so there is one control
15
- * and one route (`#full`) rather than two. Full screen carries the mirrored
16
- * chevron to bring the list back.
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.
17
21
  */
18
- 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
+ } = {}) {
19
30
 
20
31
  const { t } = useTranslation();
21
32
  const splitView = useSplitView();
@@ -26,9 +37,12 @@ export function SplitListCloseButton() {
26
37
  <Tooltip title={t("hide_list")}>
27
38
  <IconButton
28
39
  size="small"
29
- onClick={splitView.openFullScreen}
40
+ onClick={() => {
41
+ onBeforeHide?.();
42
+ splitView.openFullScreen();
43
+ }}
30
44
  aria-label={t("hide_list")}>
31
- <ChevronsLeftIcon size={iconSize.smallest}/>
45
+ <PanelLeftCloseIcon size={iconSize.smallest}/>
32
46
  </IconButton>
33
47
  </Tooltip>
34
48
  );
@@ -1,13 +1,13 @@
1
1
  import React from "react";
2
2
 
3
- import { ChevronsRightIcon, IconButton, iconSize, Tooltip } from "@rebasepro/ui";
3
+ import { IconButton, iconSize, PanelLeftOpenIcon, Tooltip } from "@rebasepro/ui";
4
4
  import { useTranslation } from "@rebasepro/app";
5
5
 
6
6
  /**
7
7
  * Brings the list back beside a record that was opened full screen.
8
8
  *
9
9
  * The mirror of {@link SplitListCloseButton}: the same edge of the same bar,
10
- * the same double chevron pointing the other way, and the same single route —
10
+ * the same pane glyph opening rather than closing, and the same single route —
11
11
  * showing the list is dropping the `#full` hash. It replaces the back arrow
12
12
  * where it applies, because the list it unfolds already holds the record.
13
13
  */
@@ -21,7 +21,7 @@ export function SplitListShowButton({ onClick }: { onClick: () => void }) {
21
21
  size="small"
22
22
  onClick={onClick}
23
23
  aria-label={t("show_list")}>
24
- <ChevronsRightIcon size={iconSize.smallest}/>
24
+ <PanelLeftOpenIcon size={iconSize.smallest}/>
25
25
  </IconButton>
26
26
  </Tooltip>
27
27
  );
@@ -85,12 +85,14 @@ function isListShortcutTarget(target: HTMLElement | null, detailPanel: HTMLEleme
85
85
  * The list only has to identify a record — a title, a subtitle and a status
86
86
  * chip — while the detail beside it carries the whole record and its rail. At
87
87
  * 30% the list was taking width from the side that had four columns of fields
88
- * to lay out.
88
+ * to lay out; 26% still was, by about the width of one field.
89
89
  *
90
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.
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.
92
94
  */
93
- const DEFAULT_PANEL_SIZE = 26;
95
+ const DEFAULT_PANEL_SIZE = 22;
94
96
 
95
97
  function getSavedPanelSize(path: string): number {
96
98
  try {
@@ -163,8 +165,20 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
163
165
  dirty.current = modified;
164
166
  }, []);
165
167
 
166
- // 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);
167
179
  useEffect(() => {
180
+ if (previousEntityId.current === selectedEntityId) return;
181
+ previousEntityId.current = selectedEntityId;
168
182
  dirty.current = false;
169
183
  }, [selectedEntityId]);
170
184
 
@@ -183,12 +197,19 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
183
197
  return true;
184
198
  }, []));
185
199
 
186
- // 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.
187
207
  const [panelSize, setPanelSize] = useState(() => getSavedPanelSize(path));
188
208
 
189
- useEffect(() => {
190
- savePanelSize(path, panelSize);
191
- }, [panelSize, path]);
209
+ const handlePanelSizeChange = useCallback((size: number) => {
210
+ setPanelSize(size);
211
+ savePanelSize(path, size);
212
+ }, [path]);
192
213
 
193
214
  // ── Animation state ──
194
215
  // We track the "rendered" entity to keep the detail panel mounted during the exit animation.
@@ -379,6 +400,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
379
400
  parentEntityIds={usedParentEntityIds}
380
401
  selectedTab={selectedTab}
381
402
  layout="split"
403
+ onCloseRequest={handleCloseDetail}
382
404
  onEditClick={() => {
383
405
  navigate(buildUrl(`${path}/${renderedEntityId}/edit`));
384
406
  }}
@@ -400,6 +422,11 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
400
422
  parentEntityIds={usedParentEntityIds}
401
423
  selectedTab={selectedTab}
402
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}
403
430
  onValuesModified={setDirty}
404
431
  onSaved={() => {
405
432
  setDirty(false);
@@ -444,7 +471,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
444
471
  secondPanel={detailPanel}
445
472
  showSecondPanel={isDetailVisible}
446
473
  panelSizePercent={animationPhase === "entering" ? 100 : panelSize}
447
- onPanelSizeChange={setPanelSize}
474
+ onPanelSizeChange={handlePanelSizeChange}
448
475
  minPanelSizePx={240}
449
476
  />
450
477
 
@@ -79,12 +79,17 @@ export function ViewModeToggle({
79
79
  const { t } = useTranslation();
80
80
 
81
81
 
82
- // Get icon for current view mode
82
+ // Get icon for current view mode.
83
+ //
84
+ // `small` on the trigger, matching the filters button beside it — the two
85
+ // labelled buttons open the toolbar together, and at 16 against its 20 this
86
+ // one read as the smaller of the pair. The options inside the popover stay
87
+ // at `smallest`, which is the size every other menu icon uses.
83
88
  const getViewModeIcon = () => {
84
- if (viewMode === "kanban") return <KanbanIcon size={iconSize.smallest}/>;
85
- if (viewMode === "cards") return <LayoutGridIcon size={iconSize.smallest}/>;
86
- if (viewMode === "table") return <TableIcon size={iconSize.smallest}/>;
87
- return <ListIcon size={iconSize.smallest}/>;
89
+ if (viewMode === "kanban") return <KanbanIcon size={iconSize.small}/>;
90
+ if (viewMode === "cards") return <LayoutGridIcon size={iconSize.small}/>;
91
+ if (viewMode === "table") return <TableIcon size={iconSize.small}/>;
92
+ return <ListIcon size={iconSize.small}/>;
88
93
  };
89
94
 
90
95
  const getViewModeName = () => {
@@ -1,5 +1,5 @@
1
1
 
2
- import { Entity, FilterValues } from "@rebasepro/types";
2
+ import { Entity, FilterValues, MAX_LIST_LIMIT } from "@rebasepro/types";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { getRelationIncludeParams, useData, useRebaseContext } from "@rebasepro/app";
5
5
  import type { AdminCollection } from "@rebasepro/admin-types";
@@ -220,6 +220,14 @@ export function useBoardDataController<M extends Record<string, unknown> = any,
220
220
  // Skip if we're in the middle of cleanup
221
221
  if (isCleaningUpRef.current) return;
222
222
 
223
+ // `itemCount` grows by one page per "load more" click with nothing
224
+ // stopping it, and a read above `MAX_LIST_LIMIT` is refused rather than
225
+ // trimmed — a column would stop loading with an error instead of
226
+ // stopping at the ceiling. Ask for what the platform serves; `hasMore`
227
+ // below still compares against the count the board asked for, so the
228
+ // column reports itself exhausted exactly where it did before.
229
+ const requestedCount = Math.min(itemCount, MAX_LIST_LIMIT);
230
+
223
231
  const currentDataClient = dataClientRef.current;
224
232
  const currentCollection = collectionRef.current;
225
233
  const currentContext = contextRef.current;
@@ -406,7 +414,7 @@ values: { ...e.values,
406
414
 
407
415
  const fetchOnce = () => accessor.find({
408
416
  where: whereFilter,
409
- limit: itemCount,
417
+ limit: requestedCount,
410
418
  orderBy: orderByParam,
411
419
  include: includeParams,
412
420
  searchString: currentSearchString
@@ -445,7 +453,7 @@ values: { ...e.values,
445
453
  let liveDataReceived = false;
446
454
  const unsubscribe = accessor.listen({
447
455
  where: whereFilter,
448
- limit: itemCount,
456
+ limit: requestedCount,
449
457
  orderBy: orderByParam,
450
458
  include: includeParams,
451
459
  // Read into a local at the top of this function and then used
@@ -1,13 +1,13 @@
1
1
  import { useMemo } from "react";
2
2
  import type { Property, RelationProperty } from "@rebasepro/types";
3
3
  import type { Entity, EntityRelation } from "@rebasepro/types";
4
- import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
4
+ import type { PropertyConfig, AdminCollection, EntityDisplayRole } from "@rebasepro/admin-types";
5
5
  import { getEntityImagePreviewPropertyKey } from "@rebasepro/app";
6
6
  import { getDisplayPropertyKey } from "@rebasepro/app";
7
7
  import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
8
8
  import { getEntityFromCache } from "@rebasepro/app";
9
9
  import { getEntityPreviewKeys } from "../../util/previews";
10
- import { getValueInPath } from "@rebasepro/utils";
10
+ import { formatRelativeTime, getValueInPath } from "@rebasepro/utils";
11
11
  import type { AuthController } from "@rebasepro/admin-types";
12
12
  import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
13
13
  import { useEntityDisplay } from "../../hooks/useEntityDisplay";
@@ -38,6 +38,16 @@ export interface PreviewSlot {
38
38
  property?: Property;
39
39
  propertyKey?: string;
40
40
  value: unknown;
41
+ /**
42
+ * Which display role this slot fills.
43
+ *
44
+ * Load-bearing for the computed case. Without a property there is nothing to
45
+ * infer from, and "render it as text" is right for a title and wrong for an
46
+ * image: a resolver that returned a URL had that URL printed inside the card's
47
+ * image frame. The role is the one thing that distinguishes them, so it
48
+ * travels with the slot rather than being re-derived at each call site.
49
+ */
50
+ role?: EntityDisplayRole;
41
51
  }
42
52
 
43
53
  /**
@@ -97,6 +107,19 @@ export interface EntityPreviewSlots {
97
107
  status: PreviewSlot | undefined;
98
108
  /** META — date / timestamp */
99
109
  date: DatePreviewSlot | undefined;
110
+ /**
111
+ * TAGS — free chips beside the status.
112
+ *
113
+ * Declared only. Every other role has an inference ladder behind it, and
114
+ * this one deliberately does not: there is no "first enum" or "looks like a
115
+ * timestamp" that means *labels*, so guessing would scatter arbitrary chips
116
+ * across every row of every panel that never asked for them.
117
+ *
118
+ * The role existed on `EntityDisplay` and in `useEntityDisplay` before this
119
+ * field did, which is why a declared `display.tags` resolved correctly and
120
+ * then had nowhere to go.
121
+ */
122
+ tags: PreviewSlot | undefined;
100
123
  /** Entity ID (always available) */
101
124
  entityId: string | number;
102
125
  }
@@ -116,6 +139,8 @@ export interface CollectionSlotKeys {
116
139
  relationKeys: string[];
117
140
  statusKey: string | undefined;
118
141
  dateKey: string | undefined;
142
+ /** Declared only — see {@link EntityPreviewSlots.tags}. */
143
+ tagsKey: string | undefined;
119
144
  }
120
145
 
121
146
  /**
@@ -250,7 +275,8 @@ imageKey,
250
275
  subtitleKey,
251
276
  relationKeys,
252
277
  statusKey,
253
- dateKey };
278
+ dateKey,
279
+ tagsKey: getDisplayPropertyKey(collection, "tags") };
254
280
  }
255
281
 
256
282
  // ── Image unwrapping helper ───────────────────────────────────────────
@@ -281,6 +307,7 @@ function resolveImageSlot(
281
307
  if (resolvedValue === undefined || resolvedValue === null) return undefined;
282
308
 
283
309
  return {
310
+ role: "image",
284
311
  property: resolvedProperty as Property,
285
312
  propertyKey: imageKey,
286
313
  value: resolvedValue
@@ -319,15 +346,11 @@ function formatDateValue(value: unknown): string {
319
346
  if (isNaN(date.getTime())) return String(value);
320
347
 
321
348
  const now = new Date();
322
- const diffMs = now.getTime() - date.getTime();
323
- const diffMins = Math.floor(diffMs / 60000);
324
- const diffHours = Math.floor(diffMs / 3600000);
325
- const diffDays = Math.floor(diffMs / 86400000);
326
-
327
- if (diffMins < 1) return "Just now";
328
- if (diffMins < 60) return `${diffMins}m ago`;
329
- if (diffHours < 24) return `${diffHours}h ago`;
330
- if (diffDays < 7) return `${diffDays}d ago`;
349
+ // A date slot holds whatever property the collection points it at, which is
350
+ // as often a due or publish date as a `updated_at`. `formatRelativeTime`
351
+ // reads the sign, so a scheduled date says "in 3d" instead of "Just now".
352
+ const relative = formatRelativeTime(date, { now });
353
+ if (relative) return relative;
331
354
 
332
355
  return date.toLocaleDateString(undefined, {
333
356
  month: "short",
@@ -368,7 +391,7 @@ export function resolveEntitySlots(
368
391
  collection: AdminCollection<Record<string, unknown>>,
369
392
  slotKeys: CollectionSlotKeys
370
393
  ): EntityPreviewSlots {
371
- const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey } = slotKeys;
394
+ const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey, tagsKey } = slotKeys;
372
395
 
373
396
  // Image
374
397
  const image = imageKey ? resolveImageSlot(collection, imageKey, entity) : undefined;
@@ -381,7 +404,8 @@ export function resolveEntitySlots(
381
404
  if (resolvedTitleKey) {
382
405
  const prop = collection.properties[resolvedTitleKey] as Property | undefined;
383
406
  const val = getValueInPath(entity.values, resolvedTitleKey);
384
- if (prop) title = { property: prop,
407
+ if (prop) title = { role: "title",
408
+ property: prop,
385
409
  propertyKey: resolvedTitleKey,
386
410
  value: val };
387
411
  }
@@ -392,7 +416,8 @@ value: val };
392
416
  const prop = collection.properties[subtitleKey] as Property | undefined;
393
417
  const val = getValueInPath(entity.values, subtitleKey);
394
418
  if (prop && val !== undefined && val !== null && val !== "") {
395
- subtitle = { property: prop,
419
+ subtitle = { role: "subtitle",
420
+ property: prop,
396
421
  propertyKey: subtitleKey,
397
422
  value: val };
398
423
  }
@@ -468,7 +493,8 @@ id: relation.id });
468
493
  const prop = collection.properties[statusKey] as Property | undefined;
469
494
  const val = getValueInPath(entity.values, statusKey);
470
495
  if (prop && val !== undefined && val !== null) {
471
- status = { property: prop,
496
+ status = { role: "status",
497
+ property: prop,
472
498
  propertyKey: statusKey,
473
499
  value: val };
474
500
  }
@@ -481,6 +507,7 @@ value: val };
481
507
  const val = getValueInPath(entity.values, dateKey);
482
508
  if (prop && val !== undefined && val !== null) {
483
509
  date = {
510
+ role: "date",
484
511
  property: prop,
485
512
  propertyKey: dateKey,
486
513
  value: val,
@@ -489,6 +516,23 @@ value: val };
489
516
  }
490
517
  }
491
518
 
519
+ // Tags — declared only, and empty means absent: an empty array is "this
520
+ // record has no labels", which must render as nothing rather than as an
521
+ // empty chip row that shifts every other slot down.
522
+ let tags: PreviewSlot | undefined;
523
+ if (tagsKey) {
524
+ const prop = collection.properties[tagsKey] as Property | undefined;
525
+ const val = getValueInPath(entity.values, tagsKey);
526
+ const empty = val === undefined || val === null || val === ""
527
+ || (Array.isArray(val) && val.length === 0);
528
+ if (prop && !empty) {
529
+ tags = { role: "tags",
530
+ property: prop,
531
+ propertyKey: tagsKey,
532
+ value: val };
533
+ }
534
+ }
535
+
492
536
  return {
493
537
  image,
494
538
  title,
@@ -496,6 +540,7 @@ value: val };
496
540
  relations,
497
541
  status,
498
542
  date,
543
+ tags,
499
544
  entityId: entity.id
500
545
  };
501
546
  }
@@ -622,30 +667,37 @@ entity };
622
667
  const image = useEntityDisplay<string>("image", params);
623
668
  const status = useEntityDisplay<string>("status", params);
624
669
  const date = useEntityDisplay<Date | string | number>("date", params);
670
+ const tags = useEntityDisplay<string[] | string>("tags", params);
625
671
 
626
672
  return useMemo(() => {
627
673
  // `source` is set only when the value came from a property, so its
628
674
  // absence is exactly the "this was computed" signal — no second flag to
629
675
  // keep in step with the first.
676
+ // The role travels with the computed slot too. It is the only thing left
677
+ // to say about a value with no property behind it, and `SlotValue` needs
678
+ // it to tell a computed image from a computed title.
630
679
  const computed = (
680
+ role: EntityDisplayRole,
631
681
  resolved: { value: unknown, source?: unknown }
632
682
  ): PreviewSlot | undefined =>
633
683
  resolved.value !== undefined && resolved.source === undefined
634
- ? { value: resolved.value }
684
+ ? { role,
685
+ value: resolved.value }
635
686
  : undefined;
636
687
 
637
- const computedDate = computed(date);
688
+ const computedDate = computed("date", date);
638
689
 
639
690
  return {
640
691
  ...derived,
641
- title: computed(title) ?? derived.title,
642
- subtitle: computed(subtitle) ?? derived.subtitle,
643
- image: computed(image) ?? derived.image,
644
- status: computed(status) ?? derived.status,
692
+ title: computed("title", title) ?? derived.title,
693
+ subtitle: computed("subtitle", subtitle) ?? derived.subtitle,
694
+ image: computed("image", image) ?? derived.image,
695
+ status: computed("status", status) ?? derived.status,
645
696
  date: computedDate
646
697
  ? { ...computedDate,
647
698
  formatted: formatDateValue(computedDate.value) }
648
- : derived.date
699
+ : derived.date,
700
+ tags: computed("tags", tags) ?? derived.tags
649
701
  };
650
- }, [derived, title, subtitle, image, status, date]);
702
+ }, [derived, title, subtitle, image, status, date, tags]);
651
703
  }
@@ -1,3 +1,5 @@
1
+ import { isArrayValue, readStoredJson, writeStoredJson } from "@rebasepro/utils";
2
+
1
3
  export function addRecentId(collectionId: string, id: string | number) {
2
4
  const recentIds = getRecentIds(collectionId);
3
5
  const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
@@ -9,11 +11,12 @@ export function addRecentId(collectionId: string, id: string | number) {
9
11
  }
10
12
 
11
13
  export function saveSearchedIdsLocally(collectionId: string, ids: (string | number)[]) {
12
- localStorage.setItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
14
+ writeStoredJson("recent_id_searches::" + collectionId, ids);
13
15
  }
14
16
 
15
17
  export function getRecentIds(collectionId: string): (string | number)[] {
16
- const stored = localStorage.getItem("recent_id_searches::" + collectionId);
17
- if (!stored) return [];
18
- return JSON.parse(stored);
18
+ return readStoredJson<(string | number)[]>(
19
+ "recent_id_searches::" + collectionId,
20
+ { fallback: [], accept: isArrayValue }
21
+ );
19
22
  }
@@ -274,13 +274,20 @@ export function DrawerLogo({
274
274
  }) {
275
275
 
276
276
  const showFullContent = drawerOpen || drawerHovered;
277
+ const { t } = useTranslation();
277
278
 
278
279
  return (
279
280
  <div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
280
281
  {/* Logo — always visible */}
282
+ {/* Named explicitly because neither branch below carries text: the
283
+ stock `RebaseLogo` is a bare <svg> with no <title>, and a custom
284
+ `logo` only ever gets the generic alt "Logo", which names the image
285
+ rather than saying where the link goes. The title link beside it is
286
+ no help either — it renders only when a `title` is set. */}
281
287
  <Link
282
288
  className="shrink-0 flex items-center justify-center w-[56px] h-[40px]"
283
289
  to={logoDestination}
290
+ aria-label={t("home") || "Home"}
284
291
  >
285
292
  {logo
286
293
  ? <img src={logo} alt="Logo" className="w-[28px] h-[28px] object-contain"/>