@rebasepro/admin 0.13.0 → 0.13.1-canary.gcd6689e
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-CZZZpYOz.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CZZZpYOz.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CeoHSmUg.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CeoHSmUg.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-CMKFWM2J.js} +4 -4
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-CMKFWM2J.js.map} +1 -1
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/AdditionalFieldValue.d.ts +25 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +29 -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/EditViewBinding.d.ts +7 -1
- package/dist/components/EntityIdentityBar.d.ts +10 -1
- package/dist/components/EntityViewBinding.d.ts +41 -6
- package/dist/components/app/Scaffold.d.ts +4 -3
- package/dist/{export-J79rMoNj.js → export-l19WevKo.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-l19WevKo.js.map} +1 -1
- package/dist/form/components/FieldBlock.d.ts +16 -0
- package/dist/{history-D7L_JlLO.js → history-JL1ZmSKD.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-JL1ZmSKD.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-DNrKrFQV.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-DNrKrFQV.js.map} +1 -1
- package/dist/index.js +79 -15
- package/dist/index.js.map +1 -1
- package/dist/preview/components/BooleanPreview.d.ts +3 -1
- package/dist/routes/RebaseRoute.d.ts +1 -1
- package/dist/types/components/PropertyPreviewProps.d.ts +7 -0
- package/dist/{util-GiwPgxnL.js → util-pQ3YwBve.js} +1022 -641
- package/dist/util-pQ3YwBve.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/EntityCardBinding.tsx +10 -33
- package/src/components/CollectionViewBinding/SlotValue.tsx +52 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
- package/src/components/DetailViewBinding.tsx +71 -54
- package/src/components/EditViewBinding.tsx +51 -33
- package/src/components/EntityIdentityBar.tsx +26 -4
- package/src/components/EntityPreviewBinding.tsx +24 -12
- package/src/components/EntityViewBinding.tsx +237 -39
- package/src/components/InlineEntityPreview.tsx +10 -10
- package/src/components/app/Scaffold.tsx +48 -7
- package/src/form/EntityForm.tsx +6 -24
- package/src/form/components/FieldBlock.tsx +24 -0
- package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
- package/src/hooks/useEntityDisplay.tsx +258 -0
- package/src/hooks/useEntityDisplayTitle.tsx +27 -38
- package/src/preview/PropertyPreview.tsx +1 -1
- package/src/preview/components/BooleanPreview.tsx +5 -2
- package/src/routes/RebaseRoute.tsx +62 -4
- package/src/types/components/PropertyPreviewProps.tsx +8 -0
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
import { useAnalyticsController } from "@rebasepro/app";
|
|
26
26
|
import { IconForView } from "@rebasepro/app";
|
|
27
27
|
import { getPropertyInPath } from "../util/property_utils";
|
|
28
|
-
import { getEntityPreviewKeys
|
|
28
|
+
import { getEntityPreviewKeys } from "../util/previews";
|
|
29
|
+
import { useEntityTitle } from "../hooks/useEntityDisplay";
|
|
29
30
|
import { getValueInPath } from "@rebasepro/utils";
|
|
30
31
|
import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
|
|
31
32
|
import { useSidePanel } from "../hooks/useSidePanel";
|
|
@@ -87,6 +88,13 @@ export function EntityPreviewBindingData({
|
|
|
87
88
|
|
|
88
89
|
const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
|
|
89
90
|
|
|
91
|
+
// Above the `!collection` return, because hooks are. `useEntityTitle`
|
|
92
|
+
// tolerates a missing collection for exactly this reason.
|
|
93
|
+
const title = useEntityTitle({
|
|
94
|
+
collection,
|
|
95
|
+
entity
|
|
96
|
+
});
|
|
97
|
+
|
|
90
98
|
const listProperties = useMemo(() => {
|
|
91
99
|
if (!collection) return [];
|
|
92
100
|
return previewKeys ?? getEntityPreviewKeys(authController, collection, customizationController.propertyConfigs, previewKeys, size === "medium" || size === "large" ? 3 : 2);
|
|
@@ -100,7 +108,7 @@ export function EntityPreviewBindingData({
|
|
|
100
108
|
);
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
const titleProperty = includeTitle ?
|
|
111
|
+
const titleProperty = includeTitle ? title.source?.key : undefined;
|
|
104
112
|
const imagePropertyKey = includeImage ? getEntityImagePreviewPropertyKey(collection) : undefined;
|
|
105
113
|
const imageProperty = imagePropertyKey ? collection.properties[imagePropertyKey] : undefined;
|
|
106
114
|
const ofProp = imageProperty && "of" in imageProperty ? imageProperty.of : undefined;
|
|
@@ -148,20 +156,24 @@ export function EntityPreviewBindingData({
|
|
|
148
156
|
</div>
|
|
149
157
|
: <Skeleton/>)}
|
|
150
158
|
|
|
151
|
-
{
|
|
159
|
+
{/* A title that came from a property keeps that property's own
|
|
160
|
+
rendering — an enum stays its coloured chip, a relation stays
|
|
161
|
+
a link. A computed one is text, because that is all a
|
|
162
|
+
resolver returns. */}
|
|
163
|
+
{includeTitle && (titleProperty || title.value) && (
|
|
152
164
|
<div
|
|
153
165
|
className={"truncate my-0.5 text-sm font-medium text-text-primary dark:text-text-primary-dark"}>
|
|
154
|
-
{
|
|
155
|
-
|
|
166
|
+
{!entity && titleProperty
|
|
167
|
+
? <SkeletonPropertyComponent
|
|
168
|
+
property={getPropertyInPath(collection.properties, titleProperty) as Property}
|
|
169
|
+
size={"medium"}/>
|
|
170
|
+
: titleProperty
|
|
156
171
|
? <PropertyPreview
|
|
157
|
-
propertyKey={titleProperty
|
|
158
|
-
value={
|
|
159
|
-
property={collection.properties
|
|
160
|
-
size={"medium"}/>
|
|
161
|
-
: <SkeletonPropertyComponent
|
|
162
|
-
property={collection.properties[titleProperty as string] as Property}
|
|
172
|
+
propertyKey={titleProperty}
|
|
173
|
+
value={title.source?.value as never}
|
|
174
|
+
property={getPropertyInPath(collection.properties, titleProperty) as Property}
|
|
163
175
|
size={"medium"}/>
|
|
164
|
-
|
|
176
|
+
: title.value}
|
|
165
177
|
</div>
|
|
166
178
|
)}
|
|
167
179
|
|
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Property } from "@rebasepro/types";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
3
|
+
import type { AdditionalFieldDelegateProps, AdminCollection, CustomizationController } 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
|
+
ErrorBoundary,
|
|
12
|
+
ExternalLinkIcon,
|
|
13
|
+
IconButton,
|
|
14
|
+
iconSize,
|
|
15
|
+
paperMixin,
|
|
16
|
+
Tooltip
|
|
17
|
+
} from "@rebasepro/ui";
|
|
18
|
+
import { getFormFieldKeys, resolveFormLayout, useCustomizationController } from "@rebasepro/app";
|
|
19
|
+
import { getValueInPath } from "@rebasepro/utils";
|
|
20
|
+
|
|
21
|
+
import { FieldBlock, spanClass } from "../form/components/FieldBlock";
|
|
22
|
+
import { AdditionalFieldValue } from "./AdditionalFieldValue";
|
|
23
|
+
import { FormSections } from "../form/components/FormSections";
|
|
24
|
+
import { FormRail } from "../form/components/FormRail";
|
|
25
|
+
import { useRailVisible } from "../form/components/useRailVisible";
|
|
26
|
+
import { LabelWithIconAndTooltip } from "../form/components/LabelWithIconAndTooltip";
|
|
27
|
+
import { PropertyPreview } from "../preview";
|
|
28
|
+
|
|
29
|
+
/** Nothing can be in error in a view that cannot be submitted. */
|
|
30
|
+
const NO_ERRORS: ReadonlySet<string> = new Set<string>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The fields the read-only view lays out, in render order.
|
|
34
|
+
*
|
|
35
|
+
* The same set the form uses, minus the `additionalFields` when there is no
|
|
36
|
+
* form context to hand them: those entries are arbitrary components, not
|
|
37
|
+
* values, and one rendered against no context is nothing at all. They are
|
|
38
|
+
* dropped *before* the layout resolves rather than skipped while rendering,
|
|
39
|
+
* because the resolver has by then given each of them a full row — skipping
|
|
40
|
+
* later leaves that row as a hole in the grid.
|
|
41
|
+
*/
|
|
42
|
+
export function readOnlyFieldKeys(collection: AdminCollection, hasFormContext: boolean): string[] {
|
|
43
|
+
const keys = getFormFieldKeys(collection);
|
|
44
|
+
if (hasFormContext) return keys;
|
|
45
|
+
const additional = new Set((collection.additionalFields ?? []).map(field => field.key));
|
|
46
|
+
return keys.filter(key => !additional.has(key));
|
|
47
|
+
}
|
|
9
48
|
|
|
10
49
|
/**
|
|
11
50
|
* @group Components
|
|
@@ -15,57 +54,216 @@ export interface EntityViewBindingProps<M extends Record<string, unknown>> {
|
|
|
15
54
|
collection: AdminCollection<M>;
|
|
16
55
|
path: string;
|
|
17
56
|
className?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Lay the record out as a page: a centred, scrolling column with the
|
|
59
|
+
* metadata rail beside it, exactly as the form does. Off for the inline
|
|
60
|
+
* uses — the delete dialog renders the record inside a box it already owns.
|
|
61
|
+
*/
|
|
62
|
+
asPage?: boolean;
|
|
63
|
+
/** Padding preset, matching the form's. Only read when `asPage`. */
|
|
64
|
+
openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
65
|
+
/**
|
|
66
|
+
* Needed only to render `additionalFields`, which are arbitrary components
|
|
67
|
+
* that take a form context. Without one those entries are left out rather
|
|
68
|
+
* than rendered against a fake context.
|
|
69
|
+
*/
|
|
70
|
+
formContext?: FormContext<M>;
|
|
18
71
|
}
|
|
19
72
|
|
|
20
73
|
/**
|
|
21
74
|
* The read-only rendering of a record's values.
|
|
22
75
|
*
|
|
23
|
-
* This
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
76
|
+
* This is the same layout as {@link EntityForm}: the sections, the grid spans,
|
|
77
|
+
* the labels and the rail all come from the one resolver, so the record does
|
|
78
|
+
* not rearrange itself when you press Edit. Only the controls differ — each
|
|
79
|
+
* field renders its {@link PropertyPreview} instead of an input.
|
|
80
|
+
*
|
|
81
|
+
* It used to be a flat two-column table of every value in the record: no
|
|
82
|
+
* grouping, no widths, a label column sized for short names and a value column
|
|
83
|
+
* that wrapped emails over two lines while the pane beside it stayed empty. A
|
|
84
|
+
* boolean and a markdown body were given the same room.
|
|
85
|
+
*
|
|
86
|
+
* The synthetic `Id` row it used to prepend is gone with the same reasoning the
|
|
87
|
+
* form applies: between that row, the collection's own id property and the
|
|
88
|
+
* `path/id` chip above them, one UUID appeared three times. The id is a
|
|
89
|
+
* copyable chip in the identity bar, and the rail's record block, once each.
|
|
28
90
|
*/
|
|
29
91
|
export function EntityViewBinding<M extends Record<string, unknown>>(
|
|
30
92
|
{
|
|
31
93
|
entity,
|
|
32
94
|
collection,
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
className,
|
|
96
|
+
asPage = false,
|
|
97
|
+
openEntityMode = "full_screen",
|
|
98
|
+
formContext
|
|
35
99
|
}: EntityViewBindingProps<M>) {
|
|
36
100
|
|
|
37
101
|
const customizationController: CustomizationController = useCustomizationController();
|
|
38
|
-
|
|
39
|
-
// A single surrogate id is the chip in the identity bar, so listing it again
|
|
40
|
-
// as the first row here is the duplication the form already removed.
|
|
41
|
-
//
|
|
42
|
-
// A composite key is different: `entity.id` is then a synthesised address
|
|
43
|
-
// (`a:::b`), and the real key columns are data — on a junction row they say
|
|
44
|
-
// which two records this joins. Those stay in the table.
|
|
45
|
-
const properties: Properties = React.useMemo(() => {
|
|
46
|
-
const entries = Object.entries(collection.properties ?? {});
|
|
47
|
-
const idKeys = entries.filter(([, p]) => p && "isId" in p && p.isId);
|
|
48
|
-
if (idKeys.length !== 1) return collection.properties;
|
|
49
|
-
return Object.fromEntries(entries.filter(([key]) => key !== idKeys[0][0])) as Properties;
|
|
50
|
-
}, [collection.properties]);
|
|
51
102
|
const externalLink = customizationController?.entityLinkBuilder?.({ entity });
|
|
52
103
|
|
|
104
|
+
// Only *whether* there is a form context changes the field list, not which
|
|
105
|
+
// one it is — so the memo turns on the boolean rather than on the context
|
|
106
|
+
// object, which is rebuilt on every keystroke of the form above it.
|
|
107
|
+
const hasFormContext = Boolean(formContext);
|
|
108
|
+
const fieldKeys = useMemo(
|
|
109
|
+
() => readOnlyFieldKeys(collection as AdminCollection, hasFormContext),
|
|
110
|
+
[collection, hasFormContext]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// `status: "existing"` — a record you are reading exists by definition, and
|
|
114
|
+
// that is what routes a generated id and the audit timestamps out of the
|
|
115
|
+
// column and into the record block.
|
|
116
|
+
const layout = useMemo(() => resolveFormLayout({
|
|
117
|
+
collection,
|
|
118
|
+
fieldKeys,
|
|
119
|
+
status: "existing"
|
|
120
|
+
}), [collection, fieldKeys]);
|
|
121
|
+
|
|
122
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
123
|
+
// Measured, not a breakpoint — see `useRailVisible`. The same reasoning as
|
|
124
|
+
// the form's: the resolver *moves* fields into the rail, so when the rail
|
|
125
|
+
// cannot be shown they have to fold back into the column.
|
|
126
|
+
const railVisible = useRailVisible(containerRef);
|
|
127
|
+
const showRail = asPage && layout.hasRail && railVisible;
|
|
128
|
+
|
|
129
|
+
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
|
|
130
|
+
() => new Set(layout.sections.filter(section => section.collapsed).map(section => section.key))
|
|
131
|
+
);
|
|
132
|
+
const toggleSection = useCallback((key: string) => {
|
|
133
|
+
setCollapsedSections(prev => {
|
|
134
|
+
const next = new Set(prev);
|
|
135
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const renderField = useCallback((field: ResolvedFormField): React.ReactNode => {
|
|
141
|
+
|
|
142
|
+
if (field.additional) {
|
|
143
|
+
const additionalField = collection.additionalFields?.find(f => f.key === field.key);
|
|
144
|
+
if (!additionalField || !formContext) return null;
|
|
145
|
+
|
|
146
|
+
const AdditionalFieldBuilder = additionalField.Builder;
|
|
147
|
+
const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
|
|
148
|
+
const child = AdditionalFieldBuilder
|
|
149
|
+
? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
|
|
150
|
+
: <div className={"w-full"}>
|
|
151
|
+
<AdditionalFieldValue
|
|
152
|
+
field={additionalField}
|
|
153
|
+
entity={entity}
|
|
154
|
+
context={additionalFieldContext}/>
|
|
155
|
+
</div>;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div key={`additional_${field.key}`} className={spanClass(field.span)}>
|
|
159
|
+
<LabelWithIconAndTooltip
|
|
160
|
+
propertyKey={field.key}
|
|
161
|
+
icon={<AlignLeftIcon size={iconSize.small}/>}
|
|
162
|
+
title={additionalField.name}
|
|
163
|
+
className={"text-text-secondary dark:text-text-secondary-dark"}/>
|
|
164
|
+
<div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
|
|
165
|
+
<ErrorBoundary>
|
|
166
|
+
{child}
|
|
167
|
+
</ErrorBoundary>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const property = collection.properties?.[field.key] as Property | undefined;
|
|
174
|
+
if (!property) return null;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div key={`field_${field.key}`} className={spanClass(field.span)}>
|
|
178
|
+
{/* Always labelled from here, unlike the form. The panel editors
|
|
179
|
+
that carry their own header have no header in preview — a map
|
|
180
|
+
previews as a table of its children and an array as its items,
|
|
181
|
+
so leaving the label to them would leave them unlabelled. */}
|
|
182
|
+
<FieldBlock propertyKey={field.key}
|
|
183
|
+
property={property}
|
|
184
|
+
showLabel={true}>
|
|
185
|
+
{/* One row's height for a single-line value, so a row of
|
|
186
|
+
short fields lines up with the taller ones beside it, and
|
|
187
|
+
anything longer simply grows. */}
|
|
188
|
+
<div className={"min-h-8 flex flex-col justify-center min-w-0 text-text-primary dark:text-text-primary-dark"}>
|
|
189
|
+
<PropertyPreview propertyKey={field.key}
|
|
190
|
+
value={getValueInPath(entity.values, field.key)}
|
|
191
|
+
property={property}
|
|
192
|
+
hideLabel
|
|
193
|
+
size={"medium"}/>
|
|
194
|
+
</div>
|
|
195
|
+
</FieldBlock>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}, [collection, entity, formContext]);
|
|
199
|
+
|
|
200
|
+
// Same fold-back as the form: with no room for the rail its fields become a
|
|
201
|
+
// trailing group rather than disappearing.
|
|
202
|
+
const sections: ResolvedFormSection[] = showRail || !layout.sidebar.length
|
|
203
|
+
? layout.sections
|
|
204
|
+
: [
|
|
205
|
+
...layout.sections,
|
|
206
|
+
{
|
|
207
|
+
key: "__rail",
|
|
208
|
+
title: "Settings",
|
|
209
|
+
collapsible: false,
|
|
210
|
+
collapsed: false,
|
|
211
|
+
fields: layout.sidebar
|
|
212
|
+
}
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const content = <>
|
|
216
|
+
{externalLink && (
|
|
217
|
+
<div className={"flex justify-end mb-2"}>
|
|
218
|
+
<Tooltip title={"Open in the live site"}>
|
|
219
|
+
<a href={externalLink} rel={"noopener noreferrer"} target={"_blank"}>
|
|
220
|
+
<IconButton size={"small"}>
|
|
221
|
+
<ExternalLinkIcon/>
|
|
222
|
+
</IconButton>
|
|
223
|
+
</a>
|
|
224
|
+
</Tooltip>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
<FormSections sections={sections}
|
|
229
|
+
collapsed={collapsedSections}
|
|
230
|
+
onToggle={toggleSection}
|
|
231
|
+
errorKeys={NO_ERRORS}
|
|
232
|
+
renderField={renderField}/>
|
|
233
|
+
</>;
|
|
234
|
+
|
|
235
|
+
if (!asPage) {
|
|
236
|
+
// `@container/col` even here: the spans resolve against the column they
|
|
237
|
+
// sit in, and inside a dialog that column is the dialog.
|
|
238
|
+
return (
|
|
239
|
+
<div ref={containerRef} className={cls("@container/col w-full min-w-0", className)}>
|
|
240
|
+
{content}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
53
245
|
return (
|
|
54
|
-
<div className={"w-full "
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
246
|
+
<div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
|
|
247
|
+
|
|
248
|
+
{/* `items-start`: without it the cross-axis default stretches the
|
|
249
|
+
column to the scroller's height and its bottom padding lands at
|
|
250
|
+
the bottom of the viewport instead of after the last field. */}
|
|
251
|
+
<div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
|
|
252
|
+
<div className={cls(
|
|
253
|
+
"@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
|
|
254
|
+
openEntityMode === "dialog"
|
|
255
|
+
? "pt-5 pb-8 px-6 sm:px-8"
|
|
256
|
+
: "pt-6 pb-12 px-5 sm:px-8"
|
|
257
|
+
)}>
|
|
258
|
+
{content}
|
|
65
259
|
</div>
|
|
66
|
-
|
|
260
|
+
</div>
|
|
67
261
|
|
|
68
|
-
|
|
262
|
+
{showRail && <FormRail
|
|
263
|
+
fields={layout.sidebar}
|
|
264
|
+
showRecordMeta={layout.showRecordMeta}
|
|
265
|
+
entity={entity as Entity<Record<string, unknown>>}
|
|
266
|
+
renderField={renderField}/>}
|
|
69
267
|
</div>
|
|
70
268
|
);
|
|
71
269
|
}
|
|
@@ -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
|
|
|
@@ -8,13 +8,37 @@ import { AppContext } from "./useApp";
|
|
|
8
8
|
|
|
9
9
|
export const DRAWER_WIDTH = 280;
|
|
10
10
|
|
|
11
|
+
const DRAWER_OPEN_STORAGE_KEY = "rebase-drawer-open";
|
|
12
|
+
|
|
13
|
+
/** `null` when the user has never toggled the drawer, or storage is unavailable. */
|
|
14
|
+
function readStoredDrawerOpen(): boolean | null {
|
|
15
|
+
if (typeof window === "undefined") return null;
|
|
16
|
+
try {
|
|
17
|
+
const stored = window.localStorage.getItem(DRAWER_OPEN_STORAGE_KEY);
|
|
18
|
+
return stored === null ? null : stored === "true";
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// Storage can be blocked: private mode, sandboxed iframe, cookie policy.
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeStoredDrawerOpen(open: boolean) {
|
|
26
|
+
if (typeof window === "undefined") return;
|
|
27
|
+
try {
|
|
28
|
+
window.localStorage.setItem(DRAWER_OPEN_STORAGE_KEY, String(open));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
// Same as above: a drawer that forgets is better than a crash.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/**
|
|
12
35
|
* @group Core
|
|
13
36
|
*/
|
|
14
37
|
export interface ScaffoldProps {
|
|
15
38
|
|
|
16
39
|
/**
|
|
17
|
-
*
|
|
40
|
+
* Expand the collapsed drawer while the pointer is over it. Off by default:
|
|
41
|
+
* the rail stays a rail until the user clicks the toggle.
|
|
18
42
|
*/
|
|
19
43
|
autoOpenDrawer?: boolean;
|
|
20
44
|
|
|
@@ -22,8 +46,8 @@ export interface ScaffoldProps {
|
|
|
22
46
|
* Start with the drawer expanded rather than collapsed to icons.
|
|
23
47
|
*
|
|
24
48
|
* Distinct from {@link autoOpenDrawer}, which is a hover behaviour: this one
|
|
25
|
-
* only seeds the
|
|
26
|
-
*
|
|
49
|
+
* only seeds the very first visit. Once the user toggles the drawer, that
|
|
50
|
+
* choice is stored in `localStorage` and wins over this prop on every load.
|
|
27
51
|
*
|
|
28
52
|
* Ignored on small layouts, where an expanded drawer covers the content it is
|
|
29
53
|
* meant to navigate.
|
|
@@ -85,10 +109,27 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
|
|
|
85
109
|
|
|
86
110
|
// Seeded once, deliberately: a `useEffect` syncing this to the prop would
|
|
87
111
|
// re-expand the drawer under a user who had just collapsed it.
|
|
88
|
-
|
|
112
|
+
// The last toggle wins over `defaultDrawerOpen`, which only seeds the very
|
|
113
|
+
// first visit — after that the stored choice survives reloads.
|
|
114
|
+
const [drawerOpen, setDrawerOpenState] = React.useState(
|
|
115
|
+
() => (readStoredDrawerOpen() ?? defaultDrawerOpen) && largeLayout
|
|
116
|
+
);
|
|
89
117
|
const [onHover, setOnHover] = React.useState(false);
|
|
90
118
|
|
|
91
|
-
const
|
|
119
|
+
const setDrawerOpen = useCallback((open: boolean) => {
|
|
120
|
+
setDrawerOpenState(open);
|
|
121
|
+
// Small layouts render the drawer as a modal sheet. Remembering that
|
|
122
|
+
// one would greet the next load with an overlay over the content.
|
|
123
|
+
if (!largeLayout) return;
|
|
124
|
+
writeStoredDrawerOpen(open);
|
|
125
|
+
}, [largeLayout]);
|
|
126
|
+
|
|
127
|
+
const setOnHoverTrue = useCallback(() => {
|
|
128
|
+
// Hover expansion is opt-in. Without this the rail pops open whenever
|
|
129
|
+
// the pointer crosses it, which reads as "the drawer is open again".
|
|
130
|
+
if (!autoOpenDrawer) return;
|
|
131
|
+
setOnHover(true);
|
|
132
|
+
}, [autoOpenDrawer]);
|
|
92
133
|
const setOnHoverFalse = useCallback(() => {
|
|
93
134
|
// Don't collapse the drawer while a popover/dropdown is open
|
|
94
135
|
if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
|
|
@@ -97,11 +138,11 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
|
|
|
97
138
|
|
|
98
139
|
const handleDrawerOpen = useCallback(() => {
|
|
99
140
|
setDrawerOpen(true);
|
|
100
|
-
}, []);
|
|
141
|
+
}, [setDrawerOpen]);
|
|
101
142
|
|
|
102
143
|
const handleDrawerClose = useCallback(() => {
|
|
103
144
|
setDrawerOpen(false);
|
|
104
|
-
}, []);
|
|
145
|
+
}, [setDrawerOpen]);
|
|
105
146
|
|
|
106
147
|
const computedDrawerOpen: boolean = drawerOpen;
|
|
107
148
|
const computedDrawerHovered = Boolean(largeLayout && onHover);
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -16,7 +16,8 @@ import type { ResolvedFormField } from "@rebasepro/app";
|
|
|
16
16
|
import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Typography } from "@rebasepro/ui";
|
|
17
17
|
import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
|
|
18
18
|
|
|
19
|
-
import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
|
|
19
|
+
import { FieldBlock, isSelfLabellingProperty, spanClass } from "./components/FieldBlock";
|
|
20
|
+
import { AdditionalFieldValue } from "../components/AdditionalFieldValue";
|
|
20
21
|
import { FormRail } from "./components/FormRail";
|
|
21
22
|
import { FormSections } from "./components/FormSections";
|
|
22
23
|
import { useRailVisible } from "./components/useRailVisible";
|
|
@@ -440,10 +441,10 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
440
441
|
const child = AdditionalFieldBuilder
|
|
441
442
|
? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
|
|
442
443
|
: <div className={"w-full"}>
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
<AdditionalFieldValue
|
|
445
|
+
field={additionalField}
|
|
446
|
+
entity={entity}
|
|
447
|
+
context={additionalFieldContext}/>
|
|
447
448
|
</div>;
|
|
448
449
|
|
|
449
450
|
return (
|
|
@@ -706,25 +707,6 @@ export function EntityForm<M extends Record<string, unknown>>({
|
|
|
706
707
|
);
|
|
707
708
|
}
|
|
708
709
|
|
|
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
710
|
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
711
|
useEffect(() => {
|
|
730
712
|
if (!autoSave) return;
|
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import type { Property } from "@rebasepro/types";
|
|
2
|
+
import type { PropertySpan } from "@rebasepro/admin-types";
|
|
2
3
|
import React from "react";
|
|
3
4
|
import { cls, ErrorBoundary, Typography } from "@rebasepro/ui";
|
|
4
5
|
import { getIconForProperty } from "../../util/property_utils";
|
|
5
6
|
import { PropertyIdCopyTooltip } from "../../components/PropertyIdCopyTooltip";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Grid placement for a span.
|
|
10
|
+
*
|
|
11
|
+
* Written out as literal class strings rather than composed, because Tailwind
|
|
12
|
+
* scans source text: a template literal would produce classes that exist at
|
|
13
|
+
* runtime and were never generated into the stylesheet.
|
|
14
|
+
*
|
|
15
|
+
* Below the column breakpoint every field takes the single available column, so
|
|
16
|
+
* the side panel, the dialog and mobile all get a plain stack.
|
|
17
|
+
*
|
|
18
|
+
* Lives here rather than in the form, because the read-only rendering of a
|
|
19
|
+
* record places its fields on the same grid — a record that changes shape when
|
|
20
|
+
* you press Edit is the bug this whole layout exists to avoid.
|
|
21
|
+
*/
|
|
22
|
+
export function spanClass(span: PropertySpan): string {
|
|
23
|
+
switch (span) {
|
|
24
|
+
case 1: return "@2xl/col:col-span-1";
|
|
25
|
+
case 2: return "@2xl/col:col-span-2";
|
|
26
|
+
case 3: return "@2xl/col:col-span-3";
|
|
27
|
+
case 4: return "@2xl/col:col-span-4";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
/**
|
|
8
32
|
* Does this property's editor supply its own heading?
|
|
9
33
|
*
|
|
@@ -41,11 +41,35 @@ export function useBuildCollectionRegistryController(props: {
|
|
|
41
41
|
if (!collectionPath) return undefined;
|
|
42
42
|
|
|
43
43
|
let collection: AdminCollection | undefined;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
|
|
45
|
+
// An exact slug match first, because a slug is allowed to contain
|
|
46
|
+
// slashes and several drivers need it to: a Firestore collection
|
|
47
|
+
// partitioned by locale is declared as `content/de-DE/podcasts`, and
|
|
48
|
+
// that string is its *name*, not a path to walk.
|
|
49
|
+
//
|
|
50
|
+
// Without this, such a slug went straight to `resolvePathToCollections`,
|
|
51
|
+
// which reads any `a/b/c` as collection/entity/subcollection, looked for
|
|
52
|
+
// a root collection called `content`, found none and threw — and the
|
|
53
|
+
// catch below turned that into `undefined`. `RebaseRoute` renders
|
|
54
|
+
// `null` for an unresolved collection, so the whole view came out blank
|
|
55
|
+
// with nothing in the console above `debug`. Thirty-five collections in
|
|
56
|
+
// one app were unreachable that way.
|
|
57
|
+
// `collectionPath`, not `cleanedPath`: an even segment count means the
|
|
58
|
+
// path ends at a record, and the id has already been trimmed off above.
|
|
59
|
+
// Matching the raw path instead would only ever hit for a collection
|
|
60
|
+
// route, leaving `content/de-DE/podcasts/abc123` back on the walker.
|
|
61
|
+
collection = registry.get(collectionPath) as AdminCollection | undefined;
|
|
62
|
+
|
|
63
|
+
if (!collection) {
|
|
64
|
+
try {
|
|
65
|
+
collection = registry.resolvePathToCollections(collectionPath).finalCollection;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Warn, not debug. An unresolved collection ends as a route with
|
|
68
|
+
// nothing in it, and at `debug` there was no way to tell that
|
|
69
|
+
// from a collection that legitimately has no rows.
|
|
70
|
+
console.warn(`[rebase] Could not resolve a collection for "${collectionPath}"`, e);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
if (!collection) {
|