@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
|
@@ -74,7 +74,7 @@ export function CollectionViewActions<M extends Record<string, unknown>>({
|
|
|
74
74
|
size="small"
|
|
75
75
|
variant="filled"
|
|
76
76
|
color="primary">
|
|
77
|
-
|
|
77
|
+
{t("add_specific", { name: collection.singularName ?? collection.name })}
|
|
78
78
|
</Button>
|
|
79
79
|
: <Button
|
|
80
80
|
id={`add_entity_${path}`}
|
|
@@ -28,6 +28,7 @@ import { ReferencePreview } from "../../preview";
|
|
|
28
28
|
import {
|
|
29
29
|
CollectionScopeProvider,
|
|
30
30
|
OnColumnResizeParams,
|
|
31
|
+
getRedundantChildViewColumnIds,
|
|
31
32
|
useAnalyticsController,
|
|
32
33
|
useAuthController,
|
|
33
34
|
useColumnIds,
|
|
@@ -687,7 +688,16 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
|
|
|
687
688
|
// Each child view gets a column that jumps to its tab. Keyed by the
|
|
688
689
|
// view's collection slug, which is the relation key — two relations
|
|
689
690
|
// to the same target used to collapse onto one column id here.
|
|
690
|
-
|
|
691
|
+
//
|
|
692
|
+
// Except where the relation was declared as a property and so already
|
|
693
|
+
// has a column showing the child rows: the button then repeated that
|
|
694
|
+
// column's heading verbatim. Filtered here as well as in
|
|
695
|
+
// `useColumnIds` so no id is displayed without a delegate to build it,
|
|
696
|
+
// and so the column picker does not offer the one back.
|
|
697
|
+
const redundant = getRedundantChildViewColumnIds(collectionWithLocalOrder);
|
|
698
|
+
const subcollectionsList = getEntityChildViews(collection)
|
|
699
|
+
.map(v => v.collection as AdminCollection)
|
|
700
|
+
.filter(child => !redundant.has(getSubcollectionColumnId(child)));
|
|
691
701
|
const subcollectionColumns: AdditionalFieldDelegate<M, any>[] = subcollectionsList.map((subcollection: AdminCollection) => {
|
|
692
702
|
return {
|
|
693
703
|
key: getSubcollectionColumnId(subcollection),
|
|
@@ -720,7 +730,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
|
|
|
720
730
|
...(collection.additionalFields ?? EMPTY_ARRAY),
|
|
721
731
|
...subcollectionColumns
|
|
722
732
|
];
|
|
723
|
-
}, [collection, path, sidePanelController]);
|
|
733
|
+
}, [collection, collectionWithLocalOrder, path, sidePanelController]);
|
|
724
734
|
|
|
725
735
|
const updateLastDeleteTimestamp = useCallback(() => {
|
|
726
736
|
setLastDeleteTimestamp(Date.now());
|
|
@@ -823,6 +833,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
|
|
|
823
833
|
collection={collection}
|
|
824
834
|
filter={tableController.filterValues}
|
|
825
835
|
sortBy={tableController.sortBy}
|
|
836
|
+
searchString={tableController.searchString}
|
|
826
837
|
onCountChange={setDocsCount}
|
|
827
838
|
/>;
|
|
828
839
|
|
|
@@ -1126,13 +1137,18 @@ parentEntityIds,
|
|
|
1126
1137
|
|
|
1127
1138
|
{countFetcher}
|
|
1128
1139
|
|
|
1129
|
-
{
|
|
1140
|
+
{/* A plugin's error view takes priority over the built-in
|
|
1141
|
+
banner, the same precedence the empty state uses. This was
|
|
1142
|
+
computed above and then rendered nowhere, so `collection.error`
|
|
1143
|
+
— a declared, documented slot with its own props interface —
|
|
1144
|
+
did nothing at all, and a plugin author had no way to tell. */}
|
|
1145
|
+
{pluginErrorView ?? (tableController.dataLoadingError && tableController.data.length > 0 && (
|
|
1130
1146
|
<div className="flex items-center gap-4 px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 flex-shrink-0">
|
|
1131
1147
|
<Typography variant="body2" className="text-red-700 dark:text-red-300 flex-1">
|
|
1132
1148
|
<strong>Warning:</strong> {tableController.dataLoadingError.message || "Failed to update data."}
|
|
1133
1149
|
</Typography>
|
|
1134
1150
|
</div>
|
|
1135
|
-
)}
|
|
1151
|
+
))}
|
|
1136
1152
|
|
|
1137
1153
|
{/* When isSplitLayout, SplitListView is ALWAYS mounted — regardless
|
|
1138
1154
|
of viewMode. The toolbar + current view live in the left panel;
|
|
@@ -1302,17 +1318,26 @@ function DefaultCollectionEmptyState({
|
|
|
1302
1318
|
*/
|
|
1303
1319
|
const inflightCountRequests = new Map<string, Promise<number>>();
|
|
1304
1320
|
|
|
1305
|
-
function EntitiesCount({
|
|
1321
|
+
export function EntitiesCount({
|
|
1306
1322
|
path,
|
|
1307
1323
|
collection,
|
|
1308
1324
|
filter,
|
|
1309
1325
|
sortBy,
|
|
1326
|
+
searchString,
|
|
1310
1327
|
onCountChange
|
|
1311
1328
|
}: {
|
|
1312
1329
|
path: string,
|
|
1313
1330
|
collection: AdminCollection,
|
|
1314
1331
|
filter?: FilterValues<any>,
|
|
1315
1332
|
sortBy?: [string, "asc" | "desc"],
|
|
1333
|
+
/**
|
|
1334
|
+
* Required, not optional. The term sits in the same scope as the element
|
|
1335
|
+
* that mounts this and is passed to the toolbar and the empty state beside
|
|
1336
|
+
* it — it was simply not passed here, so a searched list showed the count
|
|
1337
|
+
* of the unsearched collection. A required prop is what stops the next
|
|
1338
|
+
* caller doing the same; there is no useful "count without the search".
|
|
1339
|
+
*/
|
|
1340
|
+
searchString: string | undefined,
|
|
1316
1341
|
onCountChange?: (count: number | null | undefined) => void,
|
|
1317
1342
|
}) {
|
|
1318
1343
|
|
|
@@ -1344,12 +1369,17 @@ function EntitiesCount({
|
|
|
1344
1369
|
const orderByParams: [string, "asc" | "desc"] | undefined = sortByProperty && currentSort ? [String(sortByProperty), currentSort] : undefined;
|
|
1345
1370
|
|
|
1346
1371
|
// Deduplicate inflight count requests (e.g. React StrictMode double-mount)
|
|
1347
|
-
|
|
1372
|
+
// The search term is part of the query, so it is part of the key. The
|
|
1373
|
+
// cache is module-level and outlives an unmount, so a key that omits it
|
|
1374
|
+
// does not merely lose precision — it answers one search with a
|
|
1375
|
+
// different search's total.
|
|
1376
|
+
const cacheKey = `${path}|${filterKey}|${sortByProperty ?? ""}|${currentSort ?? ""}|${searchString ?? ""}`;
|
|
1348
1377
|
let countPromise = inflightCountRequests.get(cacheKey);
|
|
1349
1378
|
if (!countPromise) {
|
|
1350
1379
|
countPromise = accessor.count({
|
|
1351
1380
|
where: whereParams,
|
|
1352
|
-
orderBy: orderByParams
|
|
1381
|
+
orderBy: orderByParams,
|
|
1382
|
+
searchString
|
|
1353
1383
|
});
|
|
1354
1384
|
inflightCountRequests.set(cacheKey, countPromise);
|
|
1355
1385
|
// Clean up the inflight entry once resolved/rejected
|
|
@@ -1364,7 +1394,7 @@ function EntitiesCount({
|
|
|
1364
1394
|
});
|
|
1365
1395
|
|
|
1366
1396
|
return () => { cancelled = true; };
|
|
1367
|
-
}, [path, filterKey, sortByProperty, currentSort]);
|
|
1397
|
+
}, [path, filterKey, sortByProperty, currentSort, searchString]);
|
|
1368
1398
|
|
|
1369
1399
|
// Count is now displayed in the breadcrumb bar, this component only fetches and reports
|
|
1370
1400
|
return null;
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
} from "@rebasepro/app";
|
|
17
17
|
import { useAnalyticsController } from "@rebasepro/app";
|
|
18
18
|
import { IconForView } from "@rebasepro/app";
|
|
19
|
-
import { useCollectionSlotKeys,
|
|
19
|
+
import { useCollectionSlotKeys, useEntitySlots } from "./usePreviewSlots";
|
|
20
|
+
import { SlotValue, TagChips } from "./SlotValue";
|
|
20
21
|
|
|
21
22
|
export type EntityCardBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
|
|
22
23
|
entity: Entity<M>;
|
|
@@ -56,13 +57,10 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
|
|
|
56
57
|
customizationController.propertyConfigs
|
|
57
58
|
);
|
|
58
59
|
|
|
59
|
-
const slots =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
slotKeys
|
|
64
|
-
),
|
|
65
|
-
[entity, collection, slotKeys]
|
|
60
|
+
const slots = useEntitySlots(
|
|
61
|
+
entity as Entity<Record<string, unknown>>,
|
|
62
|
+
collection as AdminCollection<Record<string, unknown>>,
|
|
63
|
+
slotKeys
|
|
66
64
|
);
|
|
67
65
|
|
|
68
66
|
const handleClick = (e?: React.MouseEvent) => {
|
|
@@ -104,13 +102,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
|
|
|
104
102
|
<div className="aspect-[4/3] relative overflow-hidden bg-surface-100 dark:bg-surface-900">
|
|
105
103
|
{slots.image ? (
|
|
106
104
|
<div className="w-full h-full">
|
|
107
|
-
<
|
|
108
|
-
property={slots.image.property}
|
|
109
|
-
propertyKey={slots.image.propertyKey}
|
|
110
|
-
size="medium"
|
|
111
|
-
value={slots.image.value}
|
|
112
|
-
fill={true}
|
|
113
|
-
/>
|
|
105
|
+
<SlotValue slot={slots.image} size="medium" fill={true}/>
|
|
114
106
|
</div>
|
|
115
107
|
) : (
|
|
116
108
|
<div className="w-full h-full flex items-center justify-center">
|
|
@@ -160,12 +152,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
|
|
|
160
152
|
{/* Title slot */}
|
|
161
153
|
<div className="truncate my-1 text-sm font-medium min-h-[20px]">
|
|
162
154
|
{slots.title ? (
|
|
163
|
-
<
|
|
164
|
-
propertyKey={slots.title.propertyKey}
|
|
165
|
-
value={slots.title.value}
|
|
166
|
-
property={slots.title.property}
|
|
167
|
-
size="small"
|
|
168
|
-
/>
|
|
155
|
+
<SlotValue slot={slots.title} size="small"/>
|
|
169
156
|
) : (
|
|
170
157
|
<Typography variant="body2" className="text-surface-500">
|
|
171
158
|
{entity.id}
|
|
@@ -176,12 +163,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
|
|
|
176
163
|
{/* Subtitle slot */}
|
|
177
164
|
{slots.subtitle && (
|
|
178
165
|
<div className="line-clamp-3 [&_div]:line-clamp-3 text-xs text-surface-600 dark:text-surface-400 [&_p]:!my-1 [&_p:first-child]:!mt-0 [&_p:last-child]:!mb-0">
|
|
179
|
-
<
|
|
180
|
-
propertyKey={slots.subtitle.propertyKey}
|
|
181
|
-
value={slots.subtitle.value}
|
|
182
|
-
property={slots.subtitle.property}
|
|
183
|
-
size="small"
|
|
184
|
-
/>
|
|
166
|
+
<SlotValue slot={slots.subtitle} size="small"/>
|
|
185
167
|
</div>
|
|
186
168
|
)}
|
|
187
169
|
|
|
@@ -208,15 +190,20 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
|
|
|
208
190
|
</div>
|
|
209
191
|
)}
|
|
210
192
|
|
|
211
|
-
{/* Status slot */}
|
|
212
|
-
{slots.status && (
|
|
213
|
-
<div className="mt-1">
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
193
|
+
{/* Status + tags slot — "free chips beside the status" */}
|
|
194
|
+
{(slots.status || slots.tags) && (
|
|
195
|
+
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
|
196
|
+
{slots.status && <SlotValue slot={slots.status} size="small"/>}
|
|
197
|
+
{slots.tags && <TagChips slot={slots.tags}/>}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{/* Date slot — a card is a list row with the image on top, and the
|
|
202
|
+
row has always shown it. It was the one slot the card resolved
|
|
203
|
+
and never rendered. */}
|
|
204
|
+
{slots.date && (
|
|
205
|
+
<div className="mt-1 text-[10px] text-surface-500 dark:text-surface-400">
|
|
206
|
+
{slots.date.formatted}
|
|
220
207
|
</div>
|
|
221
208
|
)}
|
|
222
209
|
</div>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { PreviewSize } from "../../types/components/PropertyPreviewProps";
|
|
3
|
+
import { PropertyPreview } from "../../preview";
|
|
4
|
+
import { StorageThumbnail } from "../../preview/components/StorageThumbnail";
|
|
5
|
+
import { UrlComponentPreview } from "../../preview/components/UrlComponentPreview";
|
|
6
|
+
import { Chip } from "@rebasepro/ui";
|
|
7
|
+
import type { PreviewSlot } from "./usePreviewSlots";
|
|
8
|
+
|
|
9
|
+
export interface SlotValueProps {
|
|
10
|
+
slot: PreviewSlot | undefined;
|
|
11
|
+
size: PreviewSize;
|
|
12
|
+
/**
|
|
13
|
+
* Relations and references render as one line of linked text rather than as
|
|
14
|
+
* a card. Defaults to true: a slot is a line in a row or a card, and a
|
|
15
|
+
* relation that renders its own bordered card there — with its own id line
|
|
16
|
+
* and its own side-panel button — is a card inside a card.
|
|
17
|
+
*
|
|
18
|
+
* Pass `false` only for a slot with room for one, which in practice is
|
|
19
|
+
* none of them.
|
|
20
|
+
*/
|
|
21
|
+
textOnly?: boolean;
|
|
22
|
+
/** Images only: fill the container rather than fitting inside it. */
|
|
23
|
+
fill?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One display slot, rendered.
|
|
28
|
+
*
|
|
29
|
+
* A slot's value arrives one of two ways and they cannot be rendered the same:
|
|
30
|
+
*
|
|
31
|
+
* - **From a property** — render it with that property's own preview, so an
|
|
32
|
+
* enum status keeps its colour, a date keeps its format and a storage path
|
|
33
|
+
* becomes a thumbnail. This is why a declared path beats a resolver whenever
|
|
34
|
+
* the value is actually on the record.
|
|
35
|
+
* - **From a resolver** — there is no property, because the value was computed
|
|
36
|
+
* or fetched. All that can be said about it is that it is text.
|
|
37
|
+
*
|
|
38
|
+
* The branch lives here, once, rather than at each of the fourteen places the
|
|
39
|
+
* list row, the card and the board card render a slot. That count is the reason:
|
|
40
|
+
* the last time a rule like this was inlined per call site, it drifted into
|
|
41
|
+
* seven versions.
|
|
42
|
+
*/
|
|
43
|
+
export function SlotValue({ slot, size, textOnly = true, fill }: SlotValueProps) {
|
|
44
|
+
|
|
45
|
+
if (!slot || slot.value === undefined || slot.value === null) return null;
|
|
46
|
+
|
|
47
|
+
if (!slot.property || !slot.propertyKey) {
|
|
48
|
+
// A computed image is still an image. "There is no property, so all that
|
|
49
|
+
// can be said about it is that it is text" holds for a title and fails
|
|
50
|
+
// here: `display.image` accepts "a storage path or a URL … so a resolver
|
|
51
|
+
// may return either", and both used to be printed as their own string
|
|
52
|
+
// inside the image frame.
|
|
53
|
+
if (slot.role === "image" && typeof slot.value === "string" && slot.value.trim()) {
|
|
54
|
+
return <ComputedImage value={slot.value.trim()} size={size} fill={fill}/>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Computed. Arrays (a `tags` resolver) join rather than rendering as
|
|
58
|
+
// "[object Object]" via String().
|
|
59
|
+
const text = Array.isArray(slot.value)
|
|
60
|
+
? slot.value.map(item => String(item)).join(", ")
|
|
61
|
+
: String(slot.value);
|
|
62
|
+
return <>{text}</>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return <PropertyPreview
|
|
66
|
+
propertyKey={slot.propertyKey}
|
|
67
|
+
value={slot.value as never}
|
|
68
|
+
property={slot.property}
|
|
69
|
+
size={size}
|
|
70
|
+
textOnly={textOnly}
|
|
71
|
+
fill={fill}/>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `tags` slot, as chips beside the status.
|
|
76
|
+
*
|
|
77
|
+
* Honours both arms the same way the rest of the file does. A declared **path**
|
|
78
|
+
* goes through {@link SlotValue}, so an array of enum values keeps its own
|
|
79
|
+
* colours — that is the stated advantage of naming a property. A **resolver**
|
|
80
|
+
* returns bare strings with no property to render them as, so they become plain
|
|
81
|
+
* chips; a single string is accepted as one tag, which the role's type documents.
|
|
82
|
+
*
|
|
83
|
+
* Lives here rather than in the list, the card and the board card because that is
|
|
84
|
+
* three copies of the same decision, and this file exists to hold exactly one.
|
|
85
|
+
*/
|
|
86
|
+
export function TagChips({ slot, max = 4 }: { slot: PreviewSlot, max?: number }) {
|
|
87
|
+
|
|
88
|
+
if (slot.property && slot.propertyKey) {
|
|
89
|
+
return <SlotValue slot={slot} size="small"/>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const values = (Array.isArray(slot.value) ? slot.value : [slot.value])
|
|
93
|
+
.filter(item => item !== undefined && item !== null && String(item).trim() !== "")
|
|
94
|
+
.map(item => String(item));
|
|
95
|
+
|
|
96
|
+
if (values.length === 0) return null;
|
|
97
|
+
|
|
98
|
+
const shown = values.slice(0, max);
|
|
99
|
+
const hidden = values.length - shown.length;
|
|
100
|
+
|
|
101
|
+
return <>
|
|
102
|
+
{shown.map(value => (
|
|
103
|
+
<Chip key={value}
|
|
104
|
+
size="smallest"
|
|
105
|
+
className="!text-[10px] !leading-tight !py-0 shrink-0 max-w-[100px] truncate">
|
|
106
|
+
{value}
|
|
107
|
+
</Chip>
|
|
108
|
+
))}
|
|
109
|
+
{hidden > 0 && (
|
|
110
|
+
<span className="text-[10px] text-surface-400 dark:text-surface-500 shrink-0">+{hidden}</span>
|
|
111
|
+
)}
|
|
112
|
+
</>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether a computed image string can be fetched as-is, or has to be resolved
|
|
117
|
+
* through storage first.
|
|
118
|
+
*
|
|
119
|
+
* Exported because it is the whole decision: rendering either branch needs a
|
|
120
|
+
* provider (a storage source, a DOM), and the thing worth pinning is which
|
|
121
|
+
* branch a given string takes. `//host/x` is protocol-relative and equally
|
|
122
|
+
* fetchable; `data:` and `blob:` are already the bytes.
|
|
123
|
+
*/
|
|
124
|
+
export function isFetchableImageUrl(value: string): boolean {
|
|
125
|
+
return /^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A string a resolver returned for the `image` role, rendered as a picture.
|
|
130
|
+
*
|
|
131
|
+
* Two things can arrive here and they need different handling, which is the whole
|
|
132
|
+
* reason a property-path image is preferable: a storage property knows which
|
|
133
|
+
* backend holds it, and a bare string does not.
|
|
134
|
+
*
|
|
135
|
+
* - An **absolute URL** is already fetchable. Rendering it through the storage
|
|
136
|
+
* layer would ask a bucket to sign a path that was never in it.
|
|
137
|
+
* - Anything else is read as a **storage path** on the default source, which is
|
|
138
|
+
* the only source a value with no property can be attributed to. A resolver
|
|
139
|
+
* pointing at a named source should name the property instead.
|
|
140
|
+
*/
|
|
141
|
+
function ComputedImage({ value, size, fill }: { value: string, size: PreviewSize, fill?: boolean }) {
|
|
142
|
+
const isAbsolute = isFetchableImageUrl(value);
|
|
143
|
+
|
|
144
|
+
if (isAbsolute) {
|
|
145
|
+
return <UrlComponentPreview previewType="image"
|
|
146
|
+
url={value}
|
|
147
|
+
size={size}
|
|
148
|
+
fill={fill}
|
|
149
|
+
hint={value}/>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return <StorageThumbnail storagePathOrDownloadUrl={value}
|
|
153
|
+
storeUrl={false}
|
|
154
|
+
size={size}
|
|
155
|
+
fill={fill}/>;
|
|
156
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
import { IconButton, iconSize,
|
|
3
|
+
import { IconButton, iconSize, PanelLeftCloseIcon, Tooltip } from "@rebasepro/ui";
|
|
4
4
|
import { useTranslation } from "@rebasepro/app";
|
|
5
5
|
|
|
6
6
|
import { useSplitView } from "./SplitViewContext";
|
|
@@ -9,11 +9,24 @@ import { useSplitView } from "./SplitViewContext";
|
|
|
9
9
|
* Closes the list of a split view, leaving the open record on its own.
|
|
10
10
|
*
|
|
11
11
|
* It leads the record's own app bar, where the breadcrumb starts — this is the
|
|
12
|
-
* entity pane's old "Open full screen" button, moved to that edge
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* entity pane's old "Open full screen" button, moved to that edge: closing the
|
|
13
|
+
* list and opening the record full screen are the same thing, so there is one
|
|
14
|
+
* control and one route (`#full`) rather than two. Full screen carries the
|
|
15
|
+
* mirrored icon to bring the list back.
|
|
16
|
+
*
|
|
17
|
+
* A pane glyph rather than the double chevron it wore first. Now that the bar
|
|
18
|
+
* ends in a ✕ that dismisses the record, an arrow pointing off the left edge of
|
|
19
|
+
* the record's own bar was the other thing on it that read as "close me" — and
|
|
20
|
+
* it is the one control here that does not act on the record at all.
|
|
15
21
|
*/
|
|
16
|
-
export function SplitListCloseButton(
|
|
22
|
+
export function SplitListCloseButton({ onBeforeHide }: {
|
|
23
|
+
/**
|
|
24
|
+
* Run before the layout changes, while this form is still mounted: the edit
|
|
25
|
+
* view uses it to hand its edit in progress to the full-screen form that is
|
|
26
|
+
* about to show the same record.
|
|
27
|
+
*/
|
|
28
|
+
onBeforeHide?: () => void;
|
|
29
|
+
} = {}) {
|
|
17
30
|
|
|
18
31
|
const { t } = useTranslation();
|
|
19
32
|
const splitView = useSplitView();
|
|
@@ -24,9 +37,12 @@ export function SplitListCloseButton() {
|
|
|
24
37
|
<Tooltip title={t("hide_list")}>
|
|
25
38
|
<IconButton
|
|
26
39
|
size="small"
|
|
27
|
-
onClick={
|
|
40
|
+
onClick={() => {
|
|
41
|
+
onBeforeHide?.();
|
|
42
|
+
splitView.openFullScreen();
|
|
43
|
+
}}
|
|
28
44
|
aria-label={t("hide_list")}>
|
|
29
|
-
<
|
|
45
|
+
<PanelLeftCloseIcon size={iconSize.smallest}/>
|
|
30
46
|
</IconButton>
|
|
31
47
|
</Tooltip>
|
|
32
48
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { IconButton, iconSize, PanelLeftOpenIcon, Tooltip } from "@rebasepro/ui";
|
|
4
|
+
import { useTranslation } from "@rebasepro/app";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Brings the list back beside a record that was opened full screen.
|
|
8
|
+
*
|
|
9
|
+
* The mirror of {@link SplitListCloseButton}: the same edge of the same bar,
|
|
10
|
+
* the same pane glyph opening rather than closing, and the same single route —
|
|
11
|
+
* showing the list is dropping the `#full` hash. It replaces the back arrow
|
|
12
|
+
* where it applies, because the list it unfolds already holds the record.
|
|
13
|
+
*/
|
|
14
|
+
export function SplitListShowButton({ onClick }: { onClick: () => void }) {
|
|
15
|
+
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Tooltip title={t("show_list")}>
|
|
20
|
+
<IconButton
|
|
21
|
+
size="small"
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
aria-label={t("show_list")}>
|
|
24
|
+
<PanelLeftOpenIcon size={iconSize.smallest}/>
|
|
25
|
+
</IconButton>
|
|
26
|
+
</Tooltip>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -79,6 +79,19 @@ function isListShortcutTarget(target: HTMLElement | null, detailPanel: HTMLEleme
|
|
|
79
79
|
return !detailPanel?.contains(target);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The list's share of the split, as a percentage, before anyone drags it.
|
|
84
|
+
*
|
|
85
|
+
* The list only has to identify a record — a title, a subtitle and a status
|
|
86
|
+
* chip — while the detail beside it carries the whole record and its rail. At
|
|
87
|
+
* 30% the list was taking width from the side that had four columns of fields
|
|
88
|
+
* to lay out.
|
|
89
|
+
*
|
|
90
|
+
* A default, not a constraint: {@link savePanelSize} remembers the drag per
|
|
91
|
+
* collection, so anyone who has already sized this list keeps their size.
|
|
92
|
+
*/
|
|
93
|
+
const DEFAULT_PANEL_SIZE = 26;
|
|
94
|
+
|
|
82
95
|
function getSavedPanelSize(path: string): number {
|
|
83
96
|
try {
|
|
84
97
|
const saved = localStorage.getItem(`${PANEL_SIZE_KEY}_${path}`);
|
|
@@ -87,7 +100,7 @@ function getSavedPanelSize(path: string): number {
|
|
|
87
100
|
if (!isNaN(val) && val > 0 && val < 100) return val;
|
|
88
101
|
}
|
|
89
102
|
} catch { /* ignore */ }
|
|
90
|
-
return
|
|
103
|
+
return DEFAULT_PANEL_SIZE;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
function savePanelSize(path: string, size: number) {
|
|
@@ -150,8 +163,20 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
|
|
|
150
163
|
dirty.current = modified;
|
|
151
164
|
}, []);
|
|
152
165
|
|
|
153
|
-
// The form remounts per entity, so its dirty state must not leak into the
|
|
166
|
+
// The form remounts per entity, so its dirty state must not leak into the
|
|
167
|
+
// next one — and it has to be cleared here rather than left to the next
|
|
168
|
+
// form's own first report, because that form spends the load in a skeleton
|
|
169
|
+
// and a navigation during it would prompt about the record before it.
|
|
170
|
+
//
|
|
171
|
+
// On a *change* of record only, never on this view's own mount: a record
|
|
172
|
+
// arriving from full screen carries its edit back in and opens dirty, and
|
|
173
|
+
// the form says so from its first effect — which runs before this one, being
|
|
174
|
+
// a child's. Clearing unconditionally overwrote that report with `false`,
|
|
175
|
+
// and the way out of the split stopped asking about a live edit.
|
|
176
|
+
const previousEntityId = useRef(selectedEntityId);
|
|
154
177
|
useEffect(() => {
|
|
178
|
+
if (previousEntityId.current === selectedEntityId) return;
|
|
179
|
+
previousEntityId.current = selectedEntityId;
|
|
155
180
|
dirty.current = false;
|
|
156
181
|
}, [selectedEntityId]);
|
|
157
182
|
|
|
@@ -366,6 +391,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
|
|
|
366
391
|
parentEntityIds={usedParentEntityIds}
|
|
367
392
|
selectedTab={selectedTab}
|
|
368
393
|
layout="split"
|
|
394
|
+
onCloseRequest={handleCloseDetail}
|
|
369
395
|
onEditClick={() => {
|
|
370
396
|
navigate(buildUrl(`${path}/${renderedEntityId}/edit`));
|
|
371
397
|
}}
|
|
@@ -387,6 +413,11 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
|
|
|
387
413
|
parentEntityIds={usedParentEntityIds}
|
|
388
414
|
selectedTab={selectedTab}
|
|
389
415
|
layout="split"
|
|
416
|
+
// The ✕, and where "save and close" lands. A plain
|
|
417
|
+
// navigation: `useNavigationBlocker` above already
|
|
418
|
+
// stands between it and an unsaved edit, so the ✕ asks
|
|
419
|
+
// exactly what Escape and the browser's Back ask.
|
|
420
|
+
onCloseRequest={handleCloseDetail}
|
|
390
421
|
onValuesModified={setDirty}
|
|
391
422
|
onSaved={() => {
|
|
392
423
|
setDirty(false);
|
|
@@ -408,7 +408,8 @@ values: { ...e.values,
|
|
|
408
408
|
where: whereFilter,
|
|
409
409
|
limit: itemCount,
|
|
410
410
|
orderBy: orderByParam,
|
|
411
|
-
include: includeParams
|
|
411
|
+
include: includeParams,
|
|
412
|
+
searchString: currentSearchString
|
|
412
413
|
});
|
|
413
414
|
|
|
414
415
|
const onError = (error: Error) => {
|
|
@@ -446,7 +447,12 @@ values: { ...e.values,
|
|
|
446
447
|
where: whereFilter,
|
|
447
448
|
limit: itemCount,
|
|
448
449
|
orderBy: orderByParam,
|
|
449
|
-
include: includeParams
|
|
450
|
+
include: includeParams,
|
|
451
|
+
// Read into a local at the top of this function and then used
|
|
452
|
+
// nowhere, in both places it was read. The board took a search
|
|
453
|
+
// term, re-subscribed every column when it changed, and
|
|
454
|
+
// returned the same rows.
|
|
455
|
+
searchString: currentSearchString
|
|
450
456
|
}, res => {
|
|
451
457
|
liveDataReceived = true;
|
|
452
458
|
onUpdate(res.data as Entity<M>[]);
|
|
@@ -539,7 +545,10 @@ values: { ...e.values,
|
|
|
539
545
|
};
|
|
540
546
|
|
|
541
547
|
accessor.count({
|
|
542
|
-
where: whereFilter
|
|
548
|
+
where: whereFilter,
|
|
549
|
+
// Or the column header counts the unsearched collection
|
|
550
|
+
// while the column beneath it shows searched rows.
|
|
551
|
+
searchString: currentSearchString
|
|
543
552
|
}).then(count => {
|
|
544
553
|
if (isCleaningUpRef.current) return;
|
|
545
554
|
setColumnData(prev => ({
|