@rebasepro/admin 0.13.0 → 0.13.1-canary.g18cfeb7
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-CUFXevVg.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CUFXevVg.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DDUC7giL.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-DDUC7giL.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-vzS848Gf.js} +9 -13
- package/dist/RouterCollectionsStudioView-vzS848Gf.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/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-5bl2EoSs.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-5bl2EoSs.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-BuKKAhoY.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-BuKKAhoY.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-BhdWs34H.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-BhdWs34H.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-DNG7iK0w.js} +3756 -2300
- package/dist/util-DNG7iK0w.js.map +1 -0
- package/package.json +9 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- 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 +723 -348
- 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 +183 -64
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +19 -23
- package/src/components/DetailViewBinding.tsx +93 -56
- package/src/components/DrawerNavigationGroup.tsx +16 -39
- package/src/components/DrawerNavigationItem.tsx +44 -16
- package/src/components/EditViewBinding.tsx +215 -42
- package/src/components/EntityDisplayHeader.tsx +170 -0
- package/src/components/EntityIdentityBar.tsx +163 -23
- package/src/components/EntityInspector.tsx +24 -6
- 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/history/LastEditedByIndicator.tsx +7 -12
- package/src/components/index.ts +3 -0
- package/src/data_import/utils/data.ts +9 -1
- 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/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -128,27 +128,24 @@ export function GeopointFieldBinding({
|
|
|
128
128
|
: undefined;
|
|
129
129
|
|
|
130
130
|
return (
|
|
131
|
-
<div className="flex flex-col gap-2 w-full
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
<div className="flex flex-col gap-2 w-full">
|
|
132
|
+
{/* Label only, and only when there is one: a row that survives
|
|
133
|
+
`hideLabel` still spends its `mb-1` and the column's `gap-2`, so
|
|
134
|
+
the pair of inputs sat below the control next to them even
|
|
135
|
+
though the two labels were level. */}
|
|
136
|
+
{!hideLabel && <div className="flex items-center justify-between mb-1">
|
|
137
|
+
<LabelWithIcon
|
|
134
138
|
icon={getIconForProperty(property, "small")}
|
|
135
139
|
required={property.validation?.required}
|
|
136
140
|
title={property.name ?? propertyKey}
|
|
137
|
-
/>
|
|
138
|
-
|
|
139
|
-
<IconButton
|
|
140
|
-
size="small"
|
|
141
|
-
onClick={handleClear}
|
|
142
|
-
className="text-text-secondary hover:text-red-500"
|
|
143
|
-
aria-label="Clear location"
|
|
144
|
-
>
|
|
145
|
-
<Trash2Icon size={14}/>
|
|
146
|
-
</IconButton>
|
|
147
|
-
)}
|
|
148
|
-
</div>
|
|
141
|
+
/>
|
|
142
|
+
</div>}
|
|
149
143
|
|
|
150
144
|
<PropertyIdCopyTooltip propertyKey={propertyKey}>
|
|
151
|
-
|
|
145
|
+
{/* Clear sits beside the inputs rather than above them, so
|
|
146
|
+
entering a location does not grow the field by a row and
|
|
147
|
+
knock it out of line with its neighbour. */}
|
|
148
|
+
<div className="flex gap-4 w-full items-center">
|
|
152
149
|
<TextField
|
|
153
150
|
className="flex-1"
|
|
154
151
|
size={size}
|
|
@@ -178,6 +175,16 @@ export function GeopointFieldBinding({
|
|
|
178
175
|
placeholder="e.g. 2.1686"
|
|
179
176
|
inputClassName="font-mono"
|
|
180
177
|
/>
|
|
178
|
+
{!disabled && (latText || lngText) && (
|
|
179
|
+
<IconButton
|
|
180
|
+
size="small"
|
|
181
|
+
onClick={handleClear}
|
|
182
|
+
className="shrink-0 text-text-secondary hover:text-red-500"
|
|
183
|
+
aria-label="Clear location"
|
|
184
|
+
>
|
|
185
|
+
<Trash2Icon size={14}/>
|
|
186
|
+
</IconButton>
|
|
187
|
+
)}
|
|
181
188
|
</div>
|
|
182
189
|
</PropertyIdCopyTooltip>
|
|
183
190
|
|
|
@@ -6,7 +6,7 @@ import { useTranslation } from "@rebasepro/app";
|
|
|
6
6
|
|
|
7
7
|
import { ErrorBoundary } from "@rebasepro/ui";
|
|
8
8
|
import { getIconForProperty } from "../../util/property_utils";
|
|
9
|
-
import { isHidden, isReadOnly } from "@rebasepro/app";
|
|
9
|
+
import { isDisabled, isHidden, isReadOnly } from "@rebasepro/app";
|
|
10
10
|
import { FieldHelperText } from "../components/FieldHelperText";
|
|
11
11
|
import { LabelWithIconAndTooltip } from "../components/LabelWithIconAndTooltip";
|
|
12
12
|
import { PropertyFieldBinding } from "../PropertyFieldBinding";
|
|
@@ -50,7 +50,7 @@ export function MapFieldBinding({
|
|
|
50
50
|
{Object.entries(mapProperties)
|
|
51
51
|
.filter(([_, property]) => !isHidden(property))
|
|
52
52
|
.map(([entryKey, childProperty], index) => {
|
|
53
|
-
const thisDisabled = isReadOnly(childProperty) ||
|
|
53
|
+
const thisDisabled = isReadOnly(childProperty) || isDisabled(childProperty);
|
|
54
54
|
const fieldBindingProps: PropertyFieldBindingProps<Record<string, unknown>> = {
|
|
55
55
|
propertyKey: `${propertyKey}.${entryKey}`,
|
|
56
56
|
disabled: disabled || thisDisabled,
|
|
@@ -216,17 +216,26 @@ export function MarkdownEditorFieldBinding({
|
|
|
216
216
|
|
|
217
217
|
return (
|
|
218
218
|
<>
|
|
219
|
-
<div className="flex items-center w-full">
|
|
220
|
-
|
|
219
|
+
{!hideLabel && <div className="flex items-center w-full">
|
|
220
|
+
<LabelWithIconAndTooltip
|
|
221
221
|
propertyKey={propertyKey}
|
|
222
222
|
icon={getIconForProperty(property, "small")}
|
|
223
223
|
required={property.validation?.required}
|
|
224
224
|
title={property.name ?? propertyKey}
|
|
225
|
-
className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
|
|
226
|
-
|
|
225
|
+
className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
|
|
226
|
+
</div>}
|
|
227
|
+
{/* `relative` so Clear can float inside the box. It used to sit in
|
|
228
|
+
the label row, which meant a clearable field with its label
|
|
229
|
+
lifted out by the form still drew a row of chrome above the box
|
|
230
|
+
and started lower than the field beside it.
|
|
231
|
+
Bottom-right, not top: the editor puts its own mode toggle at
|
|
232
|
+
`top-2 right-2` and the two would sit on each other. */}
|
|
233
|
+
<div
|
|
234
|
+
className={cls("relative rounded-md", fieldBackgroundMixin, disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin)}>
|
|
227
235
|
{property.admin?.clearable && !disabled && (
|
|
228
236
|
<IconButton
|
|
229
237
|
size="small"
|
|
238
|
+
className="absolute bottom-1 right-1 z-10"
|
|
230
239
|
onClick={(e) => {
|
|
231
240
|
e.stopPropagation();
|
|
232
241
|
e.preventDefault();
|
|
@@ -236,9 +245,6 @@ export function MarkdownEditorFieldBinding({
|
|
|
236
245
|
<XIcon/>
|
|
237
246
|
</IconButton>
|
|
238
247
|
)}
|
|
239
|
-
</div>
|
|
240
|
-
<div
|
|
241
|
-
className={cls("rounded-md", fieldBackgroundMixin, disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin)}>
|
|
242
248
|
{editor}
|
|
243
249
|
</div>
|
|
244
250
|
<FieldHelperText includeDescription={includeDescription}
|
|
@@ -87,7 +87,13 @@ export function MultiSelectFieldBinding({
|
|
|
87
87
|
return (
|
|
88
88
|
<>
|
|
89
89
|
<MultiSelect
|
|
90
|
-
className
|
|
90
|
+
// No top margin: `className` lands on the box itself, not on
|
|
91
|
+
// the label above it, so `mt-2` was 8px of gap between the
|
|
92
|
+
// form's label and this control and none between the label and
|
|
93
|
+
// the text field beside it. A binding renders a control, not a
|
|
94
|
+
// labelled control — the top of what it renders is the top of
|
|
95
|
+
// the box, or it will not line up with its neighbour.
|
|
96
|
+
className={"w-full"}
|
|
91
97
|
size={size}
|
|
92
98
|
value={validValue ? value.map((v) => v?.toString()) : []}
|
|
93
99
|
disabled={disabled}
|
|
@@ -53,7 +53,13 @@ export function ReadOnlyFieldBinding({
|
|
|
53
53
|
"w-full overflow-x-scroll no-scrollbar flex items-center",
|
|
54
54
|
skipCardWrapper
|
|
55
55
|
? ""
|
|
56
|
-
|
|
56
|
+
// `min-h-12` is `TextField`'s own `large` height, which is
|
|
57
|
+
// what every editable field in the form is. At 40px a
|
|
58
|
+
// read-only field sat 8px short of the input beside it,
|
|
59
|
+
// so a row holding one of each had its two boxes'
|
|
60
|
+
// baselines and bottom edges disagree — the field looked
|
|
61
|
+
// misaligned rather than uneditable.
|
|
62
|
+
: "rounded-lg border border-surface-200 dark:border-surface-700 px-4 md:px-6 min-h-12 opacity-80"
|
|
57
63
|
)}>
|
|
58
64
|
|
|
59
65
|
<ErrorBoundary>
|
|
@@ -121,7 +121,7 @@ function ReferenceFieldBindingInternal({
|
|
|
121
121
|
/>}
|
|
122
122
|
|
|
123
123
|
{!refValue && <div className="justify-center text-left">
|
|
124
|
-
<EntityPreviewContainer className={cls("px-6 h-
|
|
124
|
+
<EntityPreviewContainer className={cls("px-6 h-12 text-sm font-medium flex items-center gap-6",
|
|
125
125
|
disabled || isSubmitting
|
|
126
126
|
? "text-surface-accent-500"
|
|
127
127
|
: "cursor-pointer text-surface-accent-700 dark:text-surface-accent-300 hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800")}
|
|
@@ -72,7 +72,10 @@ function RelationSelectorBinding({
|
|
|
72
72
|
const singleValue = normalizedSingle ?? null;
|
|
73
73
|
const multipleValue = (manyRelation && Array.isArray(value)) ? value : [];
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// Passed straight through. Mapping the form's `large` onto the selector's
|
|
76
|
+
// `medium` produced a 56px box next to 48px text fields — the labels lined
|
|
77
|
+
// up and the boxes did not.
|
|
78
|
+
const selectorSize = size;
|
|
76
79
|
|
|
77
80
|
return (
|
|
78
81
|
<div className="">
|
|
@@ -176,7 +179,7 @@ function SingleRelationFieldBinding({
|
|
|
176
179
|
/>}
|
|
177
180
|
|
|
178
181
|
{!value && <div className="justify-center text-left">
|
|
179
|
-
<EntityPreviewContainer className={cls("px-6 h-
|
|
182
|
+
<EntityPreviewContainer className={cls("px-6 h-12 text-sm font-medium flex items-center gap-6",
|
|
180
183
|
disabled || isSubmitting
|
|
181
184
|
? "text-surface-accent-500"
|
|
182
185
|
: "cursor-pointer text-surface-accent-700 dark:text-surface-accent-300 hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800")}
|
|
@@ -11,7 +11,7 @@ import { getIconForProperty } from "../../util/property_utils";
|
|
|
11
11
|
import { PropertyFieldBinding } from "../PropertyFieldBinding";
|
|
12
12
|
import { ExpandablePanel, Typography } from "@rebasepro/ui";
|
|
13
13
|
import { useClearRestoreValue } from "../useClearRestoreValue";
|
|
14
|
-
import { useAuthController, useTranslation } from "@rebasepro/app";
|
|
14
|
+
import { isDisabled, useAuthController, useTranslation } from "@rebasepro/app";
|
|
15
15
|
import { mergeDeep } from "@rebasepro/utils";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -96,14 +96,14 @@ export function RepeatFieldBinding({
|
|
|
96
96
|
</ErrorBoundary>;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
const canAddElements = !property
|
|
99
|
+
const canAddElements = !isDisabled(property) && !isSubmitting && !disabled && (property.admin?.canAddElements || property.admin?.canAddElements === undefined);
|
|
100
100
|
const sortable = property.admin?.sortable === undefined ? true : property.admin.sortable;
|
|
101
101
|
const arrayContainer = <ArrayContainer droppableId={propertyKey}
|
|
102
102
|
addLabel={property.name ? t("add_to_field", { fieldName: property.name }) : t("add_entry")}
|
|
103
103
|
value={(value as unknown[]) ?? []}
|
|
104
104
|
buildEntry={buildEntry}
|
|
105
105
|
onInternalIdAdded={setLastAddedId}
|
|
106
|
-
disabled={isSubmitting ||
|
|
106
|
+
disabled={isSubmitting || isDisabled(property)}
|
|
107
107
|
canAddElements={canAddElements}
|
|
108
108
|
sortable={sortable}
|
|
109
109
|
newDefaultEntry={getDefaultValueFor(property.of)}
|
|
@@ -8,7 +8,7 @@ import { useDropzone } from "react-dropzone";
|
|
|
8
8
|
import { FieldHelperText } from "../components/FieldHelperText";
|
|
9
9
|
import { LabelWithIconAndTooltip } from "../components/LabelWithIconAndTooltip";
|
|
10
10
|
|
|
11
|
-
import { isReadOnly } from "@rebasepro/app";
|
|
11
|
+
import { isDisabled, isReadOnly } from "@rebasepro/app";
|
|
12
12
|
import { getIconForProperty } from "../../util/property_utils";
|
|
13
13
|
import { useAuthController, useSnackbarController, useStorageSource, useTranslation } from "@rebasepro/app";
|
|
14
14
|
import { StorageSourceContext } from "@rebasepro/app";
|
|
@@ -74,7 +74,7 @@ export function StorageUploadFieldBinding({
|
|
|
74
74
|
const authController = useAuthController();
|
|
75
75
|
|
|
76
76
|
const storageSource = useStorageSource();
|
|
77
|
-
const disabled = isReadOnly(property) ||
|
|
77
|
+
const disabled = isReadOnly(property) || isDisabled(property) || isSubmitting || context.disabled;
|
|
78
78
|
|
|
79
79
|
const {
|
|
80
80
|
internalValue,
|
|
@@ -34,7 +34,10 @@ export function UserSelectFieldBinding({
|
|
|
34
34
|
size = "large"
|
|
35
35
|
}: UserSelectProps) {
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Passed straight through. Mapping the form's `large` onto the selector's
|
|
38
|
+
// `medium` produced a 56px box next to 48px text fields — the labels lined
|
|
39
|
+
// up and the boxes did not.
|
|
40
|
+
const selectorSize = size;
|
|
38
41
|
|
|
39
42
|
return (
|
|
40
43
|
<>
|
|
@@ -98,11 +98,13 @@ export function VectorFieldBinding({
|
|
|
98
98
|
const isPopulated = arrayValue.length > 0;
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
|
-
<div className="flex flex-col gap-2 w-full
|
|
102
|
-
{/*
|
|
103
|
-
|
|
101
|
+
<div className="flex flex-col gap-2 w-full">
|
|
102
|
+
{/* Only when there is a label to put in it — an empty row still
|
|
103
|
+
spends its `mb-1` and the column's `gap-2`, and the control ends
|
|
104
|
+
up below the one beside it under a label at the same height. */}
|
|
105
|
+
{label && <div className="flex items-center justify-between mb-1">
|
|
104
106
|
{label}
|
|
105
|
-
</div>
|
|
107
|
+
</div>}
|
|
106
108
|
|
|
107
109
|
<PropertyIdCopyTooltip propertyKey={propertyKey}>
|
|
108
110
|
<div className="w-full">
|
package/src/form/form_utils.ts
CHANGED
|
@@ -114,6 +114,84 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
|
|
|
114
114
|
return changes;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* What the local-changes backup still has to offer, given what the form is
|
|
119
|
+
* already showing — the answer the "unsaved local changes" banner is asking for.
|
|
120
|
+
* `undefined` means there is nothing left to apply, and no banner.
|
|
121
|
+
*
|
|
122
|
+
* Measured against what the form *opens showing*, not against the stored
|
|
123
|
+
* record. The banner exists to offer a draft the form is not already displaying,
|
|
124
|
+
* and an edit carried across a change of layout is in both the backup and the
|
|
125
|
+
* form: measuring against the stored values alone offered to restore the values
|
|
126
|
+
* already on screen, over an edit the user had never left.
|
|
127
|
+
*/
|
|
128
|
+
export function getUnappliedLocalChanges<M extends Record<string, unknown>>(
|
|
129
|
+
backupValues: Partial<M>,
|
|
130
|
+
openingValues: Partial<M>
|
|
131
|
+
): Partial<M> | undefined {
|
|
132
|
+
const changes = getChanges(backupValues, openingValues);
|
|
133
|
+
const cleaned = removeEmptyContainers(changes);
|
|
134
|
+
if (!cleaned || typeof cleaned !== "object" || Object.keys(cleaned).length === 0) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return cleaned as Partial<M>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What travels when the same record changes layout — the split's "hide list",
|
|
142
|
+
* full screen's "show list", the side panel's "open full screen". One mounted
|
|
143
|
+
* form is replaced by another showing the same record, so the edit in progress
|
|
144
|
+
* has to be handed over; left to the local-changes backup alone, the new form
|
|
145
|
+
* met it as a draft from a closed tab and raised the "unsaved local changes"
|
|
146
|
+
* banner over changes the user had made a second earlier and never left.
|
|
147
|
+
*
|
|
148
|
+
* Returns `undefined` when there is nothing to carry, which is its own answer: a
|
|
149
|
+
* record nobody edited must open in the next layout exactly as clean as it was.
|
|
150
|
+
*
|
|
151
|
+
* Only what was edited here travels, keyed by `touched` — the same subset the
|
|
152
|
+
* backup stores. Carrying the whole record instead marks every field touched in
|
|
153
|
+
* the receiving form, which lights up every empty required field as an error and
|
|
154
|
+
* writes the entire record back out as a "local change".
|
|
155
|
+
*/
|
|
156
|
+
export function getEditHandoffValues<M extends Record<string, unknown>>({
|
|
157
|
+
status,
|
|
158
|
+
dirty,
|
|
159
|
+
values,
|
|
160
|
+
touched,
|
|
161
|
+
storedValues
|
|
162
|
+
}: {
|
|
163
|
+
status: EntityStatus;
|
|
164
|
+
/** Whether `values` differ from what is stored. */
|
|
165
|
+
dirty: boolean;
|
|
166
|
+
values: Partial<M>;
|
|
167
|
+
touched: Record<string, boolean>;
|
|
168
|
+
/** The stored record, for the fallback below. Absent for a new one. */
|
|
169
|
+
storedValues?: Partial<M>;
|
|
170
|
+
}): Partial<M> | undefined {
|
|
171
|
+
|
|
172
|
+
if (!values) return undefined;
|
|
173
|
+
|
|
174
|
+
// A record with nothing stored behind it carries whole: there is no
|
|
175
|
+
// baseline to fall back to, so anything not carried is simply lost.
|
|
176
|
+
if (status === "new" || status === "copy") {
|
|
177
|
+
return Object.keys(values).length > 0 ? values : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!dirty) return undefined;
|
|
181
|
+
|
|
182
|
+
const touchedValues = removeEmptyContainers(extractTouchedValues(values, touched ?? {})) as Partial<M> | undefined;
|
|
183
|
+
if (touchedValues && Object.keys(touchedValues).length > 0) {
|
|
184
|
+
return touchedValues;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Dirty with nothing touched — a value set programmatically, by a plugin or
|
|
188
|
+
// a custom field that writes through `setFieldValue` without the blur that
|
|
189
|
+
// marks it. Diffing against the stored record still finds it, and dropping
|
|
190
|
+
// it here would hand over a form that opens clean over an edit.
|
|
191
|
+
const changes = getChanges(values, storedValues ?? {});
|
|
192
|
+
return Object.keys(changes).length > 0 ? changes : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
117
195
|
export function getInitialEntityValues<M extends Record<string, unknown>>(
|
|
118
196
|
authController: AuthController,
|
|
119
197
|
collection: AdminCollection,
|
|
@@ -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) {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { AdminCollection, EntityDisplayRole } from "@rebasepro/admin-types";
|
|
2
|
+
import type { Entity, Property } from "@rebasepro/types";
|
|
3
|
+
import { ENTITY_DISPLAY_ROLES } from "@rebasepro/admin-types";
|
|
4
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
5
|
+
import {
|
|
6
|
+
EntityDisplayCache,
|
|
7
|
+
entityDisplayKey,
|
|
8
|
+
getDisplayPropertyKey,
|
|
9
|
+
getDisplayResolver,
|
|
10
|
+
getPropertyInPath,
|
|
11
|
+
useRebaseContext
|
|
12
|
+
} from "@rebasepro/app";
|
|
13
|
+
import { getValueInPath } from "@rebasepro/utils";
|
|
14
|
+
|
|
15
|
+
import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
|
|
16
|
+
import { getUserLabel, useResolvedUser } from "./useResolvedUsers";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What fills one display role for one record.
|
|
20
|
+
*
|
|
21
|
+
* `value` is `undefined` rather than a placeholder when nothing resolved,
|
|
22
|
+
* because the substitute differs by surface and each one knows its own better
|
|
23
|
+
* than this hook does: a page heading wants the singular collection name, a link
|
|
24
|
+
* wants the id, a draft wants "New customer". A single baked-in fallback is
|
|
25
|
+
* exactly what produced three different ones scattered across the consumers.
|
|
26
|
+
*/
|
|
27
|
+
export interface ResolvedDisplayValue<T = unknown> {
|
|
28
|
+
/** What goes in the slot. `undefined` when the record has nothing for it. */
|
|
29
|
+
value: T | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* A resolver is in flight, and `value` currently holds the derived answer or
|
|
32
|
+
* nothing. Surfaces that can show a skeleton may; none has to.
|
|
33
|
+
*/
|
|
34
|
+
loading: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Set only when the value came from a property of this collection. Carries
|
|
37
|
+
* what a rich renderer needs — a status shows as its enum's coloured chip,
|
|
38
|
+
* an image as a storage thumbnail — which a bare resolved string cannot
|
|
39
|
+
* describe.
|
|
40
|
+
*/
|
|
41
|
+
source?: {
|
|
42
|
+
key: string;
|
|
43
|
+
property: Property | undefined;
|
|
44
|
+
value: unknown;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One shared store, so every surface agrees on a record and resolves it once.
|
|
50
|
+
*
|
|
51
|
+
* Module-level rather than a provider: two app instances on one page share a
|
|
52
|
+
* cache keyed by collection path and record id, which *is* the same record.
|
|
53
|
+
* Nothing here is user-specific — a resolver that reads per-user data must
|
|
54
|
+
* invalidate on sign-out, which {@link useEntityDisplayCache} exposes.
|
|
55
|
+
*/
|
|
56
|
+
let sharedCache: EntityDisplayCache | undefined;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The store behind {@link useEntityDisplay}, for invalidating after a write.
|
|
60
|
+
*
|
|
61
|
+
* Built on first use rather than at module scope. Importing a module should not
|
|
62
|
+
* construct anything: it runs during SSR where there is nothing to cache, and it
|
|
63
|
+
* made this module unimportable from any test that stubs `@rebasepro/app` —
|
|
64
|
+
* which several do, and which is a legitimate thing for them to do.
|
|
65
|
+
*/
|
|
66
|
+
export function useEntityDisplayCache(): EntityDisplayCache {
|
|
67
|
+
if (!sharedCache) sharedCache = new EntityDisplayCache();
|
|
68
|
+
return sharedCache;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface UseEntityDisplayParams<M extends Record<string, unknown>> {
|
|
72
|
+
/**
|
|
73
|
+
* Optional because several callers look the collection up in the registry by
|
|
74
|
+
* path and may not find it — a relation into a collection the panel does not
|
|
75
|
+
* register. A hook that could not be called until that succeeded would have
|
|
76
|
+
* to sit below an early return, which is not where hooks go.
|
|
77
|
+
*/
|
|
78
|
+
collection?: AdminCollection<M>;
|
|
79
|
+
/**
|
|
80
|
+
* The record. A resolver is handed the whole entity — its id and path are
|
|
81
|
+
* how it reaches anything the record does not carry — so a computed value is
|
|
82
|
+
* only available once there is an entity to compute it from.
|
|
83
|
+
*/
|
|
84
|
+
entity?: Entity<M>;
|
|
85
|
+
/**
|
|
86
|
+
* Values to read a declared path from when they are newer than the entity:
|
|
87
|
+
* the live form values while editing. Falls back to `entity.values`.
|
|
88
|
+
*/
|
|
89
|
+
values?: Record<string, unknown>;
|
|
90
|
+
/** Set false to skip resolver work entirely — a draft has nothing to show. */
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What fills one display role of one record.
|
|
96
|
+
*
|
|
97
|
+
* This is the only implementation. Before it there were seven for the title
|
|
98
|
+
* alone — the identity bar, the detail header, inline previews, reference cards,
|
|
99
|
+
* list slots, relation chips and the headless view — each re-deriving it from
|
|
100
|
+
* `collection.properties` and disagreeing about the answer.
|
|
101
|
+
*
|
|
102
|
+
* Resolution order, per role:
|
|
103
|
+
*
|
|
104
|
+
* 1. `admin.display[role]` as a **resolver** — awaited, cached per record and
|
|
105
|
+
* role, with the derived value showing meanwhile so nothing flickers empty.
|
|
106
|
+
* 2. `admin.display[role]` as a **property path** (or, for the title, the
|
|
107
|
+
* deprecated `titleProperty`).
|
|
108
|
+
* 3. The **derived** answer: for the title, the best-ranked property carrying a
|
|
109
|
+
* readable value; for the rest, today's per-role heuristics.
|
|
110
|
+
*
|
|
111
|
+
* A user-picker title resolves to the person behind the id, as it always did —
|
|
112
|
+
* that path is now here rather than in two of the seven.
|
|
113
|
+
*/
|
|
114
|
+
export function useEntityDisplay<T = unknown, M extends Record<string, unknown> = Record<string, unknown>>(
|
|
115
|
+
role: EntityDisplayRole,
|
|
116
|
+
{
|
|
117
|
+
collection,
|
|
118
|
+
entity,
|
|
119
|
+
values: valuesProp,
|
|
120
|
+
enabled = true
|
|
121
|
+
}: UseEntityDisplayParams<M>
|
|
122
|
+
): ResolvedDisplayValue<T> {
|
|
123
|
+
|
|
124
|
+
const context = useRebaseContext();
|
|
125
|
+
const cache = useEntityDisplayCache();
|
|
126
|
+
|
|
127
|
+
const values = valuesProp ?? entity?.values;
|
|
128
|
+
|
|
129
|
+
/* ---- the declared or derived key ------------------------------------- */
|
|
130
|
+
|
|
131
|
+
// The title is the one role with a ranking behind it: several candidates,
|
|
132
|
+
// best first, skipping the ones that hold nothing for *this* record. The
|
|
133
|
+
// others are a single declared or derived key.
|
|
134
|
+
const declaredKey = collection ? getDisplayPropertyKey(collection, role) : undefined;
|
|
135
|
+
const propertyKey = collection && role === "title" && !declaredKey
|
|
136
|
+
? getEntityTitlePropertyKeyForEntity(collection, values, entity?.id)
|
|
137
|
+
: declaredKey;
|
|
138
|
+
|
|
139
|
+
const rawValue = values && propertyKey ? getValueInPath(values, propertyKey) : undefined;
|
|
140
|
+
|
|
141
|
+
// A user picker stores an auth id and renders the person: resolved like a
|
|
142
|
+
// relation, and hooked unconditionally so the hook order never changes.
|
|
143
|
+
const titleUser = useResolvedUser(
|
|
144
|
+
collection && role === "title" && isUserSelectProperty(collection, propertyKey) && typeof rawValue === "string"
|
|
145
|
+
? rawValue
|
|
146
|
+
: undefined
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const derived = useMemo<T | undefined>(() => {
|
|
150
|
+
if (titleUser) return getUserLabel(titleUser) as T;
|
|
151
|
+
if (rawValue === undefined || rawValue === null) return undefined;
|
|
152
|
+
// Only text roles get flattened to a string; a date stays a date and an
|
|
153
|
+
// image stays whatever the storage property holds, so the renderer can
|
|
154
|
+
// still format them.
|
|
155
|
+
if (role === "title" || role === "subtitle" || role === "status") {
|
|
156
|
+
return (resolveTitleToString(rawValue) || undefined) as T | undefined;
|
|
157
|
+
}
|
|
158
|
+
return rawValue as T;
|
|
159
|
+
}, [titleUser, rawValue, role]);
|
|
160
|
+
|
|
161
|
+
/* ---- the computed answer --------------------------------------------- */
|
|
162
|
+
|
|
163
|
+
const resolver = collection ? getDisplayResolver(collection, role) : undefined;
|
|
164
|
+
const canResolve = Boolean(enabled && resolver && entity);
|
|
165
|
+
const key = entity ? entityDisplayKey(entity.path, entity.id, role) : undefined;
|
|
166
|
+
|
|
167
|
+
const resolved = useSyncExternalStore<unknown>(
|
|
168
|
+
// Subscribing unconditionally keeps the hook order stable. The store
|
|
169
|
+
// only notifies when an entry changes, so a collection with no resolver
|
|
170
|
+
// never re-renders from it.
|
|
171
|
+
cb => cache.subscribe(cb),
|
|
172
|
+
() => (canResolve && key ? cache.peek(key) : undefined),
|
|
173
|
+
// Server render: nothing is resolved, so every slot starts at its
|
|
174
|
+
// fallback and hydration does not disagree with the markup.
|
|
175
|
+
() => undefined
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const loading = Boolean(canResolve && key && resolved === undefined);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!canResolve || !key || !resolver || !entity) return;
|
|
182
|
+
if (cache.peek(key) !== undefined) return;
|
|
183
|
+
cache.resolve(key, () => resolver({
|
|
184
|
+
entity,
|
|
185
|
+
context
|
|
186
|
+
})).catch(() => {
|
|
187
|
+
// Unreachable, and kept only so it stays that way: `resolve` records a
|
|
188
|
+
// failure as "nothing" and returns a RESOLVED promise, which is why
|
|
189
|
+
// the warning that used to live here could never run — see the note on
|
|
190
|
+
// `EntityDisplayCache.resolve`. The cache reports the failure itself
|
|
191
|
+
// now, once per key. This exists so that if `resolve` ever starts
|
|
192
|
+
// rejecting, it surfaces as a caught no-op rather than an unhandled
|
|
193
|
+
// rejection during render.
|
|
194
|
+
});
|
|
195
|
+
}, [cache, canResolve, key, resolver, entity, context, role]);
|
|
196
|
+
|
|
197
|
+
/* ---- the answer ------------------------------------------------------ */
|
|
198
|
+
|
|
199
|
+
// A resolver that produced nothing falls back to the derived value rather
|
|
200
|
+
// than to blank: it said this record has no computed answer for the role,
|
|
201
|
+
// not that the role should be empty.
|
|
202
|
+
const computed = canResolve && resolved !== null ? resolved as T | undefined : undefined;
|
|
203
|
+
const value = computed ?? derived;
|
|
204
|
+
|
|
205
|
+
return useMemo(() => ({
|
|
206
|
+
value,
|
|
207
|
+
loading,
|
|
208
|
+
source: collection && propertyKey && value === derived && derived !== undefined
|
|
209
|
+
? {
|
|
210
|
+
key: propertyKey,
|
|
211
|
+
property: getPropertyInPath(collection.properties, propertyKey),
|
|
212
|
+
value: rawValue
|
|
213
|
+
}
|
|
214
|
+
: undefined
|
|
215
|
+
}), [value, loading, propertyKey, derived, collection, rawValue]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Every display role of a record at once — what a list row, a card or a board
|
|
220
|
+
* card needs, in one call rather than six.
|
|
221
|
+
*
|
|
222
|
+
* The role list is iterated from {@link ENTITY_DISPLAY_ROLES} so the hook order
|
|
223
|
+
* is fixed and adding a role does not mean editing every caller.
|
|
224
|
+
*/
|
|
225
|
+
export function useEntityDisplayValues<M extends Record<string, unknown>>(
|
|
226
|
+
params: UseEntityDisplayParams<M>
|
|
227
|
+
): Record<EntityDisplayRole, ResolvedDisplayValue> & { loading: boolean } {
|
|
228
|
+
|
|
229
|
+
// Not a loop over `useEntityDisplay`: the number of hooks a component calls
|
|
230
|
+
// has to be fixed at compile time, and a role added to the array later would
|
|
231
|
+
// silently change it. Written out, it is checked by the return type.
|
|
232
|
+
const title = useEntityDisplay<string, M>("title", params);
|
|
233
|
+
const subtitle = useEntityDisplay<string, M>("subtitle", params);
|
|
234
|
+
const image = useEntityDisplay<string, M>("image", params);
|
|
235
|
+
const status = useEntityDisplay<string, M>("status", params);
|
|
236
|
+
const date = useEntityDisplay<Date | string | number, M>("date", params);
|
|
237
|
+
const tags = useEntityDisplay<string[] | string, M>("tags", params);
|
|
238
|
+
|
|
239
|
+
return useMemo(() => ({
|
|
240
|
+
title,
|
|
241
|
+
subtitle,
|
|
242
|
+
image,
|
|
243
|
+
status,
|
|
244
|
+
date,
|
|
245
|
+
tags,
|
|
246
|
+
loading: title.loading || subtitle.loading || image.loading
|
|
247
|
+
|| status.loading || date.loading || tags.loading
|
|
248
|
+
}), [title, subtitle, image, status, date, tags]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* What the record is called — the one role asked for often enough on its own to
|
|
253
|
+
* deserve a name.
|
|
254
|
+
*/
|
|
255
|
+
export function useEntityTitle<M extends Record<string, unknown>>(
|
|
256
|
+
params: UseEntityDisplayParams<M>
|
|
257
|
+
): ResolvedDisplayValue<string> {
|
|
258
|
+
return useEntityDisplay<string, M>("title", params);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Re-exported so a caller can iterate roles without reaching into types. */
|
|
262
|
+
export { ENTITY_DISPLAY_ROLES };
|