@rebasepro/admin 0.13.0 → 0.13.1-canary.g3660bd5
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-MODRemil.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-MODRemil.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-Bt-VM0zQ.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-Bt-VM0zQ.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-D54P9o8O.js} +4 -4
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-D54P9o8O.js.map} +1 -1
- 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 +37 -0
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +5 -3
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +29 -4
- package/dist/components/DetailViewBinding.d.ts +6 -0
- package/dist/components/DrawerNavigationGroup.d.ts +5 -4
- package/dist/components/DrawerNavigationItem.d.ts +4 -2
- package/dist/components/EditViewBinding.d.ts +7 -1
- package/dist/components/EntityDisplayHeader.d.ts +53 -0
- package/dist/components/EntityIdentityBar.d.ts +20 -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/app/Scaffold.d.ts +9 -3
- package/dist/components/index.d.ts +2 -0
- package/dist/{export-J79rMoNj.js → export-Bl1V7lKO.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-Bl1V7lKO.js.map} +1 -1
- package/dist/form/components/FieldBlock.d.ts +47 -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/{history-D7L_JlLO.js → history-DN_dzFAF.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-DN_dzFAF.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-BJXrHdFv.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-BJXrHdFv.js.map} +1 -1
- package/dist/index.js +149 -51
- 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-EXfeGzbH.js} +8142 -7353
- package/dist/util-EXfeGzbH.js.map +1 -0
- package/package.json +9 -9
- package/src/components/AdditionalFieldValue.tsx +68 -0
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +5 -6
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +9 -27
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +11 -24
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -33
- package/src/components/CollectionViewBinding/SlotValue.tsx +60 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +14 -1
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
- package/src/components/DefaultDrawer.tsx +19 -23
- package/src/components/DetailViewBinding.tsx +74 -54
- package/src/components/DrawerNavigationGroup.tsx +4 -7
- package/src/components/DrawerNavigationItem.tsx +23 -4
- package/src/components/EditViewBinding.tsx +54 -33
- package/src/components/EntityDisplayHeader.tsx +170 -0
- package/src/components/EntityIdentityBar.tsx +49 -5
- package/src/components/EntityPreviewBinding.tsx +54 -17
- package/src/components/EntityViewBinding.tsx +341 -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/app/Scaffold.tsx +112 -10
- package/src/components/index.ts +3 -0
- package/src/form/EntityForm.tsx +28 -33
- package/src/form/components/FieldBlock.tsx +65 -8
- 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/ReadOnlyFieldBinding.tsx +7 -1
- package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
- package/src/hooks/useEntityDisplay.tsx +262 -0
- package/src/hooks/useEntityDisplayTitle.tsx +27 -38
- package/src/preview/PropertyPreview.tsx +18 -2
- 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/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/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -1,11 +1,69 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Property } from "@rebasepro/types";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
3
|
+
import type { AdditionalFieldDelegateProps, AdminCollection } from "@rebasepro/admin-types";
|
|
4
|
+
import type { ResolvedFormField, ResolvedFormSection } from "@rebasepro/app";
|
|
5
|
+
import type { FormContext } from "../types/fields";
|
|
6
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
5
7
|
import { Entity } from "@rebasepro/types";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
import {
|
|
9
|
+
AlignLeftIcon,
|
|
10
|
+
cls,
|
|
11
|
+
defaultBorderMixin,
|
|
12
|
+
ErrorBoundary,
|
|
13
|
+
paperMixin
|
|
14
|
+
} from "@rebasepro/ui";
|
|
15
|
+
import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
|
|
16
|
+
import { getValueInPath } from "@rebasepro/utils";
|
|
17
|
+
|
|
18
|
+
import { FieldBlock, LABEL_ICON_SIZE, spanClass } from "../form/components/FieldBlock";
|
|
19
|
+
import { AdditionalFieldValue } from "./AdditionalFieldValue";
|
|
20
|
+
import { EntityDisplayHeader, HEADER_DISPLAY_ROLES } from "./EntityDisplayHeader";
|
|
21
|
+
import { FormSections } from "../form/components/FormSections";
|
|
22
|
+
import { FormRail, RecordMeta } from "../form/components/FormRail";
|
|
23
|
+
import { useEntityDisplayValues } from "../hooks/useEntityDisplay";
|
|
24
|
+
import { useRailVisible } from "../form/components/useRailVisible";
|
|
25
|
+
import { LabelWithIconAndTooltip } from "../form/components/LabelWithIconAndTooltip";
|
|
26
|
+
import { PropertyPreview } from "../preview";
|
|
27
|
+
|
|
28
|
+
/** Nothing can be in error in a view that cannot be submitted. */
|
|
29
|
+
const NO_ERRORS: ReadonlySet<string> = new Set<string>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The fields the read-only view lays out, in render order.
|
|
33
|
+
*
|
|
34
|
+
* The same set the form uses, minus two groups. Both are dropped *before* the
|
|
35
|
+
* layout resolves rather than skipped while rendering, because the resolver has
|
|
36
|
+
* by then given each one a place on the grid — skipping later leaves that place
|
|
37
|
+
* as a hole.
|
|
38
|
+
*
|
|
39
|
+
* - The `additionalFields` when there is no form context to hand them: those
|
|
40
|
+
* entries are arbitrary components, not values, and one rendered against no
|
|
41
|
+
* context is nothing at all.
|
|
42
|
+
* - `headerKeys` — the properties {@link EntityDisplayHeader} has already
|
|
43
|
+
* rendered as the record's picture, subtitle, state and labels. Leaving one in
|
|
44
|
+
* prints its value twice on one screen.
|
|
45
|
+
*
|
|
46
|
+
* The record's **title property is not among them**, deliberately. It names the
|
|
47
|
+
* page in the identity bar, but a heading and a labelled field are different
|
|
48
|
+
* things: `Order #` is a column with a name and a description that someone opens
|
|
49
|
+
* the record to read, and dropping it because its text also appears in the
|
|
50
|
+
* breadcrumb removes a value from the record on the grounds that it is legible
|
|
51
|
+
* somewhere else. See {@link HEADER_DISPLAY_ROLES}.
|
|
52
|
+
*/
|
|
53
|
+
export function readOnlyFieldKeys(
|
|
54
|
+
collection: AdminCollection,
|
|
55
|
+
hasFormContext: boolean,
|
|
56
|
+
headerKeys?: ReadonlySet<string>
|
|
57
|
+
): string[] {
|
|
58
|
+
const keys = getFormFieldKeys(collection);
|
|
59
|
+
const additional = hasFormContext
|
|
60
|
+
? undefined
|
|
61
|
+
: new Set((collection.additionalFields ?? []).map(field => field.key));
|
|
62
|
+
|
|
63
|
+
return keys.filter(key =>
|
|
64
|
+
!additional?.has(key)
|
|
65
|
+
&& !headerKeys?.has(key));
|
|
66
|
+
}
|
|
9
67
|
|
|
10
68
|
/**
|
|
11
69
|
* @group Components
|
|
@@ -15,57 +73,299 @@ export interface EntityViewBindingProps<M extends Record<string, unknown>> {
|
|
|
15
73
|
collection: AdminCollection<M>;
|
|
16
74
|
path: string;
|
|
17
75
|
className?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Lay the record out as a page: a centred, scrolling column with the
|
|
78
|
+
* metadata rail beside it, exactly as the form does. Off for the inline
|
|
79
|
+
* uses — the delete dialog renders the record inside a box it already owns.
|
|
80
|
+
*/
|
|
81
|
+
asPage?: boolean;
|
|
82
|
+
/** Padding preset, matching the form's. Only read when `asPage`. */
|
|
83
|
+
openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
84
|
+
/**
|
|
85
|
+
* Needed only to render `additionalFields`, which are arbitrary components
|
|
86
|
+
* that take a form context. Without one those entries are left out rather
|
|
87
|
+
* than rendered against a fake context.
|
|
88
|
+
*/
|
|
89
|
+
formContext?: FormContext<M>;
|
|
18
90
|
}
|
|
19
91
|
|
|
20
92
|
/**
|
|
21
93
|
* The read-only rendering of a record's values.
|
|
22
94
|
*
|
|
23
|
-
* This
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
95
|
+
* This is the same layout as {@link EntityForm}: the sections, the grid spans,
|
|
96
|
+
* the labels and the rail all come from the one resolver, so the record does
|
|
97
|
+
* not rearrange itself when you press Edit. Only the controls differ — each
|
|
98
|
+
* field renders its {@link PropertyPreview} instead of an input.
|
|
99
|
+
*
|
|
100
|
+
* It used to be a flat two-column table of every value in the record: no
|
|
101
|
+
* grouping, no widths, a label column sized for short names and a value column
|
|
102
|
+
* that wrapped emails over two lines while the pane beside it stayed empty. A
|
|
103
|
+
* boolean and a markdown body were given the same room.
|
|
104
|
+
*
|
|
105
|
+
* The synthetic `Id` row it used to prepend is gone with the same reasoning the
|
|
106
|
+
* form applies: between that row, the collection's own id property and the
|
|
107
|
+
* `path/id` chip above them, one UUID appeared three times. The id is a
|
|
108
|
+
* copyable chip in the identity bar, and the rail's record block, once each.
|
|
109
|
+
*
|
|
110
|
+
* The labels are the form's labels, icon and all: a field is the same field
|
|
111
|
+
* whether or not you are editing it. What `mode: "read"` drops is the per-field
|
|
112
|
+
* *description*, which is input help rather than part of the label — and what it
|
|
113
|
+
* adds is room. A control is a bordered box that separates itself from the next
|
|
114
|
+
* one; a value is bare text, so the rows and sections are opened up rather than
|
|
115
|
+
* inheriting spacing sized for inputs.
|
|
28
116
|
*/
|
|
29
117
|
export function EntityViewBinding<M extends Record<string, unknown>>(
|
|
30
118
|
{
|
|
31
119
|
entity,
|
|
32
120
|
collection,
|
|
33
|
-
|
|
34
|
-
|
|
121
|
+
className,
|
|
122
|
+
asPage = false,
|
|
123
|
+
openEntityMode = "full_screen",
|
|
124
|
+
formContext
|
|
35
125
|
}: EntityViewBindingProps<M>) {
|
|
36
126
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
127
|
+
// The record's name, picture, state and labels, lifted out of the grid and
|
|
128
|
+
// into a header. Resolved here rather than inside it because the fields it
|
|
129
|
+
// renders have to come *out* of the layout, and the layout is resolved here.
|
|
130
|
+
const display = useEntityDisplayValues<M>({
|
|
131
|
+
collection,
|
|
132
|
+
entity
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const headerKeys = useMemo(() => {
|
|
136
|
+
const keys = new Set<string>();
|
|
137
|
+
for (const role of HEADER_DISPLAY_ROLES) {
|
|
138
|
+
// Set only for a role filled by a property of this collection. A
|
|
139
|
+
// role filled by a resolver has no field on the grid to remove.
|
|
140
|
+
const key = display[role].source?.key;
|
|
141
|
+
if (key) keys.add(key);
|
|
142
|
+
}
|
|
143
|
+
return keys;
|
|
144
|
+
}, [display]);
|
|
145
|
+
|
|
146
|
+
// Only *whether* there is a form context changes the field list, not which
|
|
147
|
+
// one it is — so the memo turns on the boolean rather than on the context
|
|
148
|
+
// object, which is rebuilt on every keystroke of the form above it.
|
|
149
|
+
const hasFormContext = Boolean(formContext);
|
|
150
|
+
const fieldKeys = useMemo(
|
|
151
|
+
() => readOnlyFieldKeys(collection as AdminCollection, hasFormContext, headerKeys),
|
|
152
|
+
[collection, hasFormContext, headerKeys]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// `status: "existing"` — a record you are reading exists by definition, and
|
|
156
|
+
// that is what routes a generated id and the audit timestamps out of the
|
|
157
|
+
// column and into the record block.
|
|
158
|
+
const layout = useMemo(() => resolveFormLayout({
|
|
159
|
+
collection,
|
|
160
|
+
fieldKeys,
|
|
161
|
+
status: "existing"
|
|
162
|
+
}), [collection, fieldKeys]);
|
|
163
|
+
|
|
164
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
165
|
+
// Measured, not a breakpoint — see `useRailVisible`. The same reasoning as
|
|
166
|
+
// the form's: the resolver *moves* fields into the rail, so when the rail
|
|
167
|
+
// cannot be shown they have to fold back into the column.
|
|
168
|
+
const railVisible = useRailVisible(containerRef);
|
|
169
|
+
const showRail = asPage && layout.hasRail && railVisible;
|
|
170
|
+
|
|
171
|
+
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
|
|
172
|
+
() => new Set(layout.sections.filter(section => section.collapsed).map(section => section.key))
|
|
173
|
+
);
|
|
174
|
+
const toggleSection = useCallback((key: string) => {
|
|
175
|
+
setCollapsedSections(prev => {
|
|
176
|
+
const next = new Set(prev);
|
|
177
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
const renderField = useCallback((field: ResolvedFormField): React.ReactNode => {
|
|
183
|
+
|
|
184
|
+
if (field.additional) {
|
|
185
|
+
const additionalField = collection.additionalFields?.find(f => f.key === field.key);
|
|
186
|
+
if (!additionalField || !formContext) return null;
|
|
187
|
+
|
|
188
|
+
const AdditionalFieldBuilder = additionalField.Builder;
|
|
189
|
+
const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
|
|
190
|
+
const child = AdditionalFieldBuilder
|
|
191
|
+
? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
|
|
192
|
+
: <div className={"w-full"}>
|
|
193
|
+
<AdditionalFieldValue
|
|
194
|
+
field={additionalField}
|
|
195
|
+
entity={entity}
|
|
196
|
+
context={additionalFieldContext}/>
|
|
197
|
+
</div>;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div key={`additional_${field.key}`} className={spanClass(field.span)}>
|
|
201
|
+
<LabelWithIconAndTooltip
|
|
202
|
+
propertyKey={field.key}
|
|
203
|
+
icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}
|
|
204
|
+
title={additionalField.name}
|
|
205
|
+
className={"text-text-secondary dark:text-text-secondary-dark"}/>
|
|
206
|
+
<div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
|
|
207
|
+
<ErrorBoundary>
|
|
208
|
+
{child}
|
|
209
|
+
</ErrorBoundary>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const property = collection.properties?.[field.key] as Property | undefined;
|
|
216
|
+
if (!property) return null;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div key={`field_${field.key}`} className={spanClass(field.span)}>
|
|
220
|
+
{/* Always labelled from here, unlike the form. The panel editors
|
|
221
|
+
that carry their own header have no header in preview — a map
|
|
222
|
+
previews as a table of its children and an array as its items,
|
|
223
|
+
so leaving the label to them would leave them unlabelled. */}
|
|
224
|
+
<FieldBlock propertyKey={field.key}
|
|
225
|
+
property={property}
|
|
226
|
+
showLabel={true}
|
|
227
|
+
mode={"read"}>
|
|
228
|
+
{/* One control's height for a single-line value, so a row of
|
|
229
|
+
short fields lines up with the taller ones beside it, and
|
|
230
|
+
anything longer simply grows. Matched to the form's row
|
|
231
|
+
rather than shrunk to the text: it is what keeps a value
|
|
232
|
+
from sitting directly on the label of the row below. */}
|
|
233
|
+
<div className={"min-h-8 flex flex-col justify-center min-w-0 text-text-primary dark:text-text-primary-dark"}>
|
|
234
|
+
<PropertyPreview propertyKey={field.key}
|
|
235
|
+
value={getValueInPath(entity.values, field.key)}
|
|
236
|
+
property={property}
|
|
237
|
+
hideLabel
|
|
238
|
+
size={"medium"}/>
|
|
239
|
+
</div>
|
|
240
|
+
</FieldBlock>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}, [collection, entity, formContext]);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One field of a `readVariant: "summary"` section: a label/value row rather
|
|
247
|
+
* than a grid cell, with the last one emphasised.
|
|
248
|
+
*
|
|
249
|
+
* The last row is the one a summary exists for — a run of figures is read to
|
|
250
|
+
* arrive at its final line — so it is separated by a rule and set at the
|
|
251
|
+
* weight the grid gave every cell equally.
|
|
252
|
+
*/
|
|
253
|
+
const renderSummaryField = useCallback((
|
|
254
|
+
field: ResolvedFormField,
|
|
255
|
+
index: number,
|
|
256
|
+
total: number
|
|
257
|
+
): React.ReactNode => {
|
|
258
|
+
|
|
259
|
+
const property = collection.properties?.[field.key] as Property | undefined;
|
|
260
|
+
if (!property) return null;
|
|
261
|
+
|
|
262
|
+
const last = index === total - 1;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div key={`summary_${field.key}`}
|
|
266
|
+
className={cls(
|
|
267
|
+
"flex items-baseline justify-between gap-4 min-w-0 py-2",
|
|
268
|
+
last && total > 1 && cls("mt-1.5 pt-3 border-t", defaultBorderMixin)
|
|
269
|
+
)}>
|
|
270
|
+
<span className={cls(
|
|
271
|
+
"min-w-0 truncate",
|
|
272
|
+
last
|
|
273
|
+
? "text-sm font-semibold text-text-primary dark:text-text-primary-dark"
|
|
274
|
+
: "text-sm text-text-secondary dark:text-text-secondary-dark"
|
|
275
|
+
)}>
|
|
276
|
+
{property.name ?? field.key}
|
|
277
|
+
</span>
|
|
278
|
+
<span className={cls(
|
|
279
|
+
"shrink-0 text-right text-text-primary dark:text-text-primary-dark",
|
|
280
|
+
last ? "text-base font-semibold" : "text-sm"
|
|
281
|
+
)}>
|
|
282
|
+
<PropertyPreview propertyKey={field.key}
|
|
283
|
+
value={getValueInPath(entity.values, field.key)}
|
|
284
|
+
property={property}
|
|
285
|
+
hideLabel
|
|
286
|
+
size={"medium"}/>
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}, [collection, entity]);
|
|
291
|
+
|
|
292
|
+
// Same fold-back as the form: with no room for the rail its fields become a
|
|
293
|
+
// trailing group rather than disappearing.
|
|
294
|
+
const sections: ResolvedFormSection[] = showRail || !layout.sidebar.length
|
|
295
|
+
? layout.sections
|
|
296
|
+
: [
|
|
297
|
+
...layout.sections,
|
|
298
|
+
{
|
|
299
|
+
key: "__rail",
|
|
300
|
+
title: "Settings",
|
|
301
|
+
collapsible: false,
|
|
302
|
+
collapsed: false,
|
|
303
|
+
fields: layout.sidebar
|
|
304
|
+
}
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
const content = <>
|
|
308
|
+
{/* The title only when nothing above is already showing it. Every
|
|
309
|
+
full-page use sits under an identity bar that carries it; the
|
|
310
|
+
inline ones — the delete dialog — have no bar. */}
|
|
311
|
+
<EntityDisplayHeader entity={entity}
|
|
312
|
+
collection={collection}
|
|
313
|
+
showTitle={!asPage}
|
|
314
|
+
className={"mb-10"}/>
|
|
315
|
+
|
|
316
|
+
<FormSections sections={sections}
|
|
317
|
+
collapsed={collapsedSections}
|
|
318
|
+
onToggle={toggleSection}
|
|
319
|
+
errorKeys={NO_ERRORS}
|
|
320
|
+
renderField={renderField}
|
|
321
|
+
renderSummaryField={renderSummaryField}
|
|
322
|
+
mode={"read"}/>
|
|
323
|
+
|
|
324
|
+
{/* Folded back with the rail's fields, and for the same reason. The rail
|
|
325
|
+
renders only where it has been measured to fit, so on the split pane,
|
|
326
|
+
the side panel and the dialog this block was dropped outright and the
|
|
327
|
+
record's id was nowhere on screen but the truncated chip in the bar.
|
|
328
|
+
An id you cannot read or copy is the one piece of a record you most
|
|
329
|
+
often opened it for. */}
|
|
330
|
+
{!showRail && layout.showRecordMeta && (
|
|
331
|
+
<div className={cls("mt-10 pt-6 border-t max-w-sm", defaultBorderMixin)}>
|
|
332
|
+
<RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</>;
|
|
336
|
+
|
|
337
|
+
if (!asPage) {
|
|
338
|
+
// `@container/col` even here: the spans resolve against the column they
|
|
339
|
+
// sit in, and inside a dialog that column is the dialog.
|
|
340
|
+
return (
|
|
341
|
+
<div ref={containerRef} className={cls("@container/col w-full min-w-0", className)}>
|
|
342
|
+
{content}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
52
346
|
|
|
53
347
|
return (
|
|
54
|
-
<div className={"w-full "
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
348
|
+
<div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
|
|
349
|
+
|
|
350
|
+
{/* `items-start`: without it the cross-axis default stretches the
|
|
351
|
+
column to the scroller's height and its bottom padding lands at
|
|
352
|
+
the bottom of the viewport instead of after the last field. */}
|
|
353
|
+
<div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
|
|
354
|
+
<div className={cls(
|
|
355
|
+
"@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
|
|
356
|
+
openEntityMode === "dialog"
|
|
357
|
+
? "pt-5 pb-8 px-6 sm:px-8"
|
|
358
|
+
: "pt-6 pb-12 px-5 sm:px-8"
|
|
359
|
+
)}>
|
|
360
|
+
{content}
|
|
65
361
|
</div>
|
|
66
|
-
|
|
362
|
+
</div>
|
|
67
363
|
|
|
68
|
-
|
|
364
|
+
{showRail && <FormRail
|
|
365
|
+
fields={layout.sidebar}
|
|
366
|
+
showRecordMeta={layout.showRecordMeta}
|
|
367
|
+
entity={entity as Entity<Record<string, unknown>>}
|
|
368
|
+
renderField={renderField}/>}
|
|
69
369
|
</div>
|
|
70
370
|
);
|
|
71
371
|
}
|
|
@@ -3,10 +3,9 @@ import * as React from "react";
|
|
|
3
3
|
import { Entity } from "@rebasepro/types";
|
|
4
4
|
import { cls, LinkIcon, Skeleton, Tooltip } from "@rebasepro/ui";
|
|
5
5
|
import { useAnalyticsController } from "@rebasepro/app";
|
|
6
|
-
import { getValueInPath } from "@rebasepro/utils";
|
|
7
6
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
8
7
|
|
|
9
|
-
import {
|
|
8
|
+
import { useEntityTitle } from "../hooks/useEntityDisplay";
|
|
10
9
|
import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
|
|
11
10
|
import { useSidePanel } from "../hooks/useSidePanel";
|
|
12
11
|
|
|
@@ -57,14 +56,15 @@ export function InlineEntityPreview({
|
|
|
57
56
|
|
|
58
57
|
const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
59
|
+
// The id is the right fallback *here* specifically: this renders a pointer at
|
|
60
|
+
// another record, and a pointer with no label is still followable if it says
|
|
61
|
+
// which record it points at. A page heading makes the opposite choice.
|
|
62
|
+
const { value: title } = useEntityTitle({
|
|
63
|
+
collection: collection as AdminCollection,
|
|
64
|
+
entity,
|
|
65
|
+
enabled: Boolean(collection)
|
|
66
|
+
});
|
|
67
|
+
const resolvedLabel = label ?? title ?? String(entity.id);
|
|
68
68
|
|
|
69
69
|
const interactive = !disabled && (onClick !== undefined || (includeEntityLink && collection !== undefined));
|
|
70
70
|
|
|
@@ -19,7 +19,7 @@ export interface RebaseLayoutProps {
|
|
|
19
19
|
appBar?: React.ReactNode;
|
|
20
20
|
/** Custom Drawer to override the default. */
|
|
21
21
|
drawer?: React.ReactNode;
|
|
22
|
-
/** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
|
|
22
|
+
/** Expand the drawer while the pointer is over it. On by default; pass `false` to opt out. Not a load-time default — that is `defaultDrawerOpen`. */
|
|
23
23
|
autoOpenDrawer?: boolean;
|
|
24
24
|
/** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
|
|
25
25
|
defaultDrawerOpen?: boolean;
|
|
@@ -52,7 +52,7 @@ export function RebaseLayout(props: RebaseLayoutProps) {
|
|
|
52
52
|
title = "Rebase",
|
|
53
53
|
appBar,
|
|
54
54
|
drawer,
|
|
55
|
-
autoOpenDrawer =
|
|
55
|
+
autoOpenDrawer = true,
|
|
56
56
|
defaultDrawerOpen = false,
|
|
57
57
|
logo,
|
|
58
58
|
devViews = []
|
|
@@ -14,7 +14,7 @@ export interface RebaseShellProps {
|
|
|
14
14
|
/** Top bar. Only rendered if provided — pass `<AppBar/>` for the default one. */
|
|
15
15
|
appBar?: React.ReactNode;
|
|
16
16
|
drawer?: React.ReactNode;
|
|
17
|
-
/** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
|
|
17
|
+
/** Expand the drawer while the pointer is over it. On by default; pass `false` to opt out. Not a load-time default — that is `defaultDrawerOpen`. */
|
|
18
18
|
autoOpenDrawer?: boolean;
|
|
19
19
|
/** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
|
|
20
20
|
defaultDrawerOpen?: boolean;
|
|
@@ -44,7 +44,7 @@ export function RebaseShell(props: RebaseShellProps) {
|
|
|
44
44
|
title = "Rebase",
|
|
45
45
|
appBar,
|
|
46
46
|
drawer,
|
|
47
|
-
autoOpenDrawer =
|
|
47
|
+
autoOpenDrawer = true,
|
|
48
48
|
defaultDrawerOpen = false,
|
|
49
49
|
logo,
|
|
50
50
|
children
|
|
@@ -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);
|