@rebasepro/admin 0.13.0 → 0.13.1-canary.g394d868
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-CjXkWZ64.js → CollectionEditorDialog-D78xsXgL.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-D78xsXgL.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-B88EC-1f.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-B88EC-1f.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-B1Ws7h9l.js} +4 -4
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-B1Ws7h9l.js.map} +1 -1
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/AdditionalFieldValue.d.ts +25 -0
- package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
- package/dist/components/DetailViewBinding.d.ts +15 -0
- package/dist/components/DrawerNavigationGroup.d.ts +9 -6
- package/dist/components/DrawerNavigationItem.d.ts +16 -6
- package/dist/components/EditViewBinding.d.ts +30 -1
- package/dist/components/EntityDisplayHeader.d.ts +53 -0
- package/dist/components/EntityIdentityBar.d.ts +43 -1
- package/dist/components/EntityViewBinding.d.ts +60 -6
- package/dist/components/RebaseLayout.d.ts +1 -1
- package/dist/components/RebaseShell.d.ts +1 -1
- package/dist/components/RelationSelector.d.ts +8 -1
- package/dist/components/UserSelector.d.ts +8 -1
- package/dist/components/app/Scaffold.d.ts +9 -3
- package/dist/components/index.d.ts +2 -0
- package/dist/{export-J79rMoNj.js → export-BZhdWhkB.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-BZhdWhkB.js.map} +1 -1
- package/dist/form/components/FieldBlock.d.ts +58 -4
- package/dist/form/components/FormRail.d.ts +13 -0
- package/dist/form/components/FormSections.d.ts +15 -1
- package/dist/form/components/useRailVisible.d.ts +5 -4
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-D7L_JlLO.js → history-BPpt68CL.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-BPpt68CL.js.map} +1 -1
- package/dist/hooks/useEntityDisplay.d.ts +102 -0
- package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
- package/dist/{import-BvYl--U4.js → import-gRfemegK.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-gRfemegK.js.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +152 -57
- package/dist/index.js.map +1 -1
- package/dist/preview/compact_text.d.ts +23 -0
- package/dist/preview/components/BooleanPreview.d.ts +3 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
- package/dist/routes/RebaseRoute.d.ts +1 -1
- package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
- package/dist/util/number_format.d.ts +14 -0
- package/dist/util/previews.d.ts +9 -2
- package/dist/{util-GiwPgxnL.js → util-BHq3VOjH.js} +9080 -7861
- package/dist/util-BHq3VOjH.js.map +1 -0
- package/package.json +9 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/components/AdditionalFieldValue.tsx +68 -0
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
- 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/CollectionViewBinding/BoardCardBinding.tsx +26 -27
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +172 -112
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
- package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +177 -54
- package/src/components/DefaultDrawer.tsx +19 -23
- package/src/components/DetailViewBinding.tsx +87 -55
- package/src/components/DrawerNavigationGroup.tsx +16 -39
- package/src/components/DrawerNavigationItem.tsx +44 -16
- package/src/components/EditViewBinding.tsx +211 -41
- package/src/components/EntityDisplayHeader.tsx +170 -0
- package/src/components/EntityIdentityBar.tsx +149 -23
- package/src/components/EntityPreviewBinding.tsx +58 -18
- package/src/components/EntityViewBinding.tsx +342 -41
- package/src/components/InlineEntityPreview.tsx +10 -10
- package/src/components/RebaseLayout.tsx +2 -2
- package/src/components/RebaseShell.tsx +2 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +15 -4
- package/src/components/SideDialogs.tsx +15 -3
- package/src/components/SidePanelBinding.tsx +122 -66
- package/src/components/UserSelector.tsx +15 -4
- package/src/components/app/Scaffold.tsx +112 -10
- package/src/components/index.ts +3 -0
- package/src/form/EntityForm.tsx +64 -52
- package/src/form/EntityFormBinding.tsx +24 -11
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/components/FieldBlock.tsx +80 -10
- package/src/form/components/FormRail.tsx +19 -2
- package/src/form/components/FormSections.tsx +32 -5
- package/src/form/components/StorageUploadProgress.tsx +9 -3
- package/src/form/components/useRailVisible.tsx +5 -4
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
- package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
- package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
- package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
- package/src/form/form_utils.ts +78 -0
- package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
- package/src/hooks/useEntityDisplay.tsx +262 -0
- package/src/hooks/useEntityDisplayTitle.tsx +27 -38
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +25 -5
- package/src/preview/compact_text.ts +68 -0
- package/src/preview/components/BooleanPreview.tsx +5 -2
- package/src/preview/components/StorageThumbnail.tsx +5 -2
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
- package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
- package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
- package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
- package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
- package/src/routes/RebaseRoute.tsx +62 -4
- package/src/types/components/PropertyPreviewProps.tsx +21 -0
- package/src/util/number_format.ts +46 -0
- package/src/util/previews.ts +8 -55
- package/src/util/property_utils.tsx +6 -1
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -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";
|
|
@@ -49,21 +49,60 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
|
|
|
49
49
|
const sideDialogsController = useSideDialogContext();
|
|
50
50
|
const urlController = useUrlController();
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The X in the identity bar.
|
|
54
|
+
*
|
|
55
|
+
* Unforced, so it stops at the same unsaved-changes prompt that Escape and
|
|
56
|
+
* a click outside already do. It used to clear `blocked` and force the
|
|
57
|
+
* close, which made the one control actually labelled "close" the only way
|
|
58
|
+
* out of the panel that dropped an edit without asking.
|
|
59
|
+
*
|
|
60
|
+
* `props.onClose` is not called here: the panel carries the same callback
|
|
61
|
+
* (see `propsToSidePanel`) and {@link SideDialogs} fires it once the panel
|
|
62
|
+
* really closes — which is after the prompt, not before it.
|
|
63
|
+
*/
|
|
52
64
|
const onClose = () => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
65
|
+
close();
|
|
66
|
+
}
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Closing because the record was just saved, which is the one case that has
|
|
70
|
+
* to force. `blocked` is driven by an effect on the form's dirty flag and is
|
|
71
|
+
* still true for a tick after a save; prompting about unsaved changes here
|
|
72
|
+
* would be asking about values that are already on the server.
|
|
73
|
+
*/
|
|
74
|
+
const closeAfterSave = () => {
|
|
57
75
|
setBlocked(false);
|
|
58
76
|
close(true);
|
|
59
77
|
}
|
|
60
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
|
+
*/
|
|
61
95
|
const onUpdate = (params: OnUpdateParams) => {
|
|
62
|
-
if (props.onUpdate) {
|
|
63
|
-
props.onUpdate(params);
|
|
64
|
-
}
|
|
65
96
|
setEditInDialog(false);
|
|
66
|
-
|
|
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") {
|
|
67
106
|
// A newly created entity replaces the panel with its own address,
|
|
68
107
|
// which already leaves the edit view behind.
|
|
69
108
|
sidePanelController.replace({
|
|
@@ -77,11 +116,7 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
|
|
|
77
116
|
closeEditView();
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
|
|
81
|
-
sideDialogsController.setPendingClose(false);
|
|
82
|
-
onClose();
|
|
83
|
-
}
|
|
84
|
-
|
|
119
|
+
props.onUpdate?.(params);
|
|
85
120
|
}
|
|
86
121
|
|
|
87
122
|
const parentCollectionSlugs = useMemo(() => {
|
|
@@ -181,6 +216,43 @@ entityId }
|
|
|
181
216
|
return <div className={"w-full"}/>;
|
|
182
217
|
}
|
|
183
218
|
|
|
219
|
+
const closeButton = (
|
|
220
|
+
<IconButton
|
|
221
|
+
className="self-center"
|
|
222
|
+
size={"small"}
|
|
223
|
+
onClick={onClose}>
|
|
224
|
+
<XIcon/>
|
|
225
|
+
</IconButton>
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Where the panel's own two controls sit, which is not the same question in
|
|
230
|
+
* the two layouts that have them.
|
|
231
|
+
*
|
|
232
|
+
* A **side panel** slides in over the list from the right, and its leading
|
|
233
|
+
* edge is the seam between the two. Close belongs on that seam — it points
|
|
234
|
+
* back at the thing still visible behind it — and it is where the eye
|
|
235
|
+
* already is, because the breadcrumb starts there.
|
|
236
|
+
*
|
|
237
|
+
* A **dialog** has no seam and nothing behind it to point at. It is a box
|
|
238
|
+
* centred on a dimmed screen, and every box like it on every platform is
|
|
239
|
+
* dismissed from its top-right corner. Close was sitting at the far left
|
|
240
|
+
* of the bar, a full dialog's width from the expand button it belongs
|
|
241
|
+
* beside, in the one corner that means "back" in a layout with no back.
|
|
242
|
+
*
|
|
243
|
+
* So in a dialog the two travel together at the trailing edge, expand then
|
|
244
|
+
* close: the window controls, after the record's actions and its overflow
|
|
245
|
+
* menu, in the corner a dialog is closed from.
|
|
246
|
+
*/
|
|
247
|
+
const barActionsStart = isDialogMode ? undefined : closeButton;
|
|
248
|
+
|
|
249
|
+
const windowControls = (expandButton: React.ReactNode) => (
|
|
250
|
+
<div className="flex gap-1 items-center">
|
|
251
|
+
{expandButton}
|
|
252
|
+
{isDialogMode && closeButton}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
|
|
184
256
|
return (
|
|
185
257
|
<>
|
|
186
258
|
<ErrorBoundary>
|
|
@@ -193,31 +265,20 @@ entityId }
|
|
|
193
265
|
parentCollectionSlugs={parentCollectionSlugs}
|
|
194
266
|
parentEntityIds={parentEntityIds}
|
|
195
267
|
onEditClick={openEditView}
|
|
196
|
-
barActionsStart={
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<IconButton
|
|
268
|
+
barActionsStart={barActionsStart}
|
|
269
|
+
barActions={() => windowControls(
|
|
270
|
+
allowFullScreen && <IconButton
|
|
200
271
|
className="self-center"
|
|
201
272
|
size={"small"}
|
|
202
|
-
onClick={
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
size={"small"}
|
|
212
|
-
onClick={() => {
|
|
213
|
-
if (entityId) {
|
|
214
|
-
const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
|
|
215
|
-
navigate(fullScreenUrl, { state: null });
|
|
216
|
-
}
|
|
217
|
-
}}>
|
|
218
|
-
<Maximize2Icon/>
|
|
219
|
-
</IconButton>}
|
|
220
|
-
</div>}
|
|
273
|
+
onClick={() => {
|
|
274
|
+
if (entityId) {
|
|
275
|
+
const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
|
|
276
|
+
navigate(fullScreenUrl, { state: null });
|
|
277
|
+
}
|
|
278
|
+
}}>
|
|
279
|
+
<Maximize2Icon/>
|
|
280
|
+
</IconButton>
|
|
281
|
+
)}
|
|
221
282
|
onTabChange={({
|
|
222
283
|
entityId: tabEntityId,
|
|
223
284
|
selectedTab,
|
|
@@ -245,38 +306,33 @@ entityId }
|
|
|
245
306
|
onValuesModified={onValuesModified}
|
|
246
307
|
onSaved={onUpdate}
|
|
247
308
|
navigateBack={closeEditView}
|
|
248
|
-
barActionsStart={
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<IconButton
|
|
309
|
+
barActionsStart={barActionsStart}
|
|
310
|
+
barActions={({ carryEdit }) => windowControls(
|
|
311
|
+
allowFullScreen && <IconButton
|
|
252
312
|
className="self-center"
|
|
253
313
|
size={"small"}
|
|
254
|
-
onClick={
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}}>
|
|
277
|
-
<Maximize2Icon/>
|
|
278
|
-
</IconButton>}
|
|
279
|
-
</div>}
|
|
314
|
+
onClick={() => {
|
|
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?.();
|
|
323
|
+
setBlocked(false);
|
|
324
|
+
setBlockedNavigationMessage(undefined);
|
|
325
|
+
if (entityId) {
|
|
326
|
+
const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
|
|
327
|
+
navigate(fullScreenUrl, { state: null });
|
|
328
|
+
} else {
|
|
329
|
+
const fullScreenUrl = urlController.buildUrlCollectionPath(path);
|
|
330
|
+
navigate(fullScreenUrl + "#new", { state: null });
|
|
331
|
+
}
|
|
332
|
+
}}>
|
|
333
|
+
<Maximize2Icon/>
|
|
334
|
+
</IconButton>
|
|
335
|
+
)}
|
|
280
336
|
onTabChange={({
|
|
281
337
|
entityId: tabEntityId,
|
|
282
338
|
selectedTab,
|
|
@@ -128,7 +128,14 @@ export interface UserSelectorProps {
|
|
|
128
128
|
value?: string | null;
|
|
129
129
|
onValueChange?: (userId: string | null) => void;
|
|
130
130
|
placeholder?: React.ReactNode;
|
|
131
|
-
|
|
131
|
+
/**
|
|
132
|
+
* `large` is the form's size and is on the shared 28/32/40/48 control
|
|
133
|
+
* scale, so a user picker lines up with the text field beside it.
|
|
134
|
+
*
|
|
135
|
+
* `small` and `medium` predate that scale (42 and 56) and are kept as they
|
|
136
|
+
* are because the table cells and the filter row are built around them.
|
|
137
|
+
*/
|
|
138
|
+
size?: "small" | "medium" | "large";
|
|
132
139
|
disabled?: boolean;
|
|
133
140
|
invisible?: boolean;
|
|
134
141
|
clearable?: boolean;
|
|
@@ -317,9 +324,13 @@ export const UserSelector = React.forwardRef<
|
|
|
317
324
|
className={cls(
|
|
318
325
|
{
|
|
319
326
|
"min-h-[42px] py-1 px-2": size === "small",
|
|
320
|
-
"min-h-[56px] py-2 px-4": size === "medium"
|
|
327
|
+
"min-h-[56px] py-2 px-4": size === "medium",
|
|
328
|
+
"min-h-[48px] py-1 px-4": size === "large"
|
|
321
329
|
},
|
|
322
|
-
|
|
330
|
+
// `rounded-lg` like every other field box —
|
|
331
|
+
// `rounded-md` made this the one control in a
|
|
332
|
+
// row with a different corner.
|
|
333
|
+
"w-full select-none rounded-lg text-sm relative flex items-center",
|
|
323
334
|
invisible ? fieldBackgroundInvisibleMixin : fieldBackgroundMixin,
|
|
324
335
|
disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
|
|
325
336
|
className
|
|
@@ -345,7 +356,7 @@ export const UserSelector = React.forwardRef<
|
|
|
345
356
|
</IconButton>
|
|
346
357
|
)}
|
|
347
358
|
<ChevronDownIcon
|
|
348
|
-
size={size === "
|
|
359
|
+
size={size === "small" ? iconSize.small : iconSize.medium}
|
|
349
360
|
className={cls("transition", isPopoverOpen ? "rotate-180" : "")}
|
|
350
361
|
/>
|
|
351
362
|
</div>
|
|
@@ -4,17 +4,48 @@ import { ChevronLeftIcon, ErrorBoundary, MenuIcon } from "@rebasepro/ui";
|
|
|
4
4
|
import { deepEqual as equal } from "fast-equals"
|
|
5
5
|
|
|
6
6
|
import { useLargeLayout, useAdminModeController, useTranslation } from "@rebasepro/app";
|
|
7
|
+
import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
|
|
7
8
|
import { AppContext } from "./useApp";
|
|
8
9
|
|
|
9
10
|
export const DRAWER_WIDTH = 280;
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Namespaced by base path: two admins served from one origin are two different
|
|
14
|
+
* navigations, and a single key would have them overwrite each other's drawer.
|
|
15
|
+
*/
|
|
16
|
+
export function drawerOpenStorageKey(basePath: string | undefined) {
|
|
17
|
+
return `rebase-drawer-open:${basePath || "/"}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `null` when the user has never toggled the drawer, or storage is unavailable. */
|
|
21
|
+
function readStoredDrawerOpen(key: string): boolean | null {
|
|
22
|
+
if (typeof window === "undefined") return null;
|
|
23
|
+
try {
|
|
24
|
+
const stored = window.localStorage.getItem(key);
|
|
25
|
+
return stored === null ? null : stored === "true";
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// Storage can be blocked: private mode, sandboxed iframe, cookie policy.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeStoredDrawerOpen(key: string, open: boolean) {
|
|
33
|
+
if (typeof window === "undefined") return;
|
|
34
|
+
try {
|
|
35
|
+
window.localStorage.setItem(key, String(open));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// Same as above: a drawer that forgets is better than a crash.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* @group Core
|
|
13
43
|
*/
|
|
14
44
|
export interface ScaffoldProps {
|
|
15
45
|
|
|
16
46
|
/**
|
|
17
|
-
*
|
|
47
|
+
* Expand the collapsed drawer while the pointer is over it. On by default —
|
|
48
|
+
* pass `false` to keep the rail a rail until the user clicks the toggle.
|
|
18
49
|
*/
|
|
19
50
|
autoOpenDrawer?: boolean;
|
|
20
51
|
|
|
@@ -22,8 +53,8 @@ export interface ScaffoldProps {
|
|
|
22
53
|
* Start with the drawer expanded rather than collapsed to icons.
|
|
23
54
|
*
|
|
24
55
|
* Distinct from {@link autoOpenDrawer}, which is a hover behaviour: this one
|
|
25
|
-
* only seeds the
|
|
26
|
-
*
|
|
56
|
+
* only seeds the very first visit. Once the user toggles the drawer, that
|
|
57
|
+
* choice is stored in `localStorage` and wins over this prop on every load.
|
|
27
58
|
*
|
|
28
59
|
* Ignored on small layouts, where an expanded drawer covers the content it is
|
|
29
60
|
* meant to navigate.
|
|
@@ -62,7 +93,7 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
|
|
|
62
93
|
|
|
63
94
|
const {
|
|
64
95
|
children,
|
|
65
|
-
autoOpenDrawer,
|
|
96
|
+
autoOpenDrawer = true,
|
|
66
97
|
defaultDrawerOpen = false,
|
|
67
98
|
logo,
|
|
68
99
|
className,
|
|
@@ -83,25 +114,96 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
|
|
|
83
114
|
const includeDrawer = drawerChildren.length > 0;
|
|
84
115
|
const largeLayout = useLargeLayout();
|
|
85
116
|
|
|
117
|
+
const storageKey = drawerOpenStorageKey(useUrlController().basePath);
|
|
118
|
+
|
|
86
119
|
// Seeded once, deliberately: a `useEffect` syncing this to the prop would
|
|
87
120
|
// re-expand the drawer under a user who had just collapsed it.
|
|
88
|
-
const [drawerOpen,
|
|
121
|
+
const [drawerOpen, setDrawerOpenState] = React.useState(defaultDrawerOpen && largeLayout);
|
|
89
122
|
const [onHover, setOnHover] = React.useState(false);
|
|
90
123
|
|
|
91
|
-
|
|
124
|
+
// The stored choice is a client-only fact, so it is applied after the first
|
|
125
|
+
// render rather than seeded into it: reading storage while rendering would
|
|
126
|
+
// make the client disagree with server-rendered HTML. A layout effect runs
|
|
127
|
+
// before paint, so the drawer still arrives in its remembered state.
|
|
128
|
+
// `defaultDrawerOpen` seeds the very first visit and is ignored after that.
|
|
129
|
+
React.useLayoutEffect(() => {
|
|
130
|
+
const stored = readStoredDrawerOpen(storageKey);
|
|
131
|
+
if (stored === null) return;
|
|
132
|
+
setDrawerOpenState(stored && largeLayout);
|
|
133
|
+
// Deliberately not re-run on `largeLayout`: crossing the breakpoint is
|
|
134
|
+
// the effect below, and re-running here would undo a user's toggle.
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, [storageKey]);
|
|
137
|
+
|
|
138
|
+
// One piece of state drives two different things: the expanded rail on
|
|
139
|
+
// large layouts, and the modal sheet on small ones. Carrying an expanded
|
|
140
|
+
// rail across the breakpoint would drop that sheet, overlay and all, over
|
|
141
|
+
// the content — so the crossing resets it, and widening again restores
|
|
142
|
+
// whatever the user last chose.
|
|
143
|
+
const wasLargeLayout = React.useRef(largeLayout);
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (wasLargeLayout.current === largeLayout) return;
|
|
146
|
+
wasLargeLayout.current = largeLayout;
|
|
147
|
+
setDrawerOpenState(largeLayout ? (readStoredDrawerOpen(storageKey) ?? defaultDrawerOpen) : false);
|
|
148
|
+
}, [largeLayout, defaultDrawerOpen, storageKey]);
|
|
149
|
+
|
|
150
|
+
const setDrawerOpen = useCallback((open: boolean) => {
|
|
151
|
+
setDrawerOpenState(open);
|
|
152
|
+
// Small layouts render the drawer as a modal sheet. Remembering that
|
|
153
|
+
// one would greet the next load with an overlay over the content.
|
|
154
|
+
if (!largeLayout) return;
|
|
155
|
+
writeStoredDrawerOpen(storageKey, open);
|
|
156
|
+
}, [largeLayout, storageKey]);
|
|
157
|
+
|
|
158
|
+
// The pointer has left, but a popover the drawer opened is still up, so the
|
|
159
|
+
// collapse is owed rather than cancelled. See `setOnHoverFalse`.
|
|
160
|
+
const collapseWhenPopoverCloses = React.useRef(false);
|
|
161
|
+
|
|
162
|
+
const setOnHoverTrue = useCallback(() => {
|
|
163
|
+
// Hover expansion is the default; `autoOpenDrawer={false}` opts out for
|
|
164
|
+
// admins that want the rail to stay a rail until the toggle is clicked.
|
|
165
|
+
if (!autoOpenDrawer) return;
|
|
166
|
+
collapseWhenPopoverCloses.current = false;
|
|
167
|
+
setOnHover(true);
|
|
168
|
+
}, [autoOpenDrawer]);
|
|
92
169
|
const setOnHoverFalse = useCallback(() => {
|
|
93
|
-
// Don't collapse the drawer
|
|
94
|
-
|
|
170
|
+
// Don't collapse the drawer out from under an open popover/dropdown —
|
|
171
|
+
// its content is portalled outside the drawer, so reaching for it reads
|
|
172
|
+
// as a mouseleave. The collapse is owed until that popover closes.
|
|
173
|
+
if (document.querySelector("[data-radix-popper-content-wrapper]")) {
|
|
174
|
+
collapseWhenPopoverCloses.current = true;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
collapseWhenPopoverCloses.current = false;
|
|
95
178
|
setOnHover(false);
|
|
96
179
|
}, []);
|
|
97
180
|
|
|
181
|
+
// Nothing fires a second mouseleave when the popover finally closes, so the
|
|
182
|
+
// owed collapse has to be noticed rather than waited for. Only mounted
|
|
183
|
+
// while the drawer is actually floating open.
|
|
184
|
+
React.useEffect(() => {
|
|
185
|
+
if (!onHover) return;
|
|
186
|
+
const collapseIfOwed = () => {
|
|
187
|
+
if (!collapseWhenPopoverCloses.current) return;
|
|
188
|
+
if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
|
|
189
|
+
collapseWhenPopoverCloses.current = false;
|
|
190
|
+
setOnHover(false);
|
|
191
|
+
};
|
|
192
|
+
const observer = new MutationObserver(collapseIfOwed);
|
|
193
|
+
observer.observe(document.body, {
|
|
194
|
+
childList: true,
|
|
195
|
+
subtree: true
|
|
196
|
+
});
|
|
197
|
+
return () => observer.disconnect();
|
|
198
|
+
}, [onHover]);
|
|
199
|
+
|
|
98
200
|
const handleDrawerOpen = useCallback(() => {
|
|
99
201
|
setDrawerOpen(true);
|
|
100
|
-
}, []);
|
|
202
|
+
}, [setDrawerOpen]);
|
|
101
203
|
|
|
102
204
|
const handleDrawerClose = useCallback(() => {
|
|
103
205
|
setDrawerOpen(false);
|
|
104
|
-
}, []);
|
|
206
|
+
}, [setDrawerOpen]);
|
|
105
207
|
|
|
106
208
|
const computedDrawerOpen: boolean = drawerOpen;
|
|
107
209
|
const computedDrawerHovered = Boolean(largeLayout && onHover);
|
package/src/components/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export type { EntityViewBindingProps } from "./EntityViewBinding";
|
|
2
2
|
export { EntityViewBinding } from "./EntityViewBinding";
|
|
3
3
|
|
|
4
|
+
export type { EntityDisplayHeaderProps } from "./EntityDisplayHeader";
|
|
5
|
+
export { EntityDisplayHeader, HEADER_DISPLAY_ROLES } from "./EntityDisplayHeader";
|
|
6
|
+
|
|
4
7
|
export * from "./DetailViewBinding";
|
|
5
8
|
|
|
6
9
|
export type { SelectionProps } from "./ReferenceTable/SelectionTableBinding";
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -9,18 +9,18 @@ import { deepEqual as equal } from "fast-equals";
|
|
|
9
9
|
import { ErrorBoundary } from "@rebasepro/ui";
|
|
10
10
|
import { AlignLeftIcon, useDebouncedCallback } from "@rebasepro/ui";
|
|
11
11
|
import { getDefaultValuesFor } from "@rebasepro/common";
|
|
12
|
-
import { isReadOnly } from "@rebasepro/app";
|
|
12
|
+
import { isDisabled, isReadOnly } from "@rebasepro/app";
|
|
13
13
|
|
|
14
14
|
import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
|
|
15
15
|
import type { ResolvedFormField } from "@rebasepro/app";
|
|
16
|
-
import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle,
|
|
16
|
+
import { Alert, Button, cls, defaultBorderMixin, Dialog, DialogActions, DialogContent, DialogTitle, paperMixin, Typography } from "@rebasepro/ui";
|
|
17
17
|
import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
|
|
18
18
|
|
|
19
|
-
import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
|
|
20
|
-
import {
|
|
19
|
+
import { FieldBlock, isSelfLabellingProperty, LABEL_ICON_SIZE, spanClass } from "./components/FieldBlock";
|
|
20
|
+
import { AdditionalFieldValue } from "../components/AdditionalFieldValue";
|
|
21
|
+
import { FormRail, RecordMeta } from "./components/FormRail";
|
|
21
22
|
import { FormSections } from "./components/FormSections";
|
|
22
23
|
import { useRailVisible } from "./components/useRailVisible";
|
|
23
|
-
import { LabelWithIconAndTooltip } from "./components/LabelWithIconAndTooltip";
|
|
24
24
|
import { PropertyFieldBinding } from "./PropertyFieldBinding";
|
|
25
25
|
import { flattenKeys } from "@rebasepro/app";
|
|
26
26
|
import { ErrorFocus } from "./components/ErrorFocus";
|
|
@@ -173,15 +173,30 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
173
173
|
});
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
/**
|
|
177
|
+
* What the form opens showing, when that is not what is stored — an edit
|
|
178
|
+
* carried over from the side panel's "open full screen", or a draft
|
|
179
|
+
* restored from the local-changes backup.
|
|
180
|
+
*
|
|
181
|
+
* The merge is deliberately *not* folded into the baseline. `mergeDeep`
|
|
182
|
+
* used to produce a single `initialValues` that served as both, with a
|
|
183
|
+
* `initialDirty: true` bolted on beside it to say what the baseline could
|
|
184
|
+
* no longer show — and that flag survived exactly one render, because the
|
|
185
|
+
* record's data arriving replaced the baseline and the form recomputed
|
|
186
|
+
* dirty as false against a baseline that already contained the edit.
|
|
187
|
+
*
|
|
188
|
+
* Read once, at mount: this is a handoff, and re-reading it every render
|
|
189
|
+
* tied the form to a cache that is cleared as soon as it has been consumed.
|
|
190
|
+
*/
|
|
191
|
+
const initialModifiedValues = useRef(
|
|
192
|
+
initialDirtyValues && Object.keys(initialDirtyValues).length > 0
|
|
193
|
+
? mergeDeep(baseInitialValues, initialDirtyValues)
|
|
194
|
+
: undefined
|
|
195
|
+
).current;
|
|
181
196
|
|
|
182
197
|
const internalFormex = useCreateFormex<M>({
|
|
183
|
-
initialValues:
|
|
184
|
-
|
|
198
|
+
initialValues: baseInitialValues as M,
|
|
199
|
+
initialModifiedValues: initialModifiedValues as M | undefined,
|
|
185
200
|
debugId: `EntityForm:${path}/${entityIdProp}`,
|
|
186
201
|
initialTouched: initialDirtyValues ?
|
|
187
202
|
flattenKeys(initialDirtyValues!)
|
|
@@ -193,7 +208,9 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
193
208
|
onSubmit,
|
|
194
209
|
onReset: () => {
|
|
195
210
|
onResetProp?.();
|
|
196
|
-
|
|
211
|
+
// The stored values — a reset returns the form to them, and by now
|
|
212
|
+
// that is the baseline whether or not it was at mount.
|
|
213
|
+
onValuesModified?.(false, baseInitialValues as M);
|
|
197
214
|
},
|
|
198
215
|
onValuesChangeDeferred: onValuesChangeDeferredProp,
|
|
199
216
|
validation: async (values): Promise<Record<string, string>> => {
|
|
@@ -420,7 +437,7 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
420
437
|
disabledProp
|
|
421
438
|
|| (!autoSave && formex.isSubmitting)
|
|
422
439
|
|| isReadOnly(property)
|
|
423
|
-
||
|
|
440
|
+
|| isDisabled(property)
|
|
424
441
|
|| (!isNew && "isId" in property && Boolean(property.isId))
|
|
425
442
|
|| (isNew && isIdAndAuto)
|
|
426
443
|
);
|
|
@@ -440,25 +457,26 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
440
457
|
const child = AdditionalFieldBuilder
|
|
441
458
|
? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
|
|
442
459
|
: <div className={"w-full"}>
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
460
|
+
<AdditionalFieldValue
|
|
461
|
+
field={additionalField}
|
|
462
|
+
entity={entity}
|
|
463
|
+
context={additionalFieldContext}/>
|
|
447
464
|
</div>;
|
|
448
465
|
|
|
449
466
|
return (
|
|
467
|
+
// Through FieldBlock like every other cell, so a computed
|
|
468
|
+
// column is labelled at the same size, with the same gap to its
|
|
469
|
+
// box, as the property next to it on the grid.
|
|
450
470
|
<div key={`additional_${field.key}`}
|
|
451
471
|
className={spanClass(field.span)}>
|
|
452
|
-
<
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
className={"
|
|
457
|
-
<div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
|
|
458
|
-
<ErrorBoundary>
|
|
472
|
+
<FieldBlock propertyKey={field.key}
|
|
473
|
+
showLabel={true}
|
|
474
|
+
label={additionalField.name}
|
|
475
|
+
icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}>
|
|
476
|
+
<div className={cls(paperMixin, "w-full min-h-12 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
|
|
459
477
|
{child}
|
|
460
|
-
</
|
|
461
|
-
</
|
|
478
|
+
</div>
|
|
479
|
+
</FieldBlock>
|
|
462
480
|
</div>
|
|
463
481
|
);
|
|
464
482
|
}
|
|
@@ -537,12 +555,25 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
537
555
|
}
|
|
538
556
|
];
|
|
539
557
|
|
|
540
|
-
return
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
558
|
+
return <>
|
|
559
|
+
<FormSections
|
|
560
|
+
sections={sections}
|
|
561
|
+
collapsed={collapsedSections}
|
|
562
|
+
onToggle={toggleSection}
|
|
563
|
+
errorKeys={errorKeys}
|
|
564
|
+
renderField={renderField}/>
|
|
565
|
+
|
|
566
|
+
{/* Folded back with the rail's fields, and for the same reason. The
|
|
567
|
+
rail renders only where it has been measured to fit, so on the
|
|
568
|
+
split pane, the side panel and the dialog this block was dropped
|
|
569
|
+
outright and the record's id was nowhere on screen but the
|
|
570
|
+
truncated chip in the bar. */}
|
|
571
|
+
{!showRail && layout.showRecordMeta && status === "existing" && entity && (
|
|
572
|
+
<div className={cls("mt-8 pt-5 border-t max-w-sm", defaultBorderMixin)}>
|
|
573
|
+
<RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
</>;
|
|
546
577
|
};
|
|
547
578
|
|
|
548
579
|
const formRef = useRef<HTMLDivElement>(null);
|
|
@@ -706,25 +737,6 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
706
737
|
);
|
|
707
738
|
}
|
|
708
739
|
|
|
709
|
-
/**
|
|
710
|
-
* Grid placement for a span.
|
|
711
|
-
*
|
|
712
|
-
* Written out as literal class strings rather than composed, because Tailwind
|
|
713
|
-
* scans source text: a template literal would produce classes that exist at
|
|
714
|
-
* runtime and were never generated into the stylesheet.
|
|
715
|
-
*
|
|
716
|
-
* Below the column breakpoint every field takes the single available column, so
|
|
717
|
-
* the side panel, the dialog and mobile all get a plain stack.
|
|
718
|
-
*/
|
|
719
|
-
function spanClass(span: 1 | 2 | 3 | 4): string {
|
|
720
|
-
switch (span) {
|
|
721
|
-
case 1: return "@2xl/col:col-span-1";
|
|
722
|
-
case 2: return "@2xl/col:col-span-2";
|
|
723
|
-
case 3: return "@2xl/col:col-span-3";
|
|
724
|
-
case 4: return "@2xl/col:col-span-4";
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
740
|
function useOnAutoSave<M extends Record<string, unknown>>(autoSave: undefined | boolean, formex: FormexController<M>, lastSavedValues: React.MutableRefObject<EntityValues<M> | undefined>, save: (values: EntityValues<M>) => Promise<Entity<M> | void>) {
|
|
729
741
|
useEffect(() => {
|
|
730
742
|
if (!autoSave) return;
|