@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
|
@@ -3,12 +3,14 @@ import type { Property, RelationProperty } from "@rebasepro/types";
|
|
|
3
3
|
import type { Entity, EntityRelation } from "@rebasepro/types";
|
|
4
4
|
import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
|
|
5
5
|
import { getEntityImagePreviewPropertyKey } from "@rebasepro/app";
|
|
6
|
+
import { getDisplayPropertyKey } from "@rebasepro/app";
|
|
6
7
|
import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
|
|
7
8
|
import { getEntityFromCache } from "@rebasepro/app";
|
|
8
9
|
import { getEntityPreviewKeys } from "../../util/previews";
|
|
9
10
|
import { getValueInPath } from "@rebasepro/utils";
|
|
10
11
|
import type { AuthController } from "@rebasepro/admin-types";
|
|
11
12
|
import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
|
|
13
|
+
import { useEntityDisplay } from "../../hooks/useEntityDisplay";
|
|
12
14
|
|
|
13
15
|
/** Whether an authored relation yields many rows. Derived from its kind. */
|
|
14
16
|
function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
|
|
@@ -23,12 +25,18 @@ function relationCardinality(relation: { kind?: string; cardinality?: string } |
|
|
|
23
25
|
// ── Slot types ────────────────────────────────────────────────────────
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
|
-
* A resolved "slot"
|
|
27
|
-
*
|
|
28
|
+
* A resolved "slot": what fills one display role for one record.
|
|
29
|
+
*
|
|
30
|
+
* `property` and `propertyKey` are set when the value was read off the record,
|
|
31
|
+
* and absent when a `display` resolver computed it — a computed title has no
|
|
32
|
+
* column behind it, so there is nothing to render it *as*. Renderers must go
|
|
33
|
+
* through {@link SlotValue}, which makes that choice once: a property renders
|
|
34
|
+
* with its own preview (an enum keeps its coloured chip, a date its format), a
|
|
35
|
+
* computed value renders as text.
|
|
28
36
|
*/
|
|
29
37
|
export interface PreviewSlot {
|
|
30
|
-
property
|
|
31
|
-
propertyKey
|
|
38
|
+
property?: Property;
|
|
39
|
+
propertyKey?: string;
|
|
32
40
|
value: unknown;
|
|
33
41
|
}
|
|
34
42
|
|
|
@@ -129,10 +137,19 @@ export function resolveCollectionSlotKeys(
|
|
|
129
137
|
? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
|
|
130
138
|
: rankedCandidates;
|
|
131
139
|
const titleKey = titleKeyCandidates[0];
|
|
140
|
+
// `getEntityImagePreviewPropertyKey` prefers `display.image` itself, so
|
|
141
|
+
// every caller of it — this, the reference card, the board — agrees.
|
|
132
142
|
const imageKey = getEntityImagePreviewPropertyKey(collection);
|
|
133
143
|
|
|
134
|
-
// Status: first string-enum that isn't the title
|
|
135
|
-
|
|
144
|
+
// Status: declared, else the first string-enum that isn't the title.
|
|
145
|
+
//
|
|
146
|
+
// Everything below this line is inference — "the first enum", "a key that
|
|
147
|
+
// looks like a timestamp", "the longest remaining string". It is a good
|
|
148
|
+
// guess and it is only a guess, so a collection that states the role wins
|
|
149
|
+
// outright and the ladder is skipped. That is the whole point of
|
|
150
|
+
// `admin.display`: the heuristics stay for the collections that never say
|
|
151
|
+
// anything, and stop overruling the ones that do.
|
|
152
|
+
let statusKey: string | undefined = getDisplayPropertyKey(collection, "status");
|
|
136
153
|
|
|
137
154
|
// 1. Explicitly defined in previewProperties
|
|
138
155
|
if (!statusKey && collection.previewProperties) {
|
|
@@ -169,13 +186,15 @@ export function resolveCollectionSlotKeys(
|
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
188
|
|
|
172
|
-
// Date:
|
|
173
|
-
let dateKey: string | undefined;
|
|
189
|
+
// Date: declared, else a well-known timestamp field, else any date
|
|
190
|
+
let dateKey: string | undefined = getDisplayPropertyKey(collection, "date");
|
|
174
191
|
const dateCandidates = ["updated_at", "updatedAt", "modified_at", "modifiedAt", "created_at", "createdAt"];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
if (!dateKey) {
|
|
193
|
+
for (const candidate of dateCandidates) {
|
|
194
|
+
if (collection.properties[candidate]) {
|
|
195
|
+
dateKey = candidate;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
179
198
|
}
|
|
180
199
|
}
|
|
181
200
|
if (!dateKey) {
|
|
@@ -222,7 +241,8 @@ export function resolveCollectionSlotKeys(
|
|
|
222
241
|
const scoreB = propB?.type === "string" ? (propB.admin?.multiline ? 2 : 1) : 0;
|
|
223
242
|
return scoreB - scoreA;
|
|
224
243
|
});
|
|
225
|
-
const subtitleKey =
|
|
244
|
+
const subtitleKey = getDisplayPropertyKey(collection, "subtitle")
|
|
245
|
+
?? (sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined);
|
|
226
246
|
|
|
227
247
|
return { titleKey,
|
|
228
248
|
titleKeyCandidates,
|
|
@@ -506,43 +526,26 @@ function resolveRelationDisplayName(
|
|
|
506
526
|
}
|
|
507
527
|
|
|
508
528
|
// Helper: extract display name from entity values using the target collection
|
|
529
|
+
//
|
|
530
|
+
// This used to carry a ranking of its own — priority keys `name/title/label/
|
|
531
|
+
// displayName`, then the first visible non-id string — which is a *seventh*
|
|
532
|
+
// answer to "what is this record called", and one that disagreed with the
|
|
533
|
+
// shared one: it read `titleProperty` with a flat lookup (so a dotted path
|
|
534
|
+
// was ignored), never saw `display.title`, and ranked by key name where the
|
|
535
|
+
// shared ranking excludes identifiers structurally. A chip and the record it
|
|
536
|
+
// points at could therefore be labelled differently on the same screen.
|
|
537
|
+
//
|
|
538
|
+
// What is left here is the part that is genuinely local: a relation carries
|
|
539
|
+
// *eagerly loaded* values, not an entity, so a resolver cannot run against
|
|
540
|
+
// it — a computed title falls back to the ranked property, which is the same
|
|
541
|
+
// thing the list row shows while its own resolver is in flight.
|
|
509
542
|
const extractDisplayName = (values: Record<string, unknown>): string | undefined => {
|
|
510
543
|
if (targetCollection) {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// Helper to check if a property is hidden/internal
|
|
517
|
-
const isHiddenProp = (p: Property): boolean => {
|
|
518
|
-
if (p.admin?.hideFromCollection) return true;
|
|
519
|
-
if (typeof p.admin?.disabled === "object" && p.admin.disabled.hidden) return true;
|
|
520
|
-
return false;
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
// Helper to check if a property is a visible, non-id string
|
|
524
|
-
const isDisplayCandidate = (p: Property): boolean => {
|
|
525
|
-
return p.type === "string" && !p.admin?.multiline && !p.admin?.markdown && !p.storage
|
|
526
|
-
&& !("isId" in p && p.isId) && !isHiddenProp(p);
|
|
527
|
-
};
|
|
528
|
-
|
|
529
|
-
// Prioritize common title-like fields: name, title, label, displayName
|
|
530
|
-
const priorityKeys = ["name", "title", "label", "displayName"];
|
|
531
|
-
for (const pk of priorityKeys) {
|
|
532
|
-
const p = targetCollection.properties[pk] as Property | undefined;
|
|
533
|
-
if (p && isDisplayCandidate(p) && values[pk] !== undefined && values[pk] !== null) {
|
|
534
|
-
return String(values[pk]);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Fallback: find first visible, non-id string property in target
|
|
539
|
-
for (const [k, p] of Object.entries(targetCollection.properties)) {
|
|
540
|
-
const tp = p as Property;
|
|
541
|
-
if (isDisplayCandidate(tp)) {
|
|
542
|
-
if (values[k] !== undefined && values[k] !== null) {
|
|
543
|
-
return String(values[k]);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
544
|
+
for (const key of getTitlePropertyCandidates(targetCollection)) {
|
|
545
|
+
const value = getValueInPath(values, key);
|
|
546
|
+
if (value === undefined || value === null || value === "") continue;
|
|
547
|
+
if (typeof value === "string" && looksLikeIdentifierValue(value)) continue;
|
|
548
|
+
return String(value);
|
|
546
549
|
}
|
|
547
550
|
}
|
|
548
551
|
// Generic fallback: walk entity values for any short string.
|
|
@@ -582,3 +585,67 @@ function resolveRelationDisplayName(
|
|
|
582
585
|
// 3. Final fallback: show the ID
|
|
583
586
|
return id !== undefined ? String(id) : undefined;
|
|
584
587
|
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The slots for one record, with `admin.display` resolvers applied.
|
|
591
|
+
*
|
|
592
|
+
* {@link resolveEntitySlots} answers the question from values already in hand —
|
|
593
|
+
* which is everything a slot filled by a property needs, and nothing a slot
|
|
594
|
+
* filled by a *resolver* can use. A computed title may have to be fetched, so it
|
|
595
|
+
* arrives after the first render; this hook is where the two meet.
|
|
596
|
+
*
|
|
597
|
+
* Until a resolver lands, the derived slot shows. That is deliberate: a list of
|
|
598
|
+
* fifty rows must not blank its titles for a beat while fifty promises settle,
|
|
599
|
+
* and the derived answer — the best-ranked property carrying a readable value —
|
|
600
|
+
* is a reasonable thing to be looking at meanwhile. When the resolved value
|
|
601
|
+
* arrives it replaces the slot outright, property and all, because there is no
|
|
602
|
+
* property behind a computed value (see {@link PreviewSlot}).
|
|
603
|
+
*
|
|
604
|
+
* Every role is hooked unconditionally, so the hook order is fixed regardless of
|
|
605
|
+
* what a given collection declares.
|
|
606
|
+
*/
|
|
607
|
+
export function useEntitySlots(
|
|
608
|
+
entity: Entity<Record<string, unknown>>,
|
|
609
|
+
collection: AdminCollection<Record<string, unknown>>,
|
|
610
|
+
slotKeys: CollectionSlotKeys
|
|
611
|
+
): EntityPreviewSlots {
|
|
612
|
+
|
|
613
|
+
const derived = useMemo(
|
|
614
|
+
() => resolveEntitySlots(entity, collection, slotKeys),
|
|
615
|
+
[entity, collection, slotKeys]
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const params = { collection,
|
|
619
|
+
entity };
|
|
620
|
+
const title = useEntityDisplay<string>("title", params);
|
|
621
|
+
const subtitle = useEntityDisplay<string>("subtitle", params);
|
|
622
|
+
const image = useEntityDisplay<string>("image", params);
|
|
623
|
+
const status = useEntityDisplay<string>("status", params);
|
|
624
|
+
const date = useEntityDisplay<Date | string | number>("date", params);
|
|
625
|
+
|
|
626
|
+
return useMemo(() => {
|
|
627
|
+
// `source` is set only when the value came from a property, so its
|
|
628
|
+
// absence is exactly the "this was computed" signal — no second flag to
|
|
629
|
+
// keep in step with the first.
|
|
630
|
+
const computed = (
|
|
631
|
+
resolved: { value: unknown, source?: unknown }
|
|
632
|
+
): PreviewSlot | undefined =>
|
|
633
|
+
resolved.value !== undefined && resolved.source === undefined
|
|
634
|
+
? { value: resolved.value }
|
|
635
|
+
: undefined;
|
|
636
|
+
|
|
637
|
+
const computedDate = computed(date);
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
...derived,
|
|
641
|
+
title: computed(title) ?? derived.title,
|
|
642
|
+
subtitle: computed(subtitle) ?? derived.subtitle,
|
|
643
|
+
image: computed(image) ?? derived.image,
|
|
644
|
+
status: computed(status) ?? derived.status,
|
|
645
|
+
date: computedDate
|
|
646
|
+
? { ...computedDate,
|
|
647
|
+
formatted: formatDateValue(computedDate.value) }
|
|
648
|
+
: derived.date
|
|
649
|
+
};
|
|
650
|
+
}, [derived, title, subtitle, image, status, date]);
|
|
651
|
+
}
|
|
@@ -151,13 +151,10 @@ function AdminNavigationContent() {
|
|
|
151
151
|
closeHover
|
|
152
152
|
} = useApp();
|
|
153
153
|
|
|
154
|
-
const [adminMenuOpen, setAdminMenuOpen] = React.useState(false);
|
|
155
|
-
|
|
156
154
|
const analyticsController = useAnalyticsController();
|
|
157
155
|
const navigationState = useNavigationStateController();
|
|
158
156
|
const context = useRebaseContext();
|
|
159
157
|
|
|
160
|
-
const tooltipsOpen = drawerHovered && !drawerOpen && !adminMenuOpen;
|
|
161
158
|
const largeLayout = useLargeLayout();
|
|
162
159
|
const adminModeController = useAdminModeController();
|
|
163
160
|
const registry = useRebaseRegistry();
|
|
@@ -251,8 +248,6 @@ context });
|
|
|
251
248
|
collapsed={isGroupCollapsed(group)}
|
|
252
249
|
onToggleCollapsed={() => toggleGroupCollapsed(group)}
|
|
253
250
|
drawerOpen={drawerVisuallyOpen}
|
|
254
|
-
tooltipsOpen={tooltipsOpen}
|
|
255
|
-
adminMenuOpen={adminMenuOpen}
|
|
256
251
|
onItemClick={onItemClick}
|
|
257
252
|
icon={groupIcons.get(group)}
|
|
258
253
|
/>
|
|
@@ -278,7 +273,7 @@ export function DrawerLogo({
|
|
|
278
273
|
drawerHovered: boolean;
|
|
279
274
|
}) {
|
|
280
275
|
|
|
281
|
-
const showFullContent = drawerOpen ||
|
|
276
|
+
const showFullContent = drawerOpen || drawerHovered;
|
|
282
277
|
|
|
283
278
|
return (
|
|
284
279
|
<div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
|
|
@@ -333,42 +328,43 @@ export function DrawerToggle({
|
|
|
333
328
|
}) {
|
|
334
329
|
const isExpanded = drawerOpen;
|
|
335
330
|
const isHovered = drawerHovered && !drawerOpen;
|
|
336
|
-
const isFloating = isHovered;
|
|
337
331
|
const showFullContent = isExpanded || isHovered;
|
|
338
332
|
|
|
333
|
+
const [wantsTooltip, setWantsTooltip] = React.useState(false);
|
|
334
|
+
// See {@link DrawerNavigationItem}: a masked tooltip never hears the pointer
|
|
335
|
+
// leave, so the stale state is dropped as the mask goes on.
|
|
336
|
+
if (showFullContent && wantsTooltip) setWantsTooltip(false);
|
|
337
|
+
|
|
339
338
|
const { t } = useTranslation();
|
|
340
339
|
|
|
341
340
|
return (
|
|
342
341
|
<div className="shrink-0 mt-auto px-4 pt-0.5 pb-2">
|
|
343
342
|
<Tooltip
|
|
344
343
|
title={isExpanded ? t("collapse") : t("expand")}
|
|
344
|
+
// The row spells out "Collapse"/"Expand" itself whenever it is wide
|
|
345
|
+
// enough to show the label — expanded, or floating open under the
|
|
346
|
+
// pointer. A tooltip repeating that word is noise; it earns its
|
|
347
|
+
// place only on a bare rail, where the chevron stands alone.
|
|
348
|
+
//
|
|
349
|
+
// Masked, not withheld: see {@link DrawerNavigationItem} for why
|
|
350
|
+
// this stays controlled in both states.
|
|
351
|
+
open={!showFullContent && wantsTooltip}
|
|
352
|
+
onOpenChange={setWantsTooltip}
|
|
345
353
|
side="right"
|
|
346
354
|
sideOffset={12}
|
|
347
355
|
asChild={true}
|
|
348
|
-
open={isFloating ? false : undefined}
|
|
349
356
|
>
|
|
350
|
-
<
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
351
359
|
className={cls(
|
|
352
|
-
"flex flex-row items-center rounded-lg cursor-pointer",
|
|
360
|
+
"flex flex-row items-center rounded-lg cursor-pointer w-full",
|
|
353
361
|
"hover:bg-surface-accent-100 dark:hover:bg-surface-800",
|
|
354
362
|
"transition-colors duration-150",
|
|
355
363
|
"py-2"
|
|
356
364
|
)}
|
|
357
|
-
role="button"
|
|
358
|
-
tabIndex={0}
|
|
359
365
|
aria-expanded={isExpanded}
|
|
360
366
|
aria-label={isExpanded ? t("collapse") : t("expand")}
|
|
361
367
|
onClick={() => isExpanded ? closeDrawer() : openDrawer()}
|
|
362
|
-
onKeyDown={(e) => {
|
|
363
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
364
|
-
e.preventDefault();
|
|
365
|
-
if (isExpanded) {
|
|
366
|
-
closeDrawer();
|
|
367
|
-
} else {
|
|
368
|
-
openDrawer();
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}}
|
|
372
368
|
>
|
|
373
369
|
<div className="shrink-0 flex items-center justify-center w-[44px] h-[24px] text-surface-500 dark:text-surface-400">
|
|
374
370
|
{isExpanded
|
|
@@ -387,7 +383,7 @@ export function DrawerToggle({
|
|
|
387
383
|
{isExpanded ? t("collapse") : t("expand")}
|
|
388
384
|
</Typography>
|
|
389
385
|
</div>
|
|
390
|
-
</
|
|
386
|
+
</button>
|
|
391
387
|
</Tooltip>
|
|
392
388
|
</div>
|
|
393
389
|
);
|
|
@@ -10,11 +10,11 @@ import { CollectionViewBinding } from "./CollectionViewBinding/CollectionViewBin
|
|
|
10
10
|
import { EntityViewBinding } from "./EntityViewBinding";
|
|
11
11
|
import { EntityIdentityBar } from "./EntityIdentityBar";
|
|
12
12
|
import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
|
|
13
|
+
import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
|
|
13
14
|
import { EntityInspector, InspectorTab } from "./EntityInspector";
|
|
14
15
|
import { CircularProgressCenter, iconSize } from "@rebasepro/ui";
|
|
15
16
|
import {
|
|
16
17
|
Alert,
|
|
17
|
-
ArrowLeftIcon,
|
|
18
18
|
Button,
|
|
19
19
|
CenteredView,
|
|
20
20
|
cls,
|
|
@@ -34,6 +34,7 @@ import { ErrorBoundary } from "@rebasepro/ui";
|
|
|
34
34
|
import { ErrorView, createFormexStub, usePermissions, useTranslation, getIcon } from "@rebasepro/app";
|
|
35
35
|
|
|
36
36
|
import { removeInitialAndTrailingSlashes, resolveDefaultSelectedView } from "@rebasepro/app";
|
|
37
|
+
import { withViewMode } from "../util/view_mode";
|
|
37
38
|
import { resolvedSelectedEntityView } from "../util/resolutions";
|
|
38
39
|
import {
|
|
39
40
|
useCustomizationController,
|
|
@@ -45,9 +46,7 @@ import {
|
|
|
45
46
|
import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
|
|
46
47
|
import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
|
|
47
48
|
import { useNavigate } from "react-router";
|
|
48
|
-
import {
|
|
49
|
-
import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
|
|
50
|
-
import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
|
|
49
|
+
import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
|
|
@@ -82,6 +81,12 @@ export interface DetailViewBindingProps<M extends Record<string, unknown> = Reco
|
|
|
82
81
|
* of the breadcrumb — the side panel's close button.
|
|
83
82
|
*/
|
|
84
83
|
barActionsStart?: React.ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* Full screen only: bring the list back beside this record. Set when the
|
|
86
|
+
* record was opened out of a split collection, so folding the list away is
|
|
87
|
+
* reversible; it takes the place of the back arrow when present.
|
|
88
|
+
*/
|
|
89
|
+
onShowList?: () => void;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
export function DetailViewBinding<M extends Record<string, unknown>>({
|
|
@@ -141,7 +146,8 @@ function DetailViewBindingInner<M extends Record<string, unknown>>({
|
|
|
141
146
|
dataLoading,
|
|
142
147
|
layout = "full_screen",
|
|
143
148
|
barActions,
|
|
144
|
-
barActionsStart
|
|
149
|
+
barActionsStart,
|
|
150
|
+
onShowList
|
|
145
151
|
}: DetailViewBindingProps<M> & {
|
|
146
152
|
entity?: Entity<M>,
|
|
147
153
|
dataLoading: boolean,
|
|
@@ -283,18 +289,13 @@ entityId }
|
|
|
283
289
|
const formViewConfig = (collection as AdminCollection<M> & { formView?: FormViewConfig<M> }).formView;
|
|
284
290
|
const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
|
|
285
291
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const title = titleUser
|
|
294
|
-
? getUserLabel(titleUser)
|
|
295
|
-
: (rawTitle !== undefined && rawTitle !== null
|
|
296
|
-
? resolveTitleToString(rawTitle)
|
|
297
|
-
: (collection.singularName ?? collection.name));
|
|
292
|
+
// The heading, resolved the one way — this used to be a copy of
|
|
293
|
+
// `useEntityDisplayTitle`'s body, and the two had already drifted.
|
|
294
|
+
const title = useEntityDisplayTitle({
|
|
295
|
+
collection,
|
|
296
|
+
entity: usedEntity,
|
|
297
|
+
status: "existing"
|
|
298
|
+
});
|
|
298
299
|
|
|
299
300
|
// Non-action custom views
|
|
300
301
|
const nonActionCustomViews = useMemo(() =>
|
|
@@ -380,20 +381,31 @@ entityId }
|
|
|
380
381
|
}, [onTabChange, path, entityId, collection]);
|
|
381
382
|
|
|
382
383
|
const propertyDetailView = () => {
|
|
383
|
-
// formView.Builder replaces the default property display
|
|
384
|
+
// formView.Builder replaces the default property display. It gets the
|
|
385
|
+
// centred column the record used to be given here; the default display
|
|
386
|
+
// now lays itself out, rail included.
|
|
384
387
|
if (FormViewBuilder && usedEntity) {
|
|
385
|
-
return <
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
388
|
+
return <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
|
|
389
|
+
<div className={cls(
|
|
390
|
+
"w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
|
|
391
|
+
layout === "dialog"
|
|
392
|
+
? "pt-5 pb-12 px-6 sm:px-8"
|
|
393
|
+
: "pt-6 pb-16 px-5 sm:px-8"
|
|
394
|
+
)}>
|
|
395
|
+
<ErrorBoundary>
|
|
396
|
+
<Suspense fallback={<CircularProgressCenter />}>
|
|
397
|
+
<FormViewBuilder
|
|
398
|
+
collection={collection}
|
|
399
|
+
parentCollectionSlugs={parentCollectionSlugs}
|
|
400
|
+
parentEntityIds={parentEntityIds}
|
|
401
|
+
entity={usedEntity}
|
|
402
|
+
modifiedValues={usedEntity?.values}
|
|
403
|
+
formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
|
|
404
|
+
/>
|
|
405
|
+
</Suspense>
|
|
406
|
+
</ErrorBoundary>
|
|
407
|
+
</div>
|
|
408
|
+
</div>;
|
|
397
409
|
}
|
|
398
410
|
|
|
399
411
|
return (
|
|
@@ -402,6 +414,9 @@ entityId }
|
|
|
402
414
|
entity={usedEntity}
|
|
403
415
|
collection={collection}
|
|
404
416
|
path={path}
|
|
417
|
+
asPage
|
|
418
|
+
openEntityMode={layout}
|
|
419
|
+
formContext={readOnlyFormContext}
|
|
405
420
|
/>}
|
|
406
421
|
</>
|
|
407
422
|
);
|
|
@@ -458,24 +473,27 @@ entityId }
|
|
|
458
473
|
);
|
|
459
474
|
});
|
|
460
475
|
|
|
476
|
+
// The breadcrumb's destination, and the back arrow's when it renders. Both
|
|
477
|
+
// keep the view mode: a collection reached from one of its own records must
|
|
478
|
+
// come back as the user left it.
|
|
479
|
+
const collectionUrl = withViewMode(urlController.buildUrlCollectionPath(path));
|
|
480
|
+
|
|
461
481
|
// Full screen is the one layout with no way out of its own: a side panel and a
|
|
462
482
|
// dialog can be dismissed, and a split keeps the list beside it, but opening an
|
|
463
483
|
// entity full screen replaces the collection entirely and left browser Back as
|
|
464
484
|
// the only route back to it — which is not an affordance the page shows, and is
|
|
465
485
|
// wrong anyway once the user has moved between tabs inside the entity.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews || layout === "side_panel" || layout === "dialog" || Boolean(backToCollectionButton);
|
|
486
|
+
//
|
|
487
|
+
// Unless the list can be unfolded back beside the record: that chevron
|
|
488
|
+
// leads to the same collection while keeping the record open, so it stands
|
|
489
|
+
// alone rather than beside an arrow going almost the same place.
|
|
490
|
+
//
|
|
491
|
+
// It goes to the bar's leading edge — where a back control reads as back,
|
|
492
|
+
// and where the edit view has always put the same arrow — rather than to
|
|
493
|
+
// the trailing edge among the record's own actions.
|
|
494
|
+
const backToCollection = layout === "full_screen" && !onShowList
|
|
495
|
+
? () => navigate(collectionUrl)
|
|
496
|
+
: undefined;
|
|
479
497
|
|
|
480
498
|
const fullScreenButton = !barActions && // Not in split: the list panel beside it carries the close control,
|
|
481
499
|
// and an expand button there competes with it.
|
|
@@ -508,43 +526,45 @@ entityId }
|
|
|
508
526
|
// Main content view. The title, the `path/id` chip and the empty band under
|
|
509
527
|
// them have moved into the identity bar, and the `w-80 2xl:w-96` rail that
|
|
510
528
|
// held a single Edit button is gone — the bar holds it now.
|
|
529
|
+
//
|
|
530
|
+
// The scroll now lives on the record's own column rather than on this row,
|
|
531
|
+
// so the metadata rail beside it stays put while the record scrolls — the
|
|
532
|
+
// same arrangement the form uses.
|
|
511
533
|
const mainView = <div
|
|
512
534
|
className={cls(
|
|
513
|
-
"flex-1 flex flex-row w-full
|
|
535
|
+
"flex-1 flex flex-row w-full min-h-0",
|
|
514
536
|
!mainViewVisible ? "hidden" : ""
|
|
515
537
|
)}>
|
|
516
|
-
|
|
517
|
-
"w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
|
|
518
|
-
layout === "dialog"
|
|
519
|
-
? "pt-5 pb-12 px-6 sm:px-8"
|
|
520
|
-
: "pt-6 pb-16 px-5 sm:px-8"
|
|
521
|
-
)}>
|
|
522
|
-
{propertyDetailView()}
|
|
523
|
-
</div>
|
|
538
|
+
{propertyDetailView()}
|
|
524
539
|
</div>;
|
|
525
540
|
|
|
526
541
|
let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-800">
|
|
527
542
|
|
|
528
543
|
<EntityIdentityBar
|
|
529
544
|
collection={collection as AdminCollection}
|
|
545
|
+
collectionUrl={layout === "full_screen" || layout === "split" ? collectionUrl : undefined}
|
|
530
546
|
title={title}
|
|
531
547
|
entityId={entityId}
|
|
532
548
|
status={"existing"}
|
|
533
549
|
dirty={false}
|
|
534
550
|
saving={false}
|
|
551
|
+
onBack={backToCollection}
|
|
535
552
|
onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
|
|
536
553
|
onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
|
|
554
|
+
externalLink={usedEntity
|
|
555
|
+
? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
|
|
556
|
+
: undefined}
|
|
537
557
|
leading={<>
|
|
538
558
|
{barActionsStart}
|
|
539
559
|
{/* Split view: closing the list is opening this record
|
|
540
560
|
full screen, and the control for it belongs at this
|
|
541
|
-
bar's leading edge. */}
|
|
561
|
+
bar's leading edge. Full screen carries its mirror. */}
|
|
542
562
|
{layout === "split" && <SplitListCloseButton/>}
|
|
563
|
+
{layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
|
|
543
564
|
</>}
|
|
544
565
|
trailing={<>
|
|
545
566
|
{editButton}
|
|
546
567
|
{pluginActionsTop}
|
|
547
|
-
{backToCollectionButton}
|
|
548
568
|
{fullScreenButton}
|
|
549
569
|
{barActions?.({
|
|
550
570
|
path,
|
|
@@ -28,11 +28,12 @@ export interface DrawerNavigationGroupProps {
|
|
|
28
28
|
*/
|
|
29
29
|
drawerOpen: boolean;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* @deprecated No longer read. Entry tooltips are uncontrolled — they follow the
|
|
32
|
+
* pointer and focus, and are suppressed wherever the label is already visible.
|
|
32
33
|
*/
|
|
33
|
-
tooltipsOpen
|
|
34
|
+
tooltipsOpen?: boolean;
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
+
* @deprecated No longer read. See {@link tooltipsOpen}.
|
|
36
37
|
*/
|
|
37
38
|
adminMenuOpen?: boolean;
|
|
38
39
|
/**
|
|
@@ -66,8 +67,6 @@ export function DrawerNavigationGroup({
|
|
|
66
67
|
collapsed,
|
|
67
68
|
onToggleCollapsed,
|
|
68
69
|
drawerOpen,
|
|
69
|
-
tooltipsOpen,
|
|
70
|
-
adminMenuOpen,
|
|
71
70
|
headerActions,
|
|
72
71
|
onItemClick,
|
|
73
72
|
hideHeader,
|
|
@@ -158,8 +157,6 @@ export function DrawerNavigationGroup({
|
|
|
158
157
|
<ResolvedDrawerNavigationItem
|
|
159
158
|
key={entry.id}
|
|
160
159
|
icon={<IconForView collectionOrView={entry.collection ?? entry.view} size={"small"}/>}
|
|
161
|
-
tooltipsOpen={!collapsed && tooltipsOpen}
|
|
162
|
-
adminMenuOpen={adminMenuOpen}
|
|
163
160
|
drawerOpen={drawerOpen}
|
|
164
161
|
onClick={() => onItemClick?.(entry)}
|
|
165
162
|
url={entry.url}
|
|
@@ -7,16 +7,16 @@ export function DrawerNavigationItem({
|
|
|
7
7
|
name,
|
|
8
8
|
icon,
|
|
9
9
|
drawerOpen,
|
|
10
|
-
adminMenuOpen,
|
|
11
|
-
tooltipsOpen,
|
|
12
10
|
url,
|
|
13
11
|
onClick,
|
|
14
12
|
indented = false
|
|
15
13
|
}: {
|
|
16
14
|
icon: React.ReactElement,
|
|
17
15
|
name: string,
|
|
18
|
-
|
|
16
|
+
/** @deprecated No longer read: the tooltip is uncontrolled. */
|
|
17
|
+
tooltipsOpen?: boolean,
|
|
19
18
|
drawerOpen: boolean,
|
|
19
|
+
/** @deprecated No longer read: the tooltip is uncontrolled. */
|
|
20
20
|
adminMenuOpen?: boolean,
|
|
21
21
|
url: string,
|
|
22
22
|
onClick?: () => void,
|
|
@@ -40,6 +40,15 @@ export function DrawerNavigationItem({
|
|
|
40
40
|
{icon}
|
|
41
41
|
</div>;
|
|
42
42
|
|
|
43
|
+
const [wantsTooltip, setWantsTooltip] = React.useState(false);
|
|
44
|
+
|
|
45
|
+
// Radix will not report a close it never noticed: while the tooltip is masked
|
|
46
|
+
// the pointer can leave the row without `onOpenChange(false)` ever firing. So
|
|
47
|
+
// the stale `true` is dropped on the way *into* the masked state — otherwise
|
|
48
|
+
// it surfaces the moment the mask lifts, as a tooltip hanging over a rail the
|
|
49
|
+
// pointer left long ago.
|
|
50
|
+
if (drawerOpen && wantsTooltip) setWantsTooltip(false);
|
|
51
|
+
|
|
43
52
|
const listItem = <div>
|
|
44
53
|
<NavLink
|
|
45
54
|
onClick={onClick}
|
|
@@ -70,8 +79,18 @@ export function DrawerNavigationItem({
|
|
|
70
79
|
</NavLink>
|
|
71
80
|
</div>;
|
|
72
81
|
|
|
82
|
+
// Per row, and always controlled. The old shared flag could only say "every
|
|
83
|
+
// tooltip" or "none", and the state it needed was unreachable, so no entry
|
|
84
|
+
// tooltip ever opened — leaving a bare rail of unlabelled icons. Radix decides
|
|
85
|
+
// when this one wants to be open, from the pointer and from focus; the drawer
|
|
86
|
+
// only masks it once the label says the same thing.
|
|
87
|
+
//
|
|
88
|
+
// Masked rather than withheld: dropping `open`, or the title, would move the
|
|
89
|
+
// tooltip between controlled and uncontrolled as the drawer opens, which
|
|
90
|
+
// strands the old state — a tooltip left up over a pointer nowhere near it.
|
|
73
91
|
return <Tooltip
|
|
74
|
-
open={drawerOpen
|
|
92
|
+
open={!drawerOpen && wantsTooltip}
|
|
93
|
+
onOpenChange={setWantsTooltip}
|
|
75
94
|
side="right"
|
|
76
95
|
title={name}>
|
|
77
96
|
{listItem}
|