@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.
- package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
- package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
- package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
- package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
- package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
- package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
- package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
- package/dist/components/DetailViewBinding.d.ts +7 -0
- package/dist/components/DrawerNavigationGroup.d.ts +4 -2
- package/dist/components/DrawerNavigationItem.d.ts +13 -5
- package/dist/components/EditViewBinding.d.ts +22 -7
- package/dist/components/EntityIdentityBar.d.ts +24 -1
- package/dist/components/RelationSelector.d.ts +13 -0
- package/dist/data_export/export/export.d.ts +7 -0
- package/dist/data_export/export/fetch_export_data.d.ts +48 -0
- package/dist/data_export/export/index.d.ts +1 -0
- package/dist/data_import/utils/csv.d.ts +30 -0
- package/dist/data_import/utils/index.d.ts +2 -0
- package/dist/data_import/utils/save_entities.d.ts +38 -0
- package/dist/data_import/utils/transforms.d.ts +9 -0
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
- package/dist/export-L53WektO.js.map +1 -0
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
- package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
- package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
- package/dist/import-C_4edkoD.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +96 -80
- package/dist/index.js.map +1 -1
- package/dist/preview/components/date_fns_locales.d.ts +9 -0
- package/dist/util/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
- package/dist/util-C-D5yD2n.js.map +1 -0
- package/package.json +10 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
- package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
- package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
- package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
- package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
- package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +7 -0
- package/src/components/DetailViewBinding.tsx +33 -9
- package/src/components/DrawerNavigationGroup.tsx +12 -32
- package/src/components/DrawerNavigationItem.tsx +33 -12
- package/src/components/EditViewBinding.tsx +175 -28
- package/src/components/EntityIdentityBar.tsx +109 -17
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityViewBinding.tsx +4 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +61 -11
- package/src/components/SearchIconsView.tsx +14 -33
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
- package/src/components/SidePanelBinding.tsx +36 -25
- package/src/components/UserSelector.tsx +52 -9
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/data_export/export/ExportCollectionAction.tsx +101 -48
- package/src/data_export/export/export.ts +63 -16
- package/src/data_export/export/fetch_export_data.ts +90 -0
- package/src/data_export/export/index.ts +1 -0
- package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
- package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
- package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
- package/src/data_import/import/ImportCollectionAction.tsx +13 -2
- package/src/data_import/utils/csv.ts +153 -0
- package/src/data_import/utils/data.ts +16 -2
- package/src/data_import/utils/file_to_json.ts +77 -6
- package/src/data_import/utils/index.ts +2 -0
- package/src/data_import/utils/save_entities.ts +138 -0
- package/src/data_import/utils/transforms.ts +23 -0
- package/src/editor/components/image-bubble.tsx +2 -0
- package/src/editor/selectors/link-selector.tsx +1 -0
- package/src/form/EntityForm.tsx +12 -3
- package/src/form/EntityFormBinding.tsx +8 -12
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
- package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
- package/src/form/form_utils.ts +78 -0
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +7 -3
- package/src/preview/components/DatePreview.tsx +15 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/date_fns_locales.ts +125 -0
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/util/navigation_utils.ts +4 -2
- package/src/util/property_utils.tsx +6 -1
- package/src/util/view_constants.ts +27 -0
- package/src/util/view_mode.ts +36 -0
- package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
- package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
- package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
- package/dist/export-C-XG3aRh.js.map +0 -1
- package/dist/import-B0GyllB0.js.map +0 -1
- package/dist/util-DHNXodBo.js.map +0 -1
|
@@ -6,6 +6,7 @@ import { useTranslation } from "@rebasepro/app";
|
|
|
6
6
|
import {
|
|
7
7
|
Button,
|
|
8
8
|
CheckIcon,
|
|
9
|
+
ChevronDownIcon,
|
|
9
10
|
cls,
|
|
10
11
|
CodeIcon,
|
|
11
12
|
CopyIcon,
|
|
@@ -21,7 +22,8 @@ import {
|
|
|
21
22
|
MoreVerticalIcon,
|
|
22
23
|
Separator,
|
|
23
24
|
Tooltip,
|
|
24
|
-
Typography
|
|
25
|
+
Typography,
|
|
26
|
+
XIcon
|
|
25
27
|
} from "@rebasepro/ui";
|
|
26
28
|
|
|
27
29
|
export interface EntityIdentityBarProps {
|
|
@@ -55,6 +57,31 @@ export interface EntityIdentityBarProps {
|
|
|
55
57
|
*/
|
|
56
58
|
onSaveAndClose?: () => void;
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Where {@link onSaveAndClose} is offered.
|
|
62
|
+
*
|
|
63
|
+
* `"button"` — its own filled button, and Save demotes to text. For the
|
|
64
|
+
* overlays, where you opened the record to do one thing to it and closing is
|
|
65
|
+
* the dominant intent.
|
|
66
|
+
*
|
|
67
|
+
* `"menu"` — a `▾` welded to the Save button. For the split, where the list
|
|
68
|
+
* is right there and `j`/`k` walk from record to record: there, saving and
|
|
69
|
+
* *staying* is the common case, so it keeps the filled button and dismissing
|
|
70
|
+
* is one step further in. The gesture still exists, so muscle memory carried
|
|
71
|
+
* over from the side panel finds it.
|
|
72
|
+
*/
|
|
73
|
+
saveAndClosePlacement?: "button" | "menu";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Dismiss this record, from the ✕ at the bar's trailing edge.
|
|
77
|
+
*
|
|
78
|
+
* Fenced off from Save by a separator: it is chrome, not the last item in
|
|
79
|
+
* the action row. The panel that supplies it decides what dismissing means —
|
|
80
|
+
* in the split it is a navigation, which the layout's unsaved-changes
|
|
81
|
+
* blocker already guards, so the ✕ cannot drop an edit without asking.
|
|
82
|
+
*/
|
|
83
|
+
onClose?: () => void;
|
|
84
|
+
|
|
58
85
|
onBack?: () => void;
|
|
59
86
|
onInspect?: () => void;
|
|
60
87
|
/** Opens the inspector straight on its history tab. */
|
|
@@ -114,6 +141,8 @@ export function EntityIdentityBar({
|
|
|
114
141
|
saveDisabled,
|
|
115
142
|
hasErrors,
|
|
116
143
|
onSaveAndClose,
|
|
144
|
+
saveAndClosePlacement = "button",
|
|
145
|
+
onClose,
|
|
117
146
|
onBack,
|
|
118
147
|
onInspect,
|
|
119
148
|
onViewHistory,
|
|
@@ -135,6 +164,15 @@ export function EntityIdentityBar({
|
|
|
135
164
|
const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory)
|
|
136
165
|
|| Boolean(externalLink);
|
|
137
166
|
|
|
167
|
+
// The two shapes "save and close" takes, so the Save button next to it knows
|
|
168
|
+
// whether it is still the primary action.
|
|
169
|
+
const saveAndCloseButton = Boolean(onSaveAndClose) && saveAndClosePlacement === "button";
|
|
170
|
+
const saveAndCloseMenu = Boolean(onSaveAndClose) && saveAndClosePlacement === "menu";
|
|
171
|
+
|
|
172
|
+
const saveTooltip = hasErrors
|
|
173
|
+
? (t("fix_errors_before_saving") ?? "Fix highlighted errors before saving")
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
138
176
|
return (
|
|
139
177
|
<div className={cls(
|
|
140
178
|
"h-[52px] shrink-0 flex items-center gap-2 pl-1.5 pr-2 border-b",
|
|
@@ -190,24 +228,66 @@ export function EntityIdentityBar({
|
|
|
190
228
|
)}
|
|
191
229
|
|
|
192
230
|
{onSave && (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
// `rounded-lg overflow-hidden`: the two halves of the split
|
|
232
|
+
// button are square inside and the group carries the radius, so
|
|
233
|
+
// there is one control with a seam rather than two buttons that
|
|
234
|
+
// happen to touch. Their borders match their own fill, so the
|
|
235
|
+
// seam has to be drawn — see the `▾` half for where and in what.
|
|
236
|
+
<div className={"flex items-stretch rounded-lg overflow-hidden"}>
|
|
237
|
+
<Tooltip title={saveTooltip}>
|
|
238
|
+
<LoadingButton
|
|
239
|
+
// Where a separate close button carries it, closing
|
|
240
|
+
// is the dominant intent and takes the filled
|
|
241
|
+
// treatment. Under the `▾` the record is staying
|
|
242
|
+
// open, so Save keeps it.
|
|
243
|
+
variant={saveAndCloseButton ? "text" : "filled"}
|
|
244
|
+
color={"primary"}
|
|
245
|
+
size={"small"}
|
|
246
|
+
loading={saving && !saveAndCloseButton}
|
|
247
|
+
disabled={saveDisabled}
|
|
248
|
+
onClick={onSave}
|
|
249
|
+
className={saveAndCloseMenu ? "rounded-none" : undefined}>
|
|
250
|
+
{saveLabel}
|
|
251
|
+
</LoadingButton>
|
|
252
|
+
</Tooltip>
|
|
253
|
+
|
|
254
|
+
{saveAndCloseMenu && <>
|
|
255
|
+
<Menu align={"end"}
|
|
256
|
+
trigger={
|
|
257
|
+
<Button variant={"filled"}
|
|
258
|
+
color={"primary"}
|
|
259
|
+
size={"small"}
|
|
260
|
+
disabled={saveDisabled}
|
|
261
|
+
aria-label={closeLabel}
|
|
262
|
+
// The seam is this half's own left border,
|
|
263
|
+
// tinted from `currentColor` — the ink the
|
|
264
|
+
// label is drawn in — so it holds on any
|
|
265
|
+
// button colour and dims with the control
|
|
266
|
+
// rather than needing a case per state. It
|
|
267
|
+
// was a `bg-white/25` span between the
|
|
268
|
+
// halves: a bright hairline scratched over a
|
|
269
|
+
// saturated blue, and, being a sibling of
|
|
270
|
+
// the buttons rather than part of one, it
|
|
271
|
+
// stayed at full strength while `disabled`
|
|
272
|
+
// dropped both halves to `opacity-40` — a
|
|
273
|
+
// rule brighter than the button it bisected.
|
|
274
|
+
// Inline because it has to beat the
|
|
275
|
+
// variant's `border-<color>` on one edge.
|
|
276
|
+
style={{ borderLeftColor: "color-mix(in oklab, currentColor 15%, transparent)" }}
|
|
277
|
+
className={"rounded-none px-1.5"}>
|
|
278
|
+
<ChevronDownIcon size={iconSize.smallest}/>
|
|
279
|
+
</Button>
|
|
280
|
+
}>
|
|
281
|
+
<MenuItem onClick={onSaveAndClose}>
|
|
282
|
+
<CheckIcon size={iconSize.smallest}/>
|
|
283
|
+
{closeLabel}
|
|
284
|
+
</MenuItem>
|
|
285
|
+
</Menu>
|
|
286
|
+
</>}
|
|
287
|
+
</div>
|
|
208
288
|
)}
|
|
209
289
|
|
|
210
|
-
{
|
|
290
|
+
{saveAndCloseButton && (
|
|
211
291
|
<LoadingButton variant={"filled"}
|
|
212
292
|
color={"primary"}
|
|
213
293
|
size={"small"}
|
|
@@ -251,6 +331,18 @@ export function EntityIdentityBar({
|
|
|
251
331
|
)}
|
|
252
332
|
|
|
253
333
|
{trailing}
|
|
334
|
+
|
|
335
|
+
{/* Last, and behind a rule. A ✕ that sits flush against Save reads
|
|
336
|
+
as the next item in the action row, and the two adjacent controls
|
|
337
|
+
are then "commit this edit" and "abandon it". */}
|
|
338
|
+
{onClose && <>
|
|
339
|
+
<Separator orientation={"vertical"} className={"h-5 mx-1 shrink-0"}/>
|
|
340
|
+
<Tooltip title={`${t("close")} · Esc`}>
|
|
341
|
+
<IconButton size={"small"} onClick={onClose} aria-label={t("close")}>
|
|
342
|
+
<XIcon size={iconSize.smallest}/>
|
|
343
|
+
</IconButton>
|
|
344
|
+
</Tooltip>
|
|
345
|
+
</>}
|
|
254
346
|
</div>
|
|
255
347
|
);
|
|
256
348
|
}
|
|
@@ -65,16 +65,34 @@ export function EntityInspector({
|
|
|
65
65
|
const { t } = useTranslation();
|
|
66
66
|
const open = tab !== null;
|
|
67
67
|
|
|
68
|
+
// Escape closes the inspector, and only the inspector.
|
|
69
|
+
//
|
|
70
|
+
// The split view holds its own Escape listener for as long as a record is
|
|
71
|
+
// selected, and closing the record panel is a navigation — so an Escape
|
|
72
|
+
// meant for this panel was taking the whole record with it. On `window` in
|
|
73
|
+
// the bubble phase, as this listener used to be, `stopPropagation` cannot
|
|
74
|
+
// prevent that: it governs an event's travel *between* elements and says
|
|
75
|
+
// nothing about the other listeners on the element it is called from. Both
|
|
76
|
+
// ran, and the split's ran first, because it registered first — this one
|
|
77
|
+
// only exists while the inspector is open, which is always later.
|
|
78
|
+
//
|
|
79
|
+
// Capture on `document` is the idiom that holds regardless of mount order:
|
|
80
|
+
// it runs on the way down, before anything bubbles back to `window`, and
|
|
81
|
+
// there `stopPropagation` is enough. It is what the relation and user
|
|
82
|
+
// selectors already use to own the key while their popovers are open.
|
|
83
|
+
// Pinned in escape_key_ownership.test.tsx; see docs/bug-classes.md.
|
|
68
84
|
useEffect(() => {
|
|
69
85
|
if (!open) return;
|
|
70
86
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
71
|
-
if (e.key
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
if (e.key !== "Escape") return;
|
|
88
|
+
// A Radix overlay opened from inside the inspector is above it and
|
|
89
|
+
// owns the key first — the same rule the split view applies.
|
|
90
|
+
if (document.querySelector('[role="dialog"][data-state="open"]')) return;
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
onClose();
|
|
75
93
|
};
|
|
76
|
-
|
|
77
|
-
return () =>
|
|
94
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
95
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
78
96
|
}, [open, onClose]);
|
|
79
97
|
|
|
80
98
|
if (!open) return null;
|
|
@@ -346,14 +346,16 @@ export function EntityViewBinding<M extends Record<string, unknown>>(
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
return (
|
|
349
|
-
<div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
|
|
349
|
+
<div ref={containerRef} className={cls("@container/form flex-1 flex flex-row w-full min-h-0", className)}>
|
|
350
350
|
|
|
351
351
|
{/* `items-start`: without it the cross-axis default stretches the
|
|
352
352
|
column to the scroller's height and its bottom padding lands at
|
|
353
353
|
the bottom of the viewport instead of after the last field. */}
|
|
354
354
|
<div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
|
|
355
355
|
<div className={cls(
|
|
356
|
-
|
|
356
|
+
// Same widths as the editable form (see EntityForm) — toggling
|
|
357
|
+
// between read-only and edit must not move the column.
|
|
358
|
+
"@container/col w-full max-w-3xl @7xl/form:max-w-4xl 2xl:max-w-4xl flex flex-col",
|
|
357
359
|
openEntityMode === "dialog"
|
|
358
360
|
? "pt-5 pb-8 px-6 sm:px-8"
|
|
359
361
|
: "pt-6 pb-12 px-5 sm:px-8"
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
useData,
|
|
15
15
|
useDataTableController,
|
|
16
16
|
useLargeLayout,
|
|
17
|
-
usePermissions
|
|
17
|
+
usePermissions,
|
|
18
|
+
useTranslation
|
|
18
19
|
} from "@rebasepro/app";
|
|
19
20
|
import { ErrorView } from "@rebasepro/app";
|
|
20
21
|
import { Button, DialogActions, PlusIcon, Typography } from "@rebasepro/ui";
|
|
@@ -347,6 +348,7 @@ function EntitySelectionDialogActions({
|
|
|
347
348
|
const { canCreate } = usePermissions();
|
|
348
349
|
|
|
349
350
|
const largeLayout = useLargeLayout();
|
|
351
|
+
const { t } = useTranslation();
|
|
350
352
|
|
|
351
353
|
const onClick: MouseEventHandler<HTMLButtonElement> | undefined = onNewClick
|
|
352
354
|
? (e) => {
|
|
@@ -359,7 +361,7 @@ function EntitySelectionDialogActions({
|
|
|
359
361
|
? <Button
|
|
360
362
|
onClick={onClick}
|
|
361
363
|
startIcon={<PlusIcon/>}>
|
|
362
|
-
|
|
364
|
+
{t("add_specific", { name: collection.singularName ?? collection.name })}
|
|
363
365
|
</Button>
|
|
364
366
|
: <Button
|
|
365
367
|
onClick={onClick}>
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
SearchIcon,
|
|
19
19
|
Separator,
|
|
20
20
|
Tooltip,
|
|
21
|
-
|
|
21
|
+
usePortalContainer,
|
|
22
22
|
XIcon
|
|
23
23
|
} from "@rebasepro/ui";
|
|
24
24
|
import * as React from "react";
|
|
@@ -29,7 +29,6 @@ import { EntityPreviewBindingData } from "./EntityPreviewBinding";
|
|
|
29
29
|
import { useData, useRelationSelector } from "@rebasepro/app";
|
|
30
30
|
import { useSidePanel } from "../hooks/useSidePanel";
|
|
31
31
|
import { normalizeToEntityRelation } from "@rebasepro/common";
|
|
32
|
-
import { EmptyValue } from "../preview";
|
|
33
32
|
|
|
34
33
|
/** Whether an authored relation yields many rows. Derived from its kind. */
|
|
35
34
|
function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
|
|
@@ -91,7 +90,20 @@ export interface RelationSelectorProps {
|
|
|
91
90
|
pageSize?: number;
|
|
92
91
|
emptyPlaceholder?: string;
|
|
93
92
|
searchPlaceholder?: string;
|
|
93
|
+
/** Shown in the open list when a search matched nothing. */
|
|
94
94
|
noResultsText?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Shown on the closed trigger when nothing is selected. Says only that the
|
|
97
|
+
* field is empty — see the note by `resolvedPlaceholder` for why it cannot
|
|
98
|
+
* say whether there is anything to pick.
|
|
99
|
+
*/
|
|
100
|
+
emptyText?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Shown in the open list when the target collection turned out to hold no
|
|
103
|
+
* rows at all, as opposed to a search simply not matching. Defaults to a
|
|
104
|
+
* sentence naming the target collection.
|
|
105
|
+
*/
|
|
106
|
+
emptyCollectionText?: string;
|
|
95
107
|
loadingText?: string;
|
|
96
108
|
}
|
|
97
109
|
|
|
@@ -115,7 +127,9 @@ export const RelationSelector = React.forwardRef<
|
|
|
115
127
|
pageSize,
|
|
116
128
|
emptyPlaceholder,
|
|
117
129
|
searchPlaceholder = "Search...",
|
|
118
|
-
noResultsText = "No
|
|
130
|
+
noResultsText = "No matches.",
|
|
131
|
+
emptyText = "Select…",
|
|
132
|
+
emptyCollectionText,
|
|
119
133
|
loadingText = "Loading..."
|
|
120
134
|
},
|
|
121
135
|
ref
|
|
@@ -124,10 +138,16 @@ export const RelationSelector = React.forwardRef<
|
|
|
124
138
|
const collection = relation.target();
|
|
125
139
|
const dataClient = useData();
|
|
126
140
|
const sidePanelController = useSidePanel();
|
|
141
|
+
const contextPortalContainer = usePortalContainer();
|
|
127
142
|
const multiple = multipleOverride ?? relationCardinality(relation) === "many";
|
|
128
143
|
|
|
129
144
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
130
145
|
const isPopoverOpenRef = useRef(false);
|
|
146
|
+
// The list is only worth fetching once the picker has been opened. A
|
|
147
|
+
// table cell mounts one of these per row, and the selected value is
|
|
148
|
+
// resolved by its own effect below, not from the list. Latches on the
|
|
149
|
+
// first open so a closed popover keeps its results.
|
|
150
|
+
const [listRequested, setListRequested] = useState(false);
|
|
131
151
|
const [selectedItems, setSelectedItems] = useState<RelationItem[]>([]);
|
|
132
152
|
const [isLoadingSelectedItems, setIsLoadingSelectedItems] = useState(false);
|
|
133
153
|
const [searchString, setSearchString] = useState<string>("");
|
|
@@ -150,7 +170,8 @@ export const RelationSelector = React.forwardRef<
|
|
|
150
170
|
path: getCollectionDataPath(collection),
|
|
151
171
|
collection,
|
|
152
172
|
fixedFilter,
|
|
153
|
-
pageSize
|
|
173
|
+
pageSize,
|
|
174
|
+
enabled: listRequested
|
|
154
175
|
});
|
|
155
176
|
|
|
156
177
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
@@ -417,6 +438,7 @@ relation } as RelationItem;
|
|
|
417
438
|
if (next) {
|
|
418
439
|
// Capture current selection so we can pin those items to the top of the list
|
|
419
440
|
pinnedIdsRef.current = new Set(selectedItems.map(i => String(i.id)));
|
|
441
|
+
setListRequested(true);
|
|
420
442
|
setIsPopoverOpen(true);
|
|
421
443
|
isPopoverOpenRef.current = true;
|
|
422
444
|
}
|
|
@@ -455,18 +477,35 @@ relation } as RelationItem;
|
|
|
455
477
|
};
|
|
456
478
|
}, [isPopoverOpen]);
|
|
457
479
|
|
|
458
|
-
useInjectStyles("RelationSelector", " [cmdk-group] { max-height: 40vh; overflow-y: auto; } ");
|
|
459
|
-
|
|
460
480
|
const closePopover = useCallback(() => {
|
|
461
481
|
setIsPopoverOpen(false);
|
|
462
482
|
isPopoverOpenRef.current = false;
|
|
463
483
|
pinnedIdsRef.current = null;
|
|
464
484
|
}, []);
|
|
465
485
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
|
|
486
|
+
// Text, not `EmptyValue`. That component draws a small grey rounded bar
|
|
487
|
+
// with no text in it, which is the same shape the `Skeleton` component
|
|
488
|
+
// uses while data is in flight — so a relation you simply had not filled
|
|
489
|
+
// in was indistinguishable from one that was still loading, and an empty
|
|
490
|
+
// Author or Tags field read as permanently stuck. It also left the trigger
|
|
491
|
+
// button with no accessible name, since the bar contains no text at all.
|
|
492
|
+
//
|
|
493
|
+
// This says "not filled in" rather than "nothing to choose from": which of
|
|
494
|
+
// those it is cannot be known here. The list is deliberately not fetched
|
|
495
|
+
// until the picker is first opened (`enabled: listRequested`, guarded by
|
|
496
|
+
// relation_cell_picker_gating.test.tsx, because a table renders one of
|
|
497
|
+
// these per row), so before that `availableItems` is empty because nobody
|
|
498
|
+
// asked, not because the target collection is. The genuine "nothing to
|
|
499
|
+
// pick yet" state is reported inside the popover below, where the fetch
|
|
500
|
+
// has actually happened.
|
|
501
|
+
const resolvedPlaceholder = placeholder || emptyPlaceholder || emptyText;
|
|
502
|
+
|
|
503
|
+
// Whatever dialog, side dialog or sheet we are inside offers as its
|
|
504
|
+
// portal host, falling back to `document.body`. This is not cosmetic:
|
|
505
|
+
// those are modal, and a modal locks scrolling everywhere outside its
|
|
506
|
+
// own content — a list portaled to the body opens and then refuses to
|
|
507
|
+
// scroll, because every wheel event over it is cancelled.
|
|
508
|
+
const portalContainer = (contextPortalContainer ?? (typeof document !== "undefined" ? document.body : undefined)) ?? undefined;
|
|
470
509
|
|
|
471
510
|
return (
|
|
472
511
|
<>
|
|
@@ -481,6 +520,10 @@ relation } as RelationItem;
|
|
|
481
520
|
disabled={disabled}
|
|
482
521
|
onClick={() => {
|
|
483
522
|
if (disabled) return;
|
|
523
|
+
// Unconditional: the toggle can only close a
|
|
524
|
+
// popover that was opened, which requested the
|
|
525
|
+
// list already.
|
|
526
|
+
setListRequested(true);
|
|
484
527
|
setIsPopoverOpen(o => {
|
|
485
528
|
const next = !o;
|
|
486
529
|
isPopoverOpenRef.current = next;
|
|
@@ -663,10 +706,17 @@ relation } as RelationItem;
|
|
|
663
706
|
className="ml-2 text-sm text-text-secondary dark:text-text-secondary-dark">{loadingText}</span>
|
|
664
707
|
</div>
|
|
665
708
|
)}
|
|
709
|
+
{/* Two different nothings, which used to share one
|
|
710
|
+
message ("No relations found.") even though the
|
|
711
|
+
fix for each is the opposite: clear the search, or
|
|
712
|
+
go and create the first row. With the fetch done,
|
|
713
|
+
the search string is what tells them apart. */}
|
|
666
714
|
{!isLoading && availableItems.length === 0 && (
|
|
667
715
|
<CommandPrimitive.Empty
|
|
668
716
|
className="px-4 py-6 text-center text-text-secondary dark:text-text-secondary-dark">
|
|
669
|
-
{
|
|
717
|
+
{searchString
|
|
718
|
+
? noResultsText
|
|
719
|
+
: (emptyCollectionText ?? `No ${collection.name ?? "entries"} yet.`)}
|
|
670
720
|
</CommandPrimitive.Empty>
|
|
671
721
|
)}
|
|
672
722
|
<CommandPrimitive.Group>
|
|
@@ -4,9 +4,8 @@ import {
|
|
|
4
4
|
coolIconKeys,
|
|
5
5
|
debounce,
|
|
6
6
|
IconButton,
|
|
7
|
-
iconKeys,
|
|
8
7
|
iconSize,
|
|
9
|
-
|
|
8
|
+
LucideIconByName,
|
|
10
9
|
SearchBar,
|
|
11
10
|
Tooltip
|
|
12
11
|
} from "@rebasepro/ui";
|
|
@@ -15,20 +14,6 @@ import { useTranslation } from "@rebasepro/app";
|
|
|
15
14
|
|
|
16
15
|
const UPDATE_SEARCH_INDEX_WAIT_MS = 220;
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
function toPascalCase(str: string): string {
|
|
20
|
-
return str.split(/[-_]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function resolveIcon(iconKey: string): React.ComponentType<{ size: number }> | null {
|
|
24
|
-
const allIcons = lucideIcons as Record<string, React.ComponentType<{ size: number }>>;
|
|
25
|
-
let icon = allIcons[iconKey];
|
|
26
|
-
if (!icon) {
|
|
27
|
-
icon = allIcons[toPascalCase(iconKey)];
|
|
28
|
-
}
|
|
29
|
-
return icon ?? null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
17
|
export interface SearchIconsProps {
|
|
33
18
|
selectedIcon?: string;
|
|
34
19
|
onIconSelected?: (icon: string) => void;
|
|
@@ -74,23 +59,19 @@ export function SearchIconsView({
|
|
|
74
59
|
/>
|
|
75
60
|
|
|
76
61
|
<div className={"flex max-w-full flex-wrap mt-4"}>
|
|
77
|
-
{icons.map((icon: string) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</IconButton>
|
|
91
|
-
</Tooltip>
|
|
92
|
-
);
|
|
93
|
-
})}
|
|
62
|
+
{icons.map((icon: string) => (
|
|
63
|
+
<Tooltip title={icon} key={icon}
|
|
64
|
+
asChild={true}>
|
|
65
|
+
<IconButton
|
|
66
|
+
shape={"square"}
|
|
67
|
+
toggled={selectedIcon === icon}
|
|
68
|
+
onClick={onIconSelected ? () => onIconSelected(icon) : undefined}
|
|
69
|
+
className="box-content m-1"
|
|
70
|
+
>
|
|
71
|
+
<LucideIconByName name={icon} size={iconSize.medium}/>
|
|
72
|
+
</IconButton>
|
|
73
|
+
</Tooltip>
|
|
74
|
+
))}
|
|
94
75
|
</div>
|
|
95
76
|
</>
|
|
96
77
|
);
|
|
@@ -190,6 +190,7 @@ export function StringNumberFilterField({
|
|
|
190
190
|
|
|
191
191
|
{!enumValues && <TextField
|
|
192
192
|
size={"medium"}
|
|
193
|
+
aria-label={`${title ?? name} filter value`}
|
|
193
194
|
type={type === "number" ? "number" : undefined}
|
|
194
195
|
value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
|
|
195
196
|
disabled={isNullOperation}
|
|
@@ -9,7 +9,7 @@ import { EditViewBinding } from "./EditViewBinding";
|
|
|
9
9
|
import { DetailViewBinding } from "./DetailViewBinding";
|
|
10
10
|
import { useSideDialogContext } from "./SideDialogs";
|
|
11
11
|
import { useNavigate } from "react-router";
|
|
12
|
-
import {
|
|
12
|
+
import { useComponentOverride } from "@rebasepro/app";
|
|
13
13
|
import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
|
|
14
14
|
import { useSidePanel } from "../hooks/useSidePanel";
|
|
15
15
|
import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
|
|
@@ -76,12 +76,33 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
|
|
|
76
76
|
close(true);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The panel's response to a save, and the one place it decides where the
|
|
81
|
+
* panel goes next.
|
|
82
|
+
*
|
|
83
|
+
* Exactly **one** panel navigation is raised here. These branches used to
|
|
84
|
+
* run independently — a `replace` onto the saved record's address *and* a
|
|
85
|
+
* close — and because the last navigation is the one that survives, which
|
|
86
|
+
* of them the user got was settled by statement order rather than by
|
|
87
|
+
* intent. Closing wins by construction now: moving a panel to a new address
|
|
88
|
+
* it is about to leave is work whose only effect is to fight the close.
|
|
89
|
+
*
|
|
90
|
+
* `props.onUpdate` runs last for the same reason. It belongs to whoever
|
|
91
|
+
* opened the panel and it is free to navigate — the reference picker's
|
|
92
|
+
* selects the record and closes the picker — so the opener's intent should
|
|
93
|
+
* be the final word, not something this panel overwrites on its way out.
|
|
94
|
+
*/
|
|
79
95
|
const onUpdate = (params: OnUpdateParams) => {
|
|
80
|
-
if (props.onUpdate) {
|
|
81
|
-
props.onUpdate(params);
|
|
82
|
-
}
|
|
83
96
|
setEditInDialog(false);
|
|
84
|
-
|
|
97
|
+
|
|
98
|
+
// Either "save and close" was clicked, or the opener asked for it when
|
|
99
|
+
// it opened the panel.
|
|
100
|
+
const closeRequested = sideDialogsController.pendingClose || props.closeOnSave;
|
|
101
|
+
|
|
102
|
+
if (closeRequested) {
|
|
103
|
+
sideDialogsController.setPendingClose(false);
|
|
104
|
+
closeAfterSave();
|
|
105
|
+
} else if (params.status !== "existing") {
|
|
85
106
|
// A newly created entity replaces the panel with its own address,
|
|
86
107
|
// which already leaves the edit view behind.
|
|
87
108
|
sidePanelController.replace({
|
|
@@ -95,11 +116,7 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
|
|
|
95
116
|
closeEditView();
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
|
|
99
|
-
sideDialogsController.setPendingClose(false);
|
|
100
|
-
closeAfterSave();
|
|
101
|
-
}
|
|
102
|
-
|
|
119
|
+
props.onUpdate?.(params);
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
const parentCollectionSlugs = useMemo(() => {
|
|
@@ -290,25 +307,19 @@ entityId }
|
|
|
290
307
|
onSaved={onUpdate}
|
|
291
308
|
navigateBack={closeEditView}
|
|
292
309
|
barActionsStart={barActionsStart}
|
|
293
|
-
barActions={({
|
|
294
|
-
status,
|
|
295
|
-
values,
|
|
296
|
-
dirty
|
|
297
|
-
}) => windowControls(
|
|
310
|
+
barActions={({ carryEdit }) => windowControls(
|
|
298
311
|
allowFullScreen && <IconButton
|
|
299
312
|
className="self-center"
|
|
300
313
|
size={"small"}
|
|
301
314
|
onClick={() => {
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
saveEntityToMemoryCache(key, values);
|
|
311
|
-
}
|
|
315
|
+
// The form's own handoff, rather than a second
|
|
316
|
+
// copy of the rules for it here: only an edit in
|
|
317
|
+
// progress is worth carrying (a clean record
|
|
318
|
+
// stashed anyway arrived announcing unsaved
|
|
319
|
+
// changes to someone who had only looked at it),
|
|
320
|
+
// and only the fields that were edited (carrying
|
|
321
|
+
// the whole record marked every field touched).
|
|
322
|
+
carryEdit?.();
|
|
312
323
|
setBlocked(false);
|
|
313
324
|
setBlockedNavigationMessage(undefined);
|
|
314
325
|
if (entityId) {
|