@rebasepro/admin 0.13.0 → 0.13.1-canary.g249daa1
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-DgfCYDmF.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-DgfCYDmF.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CCrruunW.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CCrruunW.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-C_EwDSi1.js} +9 -13
- package/dist/RouterCollectionsStudioView-C_EwDSi1.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/EntityCardBinding.d.ts +3 -1
- package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
- package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +92 -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-CV-qQfxK.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-CV-qQfxK.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-By7QT3V0.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-By7QT3V0.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-CYbvYa_x.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-CYbvYa_x.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/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-GiwPgxnL.js → util-B8hurkxt.js} +3569 -1692
- package/dist/util-B8hurkxt.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/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +51 -12
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +285 -0
- 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 +47 -7
- 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 +109 -62
- package/src/components/DrawerNavigationGroup.tsx +16 -39
- package/src/components/DrawerNavigationItem.tsx +44 -16
- package/src/components/EditViewBinding.tsx +233 -50
- 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 +344 -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 +23 -9
- package/src/components/SideDialogs.tsx +15 -3
- package/src/components/SidePanelBinding.tsx +122 -66
- package/src/components/UserSelector.tsx +20 -8
- 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 +74 -53
- 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 +21 -2
- 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/navigation_utils.ts +4 -2
- package/src/util/number_format.ts +46 -0
- package/src/util/previews.ts +8 -55
- package/src/util/property_utils.tsx +6 -1
- package/src/util/view_constants.ts +27 -0
- package/src/util/view_mode.ts +36 -0
- package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
|
|
2
|
-
import type { Property } from "@rebasepro/types";
|
|
2
|
+
import type { Properties, Property } from "@rebasepro/types";
|
|
3
3
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Entity } from "@rebasepro/types";
|
|
5
5
|
import { CollectionSize, EntityAction, EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
|
|
6
6
|
import {
|
|
7
|
+
ArrowDownIcon,
|
|
8
|
+
ArrowUpIcon,
|
|
7
9
|
Checkbox,
|
|
8
10
|
Chip,
|
|
9
|
-
CircularProgress,
|
|
10
11
|
cls,
|
|
11
12
|
defaultBorderMixin,
|
|
12
13
|
IconButton,
|
|
@@ -20,13 +21,18 @@ import {
|
|
|
20
21
|
useCustomizationController
|
|
21
22
|
} from "@rebasepro/app";
|
|
22
23
|
import { useAnalyticsController } from "@rebasepro/app";
|
|
23
|
-
import { getEntityPreviewKeys } from "../../util/previews";
|
|
24
24
|
import { IconForView } from "@rebasepro/app";
|
|
25
25
|
import { getIcon } from "@rebasepro/app";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { hasDeclaredDisplay } from "@rebasepro/app";
|
|
27
|
+
import { formatRelativeTime, getValueInPath } from "@rebasepro/utils";
|
|
28
|
+
import { useCollectionSlotKeys, useEntitySlots, type CollectionSlotKeys, type EntityPreviewSlots } from "./usePreviewSlots";
|
|
29
|
+
import { SlotValue, TagChips } from "./SlotValue";
|
|
30
|
+
import { Highlighted } from "./SearchHighlight";
|
|
31
|
+
import { useSearchExplanation, MatchExplanation, fieldLabel } from "./SearchExplanation";
|
|
28
32
|
import { useAdminContext } from "../../hooks/useAdminContext";
|
|
29
33
|
import { resolveEntityAction } from "../../util/resolutions";
|
|
34
|
+
import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
|
|
35
|
+
import { getResolvedPropertyInPath } from "../../util/property_utils";
|
|
30
36
|
|
|
31
37
|
export type CollectionListViewBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
|
|
32
38
|
collection: AdminCollection<M>;
|
|
@@ -69,17 +75,79 @@ export type CollectionListViewBindingProps<M extends Record<string, unknown> = R
|
|
|
69
75
|
openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
70
76
|
};
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Which display slot fills a column, when a slot does rather than a property.
|
|
80
|
+
* A status is an enum chip and a date a relative timestamp wherever they come
|
|
81
|
+
* from, so the cell renders the slot; the column exists either way, because a
|
|
82
|
+
* header needs something to sit over.
|
|
83
|
+
*/
|
|
84
|
+
type ListColumnSlot = "tags" | "status" | "date";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One column of the list, in the header and in every row.
|
|
88
|
+
*
|
|
89
|
+
* The header and the cells read the same definition — the same width, the same
|
|
90
|
+
* alignment, the same decision about being shown at this container width — so a
|
|
91
|
+
* label cannot end up over a column that is not there, or over the wrong one.
|
|
92
|
+
*/
|
|
93
|
+
type ListColumn = {
|
|
73
94
|
key: string;
|
|
74
95
|
label: string;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
isDate?: boolean;
|
|
96
|
+
/** Set when a display slot fills the cell; absent when the record does. */
|
|
97
|
+
slot?: ListColumnSlot;
|
|
98
|
+
property?: Property;
|
|
79
99
|
align: "left" | "center" | "right";
|
|
80
100
|
width: string;
|
|
101
|
+
/**
|
|
102
|
+
* What {@link width} costs in pixels, so the fit calculation can budget in
|
|
103
|
+
* the same units the row lays out in. A flat per-column estimate treated a
|
|
104
|
+
* relation (w-64) as costing the same as a date (w-28), which is where a row
|
|
105
|
+
* ran out of width for the title while still claiming everything fit.
|
|
106
|
+
*/
|
|
107
|
+
widthPx: number;
|
|
108
|
+
/** Whether clicking this header can order the collection by it. */
|
|
109
|
+
sortable: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* What survives when the row runs out of width: the lowest goes first.
|
|
112
|
+
*/
|
|
113
|
+
priority: number;
|
|
81
114
|
};
|
|
82
115
|
|
|
116
|
+
// ── Column priorities ─────────────────────────────────────────────────
|
|
117
|
+
// Read as "what a narrowing row gives up first".
|
|
118
|
+
//
|
|
119
|
+
// Three bands. The sort or filter *in force* comes first: it is the reason the
|
|
120
|
+
// rows are in the order they are in, and hiding the column it names leaves that
|
|
121
|
+
// order unexplained. The collection's own columns come next — its `listProperties`,
|
|
122
|
+
// or the status and date it was read as having. A column left over from a
|
|
123
|
+
// request since cleared comes last: it is worth keeping while there is room for
|
|
124
|
+
// it, and it is not worth the collection's own status.
|
|
125
|
+
|
|
126
|
+
/** A column left over from a sort or filter that is no longer applied. */
|
|
127
|
+
const PRIORITY_STALE_REQUEST = 1000;
|
|
128
|
+
/** The collection's own columns. Slots sit just above it, in give-way order. */
|
|
129
|
+
const PRIORITY_DECLARED = 2000;
|
|
130
|
+
const PRIORITY_DATE = PRIORITY_DECLARED;
|
|
131
|
+
const PRIORITY_STATUS = PRIORITY_DECLARED + 1;
|
|
132
|
+
const PRIORITY_TAGS = PRIORITY_DECLARED + 2;
|
|
133
|
+
/** The sort or filter currently in force. */
|
|
134
|
+
const PRIORITY_ACTIVE_REQUEST = 3000;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How wide a column of this property wants to be — the Tailwind class the cell
|
|
138
|
+
* gets, and the pixel cost of that class. The two are returned together because
|
|
139
|
+
* they must not drift: the budget is only meaningful if it describes the widths
|
|
140
|
+
* actually rendered. `lg:` variants key off the viewport, not this panel, so a
|
|
141
|
+
* narrow split on a wide screen gets the larger of the two — the cost we budget.
|
|
142
|
+
*/
|
|
143
|
+
function getIdealColumnWidth(prop: Property): { width: string, widthPx: number } {
|
|
144
|
+
if (prop.type === "string" && "enum" in prop) return { width: "flex-shrink-0 w-32", widthPx: 128 };
|
|
145
|
+
if (prop.type === "date" || prop.type === "number" || prop.type === "boolean") return { width: "flex-shrink-0 w-28", widthPx: 112 };
|
|
146
|
+
if (prop.type === "reference" || prop.type === "relation") return { width: "flex-shrink-0 w-56 lg:w-64", widthPx: 256 };
|
|
147
|
+
if (prop.type === "string") return { width: "flex-shrink-0 w-40 lg:w-48", widthPx: 192 };
|
|
148
|
+
return { width: "flex-shrink-0 w-40", widthPx: 160 };
|
|
149
|
+
}
|
|
150
|
+
|
|
83
151
|
/**
|
|
84
152
|
* Get row padding/spacing classes based on size
|
|
85
153
|
*/
|
|
@@ -94,47 +162,47 @@ function getRowClasses(size: CollectionSize): string {
|
|
|
94
162
|
}
|
|
95
163
|
}
|
|
96
164
|
|
|
165
|
+
// ── Row layout budget ─────────────────────────────────────────────────
|
|
166
|
+
// What the row spends before a single column is placed. These mirror the
|
|
167
|
+
// classes on the row itself (`px-5`, `gap-4`, `w-8` checkbox, `w-10` image);
|
|
168
|
+
// they are the same layout described twice, so a change to one is a change to
|
|
169
|
+
// the other.
|
|
170
|
+
|
|
171
|
+
/** `px-5` on both sides. */
|
|
172
|
+
const ROW_PADDING_WIDTH = 40;
|
|
173
|
+
/** The checkbox cell (`w-8`). */
|
|
174
|
+
const CHECKBOX_WIDTH = 32;
|
|
175
|
+
/** The thumbnail / icon square (`w-10`). */
|
|
176
|
+
const IMAGE_WIDTH = 40;
|
|
177
|
+
/** `gap-4` between every cell of the row. */
|
|
178
|
+
const COLUMN_GAP = 16;
|
|
179
|
+
|
|
97
180
|
/**
|
|
98
|
-
*
|
|
181
|
+
* The title's share of a row, taken before any column bids for width.
|
|
182
|
+
*
|
|
183
|
+
* A title is a name, and a name truncated to "Lider Sr. en…" has stopped being
|
|
184
|
+
* one. Columns are a scanner's convenience; the title is the row's identity, so
|
|
185
|
+
* a narrow list spends its width on the title and simply shows fewer columns.
|
|
99
186
|
*/
|
|
100
|
-
|
|
101
|
-
switch (size) {
|
|
102
|
-
case "xs": return 44;
|
|
103
|
-
case "s": return 52;
|
|
104
|
-
case "m": return 64;
|
|
105
|
-
case "l": return 76;
|
|
106
|
-
case "xl": return 88;
|
|
107
|
-
default: return 64;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
187
|
+
const TITLE_COMFORTABLE_WIDTH = 320;
|
|
110
188
|
|
|
111
|
-
/**
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
189
|
+
/** An `IconButton size="small"` (`w-8`), which is what a row action renders as. */
|
|
190
|
+
const ACTION_BUTTON_WIDTH = 32;
|
|
191
|
+
/** `gap-0.5` between row actions. */
|
|
192
|
+
const ACTION_BUTTON_GAP = 2;
|
|
193
|
+
/**
|
|
194
|
+
* How many rows are read to size the actions cell.
|
|
195
|
+
*
|
|
196
|
+
* Actions are resolved per record, so the width they need is only knowable by
|
|
197
|
+
* asking — and asking every loaded row would mean re-resolving thousands of
|
|
198
|
+
* them on every page. The first rows are representative: a collection whose
|
|
199
|
+
* actions vary at all varies within its first screenful.
|
|
200
|
+
*/
|
|
201
|
+
const ACTIONS_SAMPLE_SIZE = 20;
|
|
116
202
|
|
|
117
203
|
/** Stable empty array for when no list-view actions are available. */
|
|
118
204
|
const EMPTY_LIST_VIEW_ACTIONS: EntityAction[] = [];
|
|
119
205
|
|
|
120
|
-
/**
|
|
121
|
-
* Walk up the DOM from `element` to find the nearest scrollable ancestor.
|
|
122
|
-
*/
|
|
123
|
-
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
|
|
124
|
-
let parent = element?.parentElement ?? null;
|
|
125
|
-
while (parent) {
|
|
126
|
-
const style = getComputedStyle(parent);
|
|
127
|
-
if (
|
|
128
|
-
style.overflowY === "auto" || style.overflowY === "scroll" ||
|
|
129
|
-
style.overflow === "auto" || style.overflow === "scroll"
|
|
130
|
-
) {
|
|
131
|
-
return parent;
|
|
132
|
-
}
|
|
133
|
-
parent = parent.parentElement;
|
|
134
|
-
}
|
|
135
|
-
return document.documentElement;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
206
|
/**
|
|
139
207
|
* Returns true if a property type should NOT be rendered via
|
|
140
208
|
* PropertyPreview in list row columns (because it would blow up height).
|
|
@@ -235,9 +303,15 @@ function compactValueSummary(value: unknown, property: Property): string | null
|
|
|
235
303
|
}
|
|
236
304
|
|
|
237
305
|
/**
|
|
238
|
-
* Format a date value for display in the list
|
|
306
|
+
* Format a date value for display in the list.
|
|
307
|
+
*
|
|
308
|
+
* A date column holds whatever the developer put in it, so the value is as
|
|
309
|
+
* likely to be ahead of now as behind it. The relative phrase therefore comes
|
|
310
|
+
* from {@link formatRelativeTime}, which reads the sign: a row due this
|
|
311
|
+
* afternoon says "in 2h" rather than the "-1d ago" that flooring a negative
|
|
312
|
+
* difference used to produce.
|
|
239
313
|
*/
|
|
240
|
-
function formatDateValue(value: unknown): string | null {
|
|
314
|
+
function formatDateValue(value: unknown, now: Date = new Date()): string | null {
|
|
241
315
|
if (!value) return null;
|
|
242
316
|
let date: Date | null = null;
|
|
243
317
|
if (value instanceof Date) {
|
|
@@ -247,22 +321,25 @@ function formatDateValue(value: unknown): string | null {
|
|
|
247
321
|
}
|
|
248
322
|
if (!date || isNaN(date.getTime())) return null;
|
|
249
323
|
|
|
250
|
-
const now = new Date();
|
|
251
324
|
const diffMs = now.getTime() - date.getTime();
|
|
252
325
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
253
326
|
|
|
327
|
+
// Today and yesterday get their own wording; both are strictly in the past,
|
|
328
|
+
// because a negative difference floors to -1 and falls through to below.
|
|
254
329
|
if (diffDays === 0) {
|
|
255
330
|
return date.toLocaleTimeString(undefined, { hour: "2-digit",
|
|
256
331
|
minute: "2-digit" });
|
|
257
|
-
} else if (diffDays === 1) {
|
|
258
|
-
return "Yesterday";
|
|
259
|
-
} else if (diffDays < 7) {
|
|
260
|
-
return `${diffDays}d ago`;
|
|
261
|
-
} else {
|
|
262
|
-
return date.toLocaleDateString(undefined, { month: "short",
|
|
263
|
-
day: "numeric",
|
|
264
|
-
year: diffDays > 365 ? "numeric" : undefined });
|
|
265
332
|
}
|
|
333
|
+
if (diffDays === 1) return "Yesterday";
|
|
334
|
+
|
|
335
|
+
const relative = formatRelativeTime(date, { now });
|
|
336
|
+
if (relative) return relative;
|
|
337
|
+
|
|
338
|
+
// Comparing years rather than counting days: a date six weeks back across a
|
|
339
|
+
// new year needs the year, and one 400 days ahead needs it just as much.
|
|
340
|
+
return date.toLocaleDateString(undefined, { month: "short",
|
|
341
|
+
day: "numeric",
|
|
342
|
+
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
|
266
343
|
}
|
|
267
344
|
|
|
268
345
|
/**
|
|
@@ -293,14 +370,21 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
293
370
|
const context = useAdminContext();
|
|
294
371
|
|
|
295
372
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
296
|
-
|
|
373
|
+
// `undefined` is "not measured yet", and it renders the title alone. The
|
|
374
|
+
// seed used to be a 1200px guess, which is a claim about a list that may be
|
|
375
|
+
// a 300px column of a split — and, because the ref below was never attached
|
|
376
|
+
// to anything, a guess nothing ever corrected.
|
|
377
|
+
const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined);
|
|
297
378
|
|
|
298
379
|
// Track container width for responsive column visibility
|
|
299
380
|
useEffect(() => {
|
|
300
381
|
const el = containerRef.current;
|
|
301
382
|
if (!el) return;
|
|
383
|
+
const measure = (width: number) => setContainerWidth(width > 0 ? width : undefined);
|
|
384
|
+
measure(el.getBoundingClientRect().width);
|
|
385
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
302
386
|
const ro = new ResizeObserver(([entry]) => {
|
|
303
|
-
if (entry)
|
|
387
|
+
if (entry) measure(entry.contentRect.width);
|
|
304
388
|
});
|
|
305
389
|
ro.observe(el);
|
|
306
390
|
return () => ro.disconnect();
|
|
@@ -314,16 +398,14 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
314
398
|
itemCount,
|
|
315
399
|
setItemCount,
|
|
316
400
|
pageSize = 50,
|
|
317
|
-
paginationEnabled
|
|
401
|
+
paginationEnabled,
|
|
402
|
+
sortBy,
|
|
403
|
+
setSortBy,
|
|
404
|
+
filterValues,
|
|
405
|
+
checkFilterCombination,
|
|
406
|
+
searchString
|
|
318
407
|
} = tableController;
|
|
319
408
|
|
|
320
|
-
const isLoadingMore = useRef(false);
|
|
321
|
-
|
|
322
|
-
// Reset the loading-more gate when new data arrives
|
|
323
|
-
useEffect(() => {
|
|
324
|
-
if (!dataLoading) isLoadingMore.current = false;
|
|
325
|
-
}, [dataLoading]);
|
|
326
|
-
|
|
327
409
|
const resolvedCollection = collection;
|
|
328
410
|
|
|
329
411
|
// ── Shared slot resolution (replaces 4 individual useMemo calls) ──
|
|
@@ -334,158 +416,292 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
334
416
|
);
|
|
335
417
|
const { titleKey: titlePropertyKey, imageKey: imagePropertyKey, subtitleKey, statusKey: statusPropertyKey, dateKey: datePropertyKey } = slotKeys;
|
|
336
418
|
|
|
337
|
-
const
|
|
338
|
-
() => getEntityPreviewKeys(authController, resolvedCollection, customizationController.propertyConfigs, undefined, 10)
|
|
339
|
-
.filter(key => key !== titlePropertyKey && key !== imagePropertyKey && key !== statusPropertyKey && key !== datePropertyKey),
|
|
340
|
-
[authController, resolvedCollection, customizationController.propertyConfigs, titlePropertyKey, imagePropertyKey, statusPropertyKey, datePropertyKey]
|
|
341
|
-
);
|
|
419
|
+
const columnMode = !!resolvedCollection.listProperties && resolvedCollection.listProperties.length > 0;
|
|
342
420
|
|
|
343
|
-
|
|
344
|
-
|
|
421
|
+
/**
|
|
422
|
+
* The properties an `ORDER BY` can be written for, under the same authority
|
|
423
|
+
* the table headers and the sort menu ask — so a header that offers a sort
|
|
424
|
+
* here offers it there, and none of the three invents one the driver would
|
|
425
|
+
* silently drop.
|
|
426
|
+
*/
|
|
427
|
+
const sortableKeys = useMemo(
|
|
428
|
+
() => new Set(getSortablePropertyOptions(resolvedCollection.properties as Properties).map(option => option.key)),
|
|
429
|
+
[resolvedCollection.properties]
|
|
430
|
+
);
|
|
345
431
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
432
|
+
/** The row's identity cell: the leading column, or the title slot. */
|
|
433
|
+
const titleColumn = useMemo<ListColumn | undefined>(() => {
|
|
434
|
+
const key = columnMode ? resolvedCollection.listProperties?.[0] : titlePropertyKey;
|
|
435
|
+
if (!key) return undefined;
|
|
436
|
+
const property = resolvedCollection.properties[key] as Property | undefined;
|
|
437
|
+
return {
|
|
438
|
+
key,
|
|
439
|
+
label: property?.name || (columnMode ? key : "Name"),
|
|
440
|
+
property,
|
|
441
|
+
align: "left",
|
|
442
|
+
width: "flex-1 min-w-0",
|
|
443
|
+
widthPx: TITLE_COMFORTABLE_WIDTH,
|
|
444
|
+
sortable: sortableKeys.has(key),
|
|
445
|
+
priority: Number.MAX_SAFE_INTEGER
|
|
352
446
|
};
|
|
447
|
+
}, [columnMode, resolvedCollection, titlePropertyKey, sortableKeys]);
|
|
353
448
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
449
|
+
/**
|
|
450
|
+
* The trailing columns the collection itself implies: the developer's
|
|
451
|
+
* `listProperties` when there are any, otherwise the display slots the row
|
|
452
|
+
* has always shown to the right of the title.
|
|
453
|
+
*/
|
|
454
|
+
const declaredColumns = useMemo<ListColumn[]>(() => {
|
|
455
|
+
if (columnMode) {
|
|
456
|
+
const keys = resolvedCollection.listProperties!.slice(1);
|
|
457
|
+
return keys.flatMap((key, index) => {
|
|
458
|
+
const property = resolvedCollection.properties[key] as Property | undefined;
|
|
459
|
+
if (!property) return [];
|
|
460
|
+
return [{
|
|
360
461
|
key,
|
|
361
|
-
label:
|
|
362
|
-
property
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
462
|
+
label: property.name || key,
|
|
463
|
+
property,
|
|
464
|
+
align: (property.type === "number" || property.type === "date" ? "right" : "left") as ListColumn["align"],
|
|
465
|
+
...getIdealColumnWidth(property),
|
|
466
|
+
sortable: sortableKeys.has(key),
|
|
467
|
+
// Declared order is a statement of importance: the first
|
|
468
|
+
// column after the title outlives the last one.
|
|
469
|
+
priority: PRIORITY_DECLARED + (keys.length - index)
|
|
470
|
+
}];
|
|
367
471
|
});
|
|
368
|
-
return cols;
|
|
369
472
|
}
|
|
370
473
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
474
|
+
const cols: ListColumn[] = [];
|
|
475
|
+
|
|
476
|
+
// A slot column exists when the collection has something to put in it —
|
|
477
|
+
// a property to read, or a resolver that computes one. Whether a given
|
|
478
|
+
// record fills it is a per-row question, and the wrong one to ask here:
|
|
479
|
+
// a column that came and went with the values under it would leave its
|
|
480
|
+
// header over a different cell on every row.
|
|
481
|
+
const slotColumn = (
|
|
482
|
+
slot: ListColumnSlot,
|
|
483
|
+
key: string | undefined,
|
|
484
|
+
fallbackLabel: string,
|
|
485
|
+
layout: { align: ListColumn["align"], width: string, widthPx: number },
|
|
486
|
+
priority: number
|
|
487
|
+
): ListColumn | undefined => {
|
|
488
|
+
const declared = hasDeclaredDisplay(resolvedCollection, slot);
|
|
489
|
+
if (!key && !declared) return undefined;
|
|
490
|
+
const property = key ? resolvedCollection.properties[key] as Property | undefined : undefined;
|
|
491
|
+
return {
|
|
492
|
+
key: key ?? `display:${slot}`,
|
|
493
|
+
label: property?.name || fallbackLabel,
|
|
494
|
+
slot,
|
|
495
|
+
property,
|
|
496
|
+
...layout,
|
|
497
|
+
// A computed slot has no column to order by, whatever it renders.
|
|
498
|
+
sortable: !!key && sortableKeys.has(key),
|
|
499
|
+
priority
|
|
500
|
+
};
|
|
501
|
+
};
|
|
392
502
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
503
|
+
const tags = slotColumn(
|
|
504
|
+
"tags",
|
|
505
|
+
slotKeys.tagsKey,
|
|
506
|
+
"Tags",
|
|
507
|
+
{ align: "left", width: "flex-shrink-0 w-40", widthPx: 160 },
|
|
508
|
+
PRIORITY_TAGS
|
|
509
|
+
);
|
|
510
|
+
if (tags) cols.push(tags);
|
|
511
|
+
|
|
512
|
+
const status = slotColumn(
|
|
513
|
+
"status",
|
|
398
514
|
statusPropertyKey,
|
|
515
|
+
"Status",
|
|
516
|
+
{ align: "left", width: "flex-shrink-0 w-32", widthPx: 128 },
|
|
517
|
+
PRIORITY_STATUS
|
|
518
|
+
);
|
|
519
|
+
if (status) cols.push(status);
|
|
520
|
+
|
|
521
|
+
const date = slotColumn(
|
|
522
|
+
"date",
|
|
399
523
|
datePropertyKey,
|
|
400
|
-
|
|
401
|
-
"
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (usedKeys.has(k)) return false;
|
|
406
|
-
const prop = resolvedCollection.properties[k] as Property | undefined;
|
|
407
|
-
if (!prop) return false;
|
|
408
|
-
// Exclude storage/image properties — they are already rendered in the image slot
|
|
409
|
-
if (prop.type === "string" && (prop.storage || prop.admin?.urlPreview === "image")) return false;
|
|
410
|
-
if (prop.type === "array" && prop.of && !Array.isArray(prop.of)) {
|
|
411
|
-
const inner = prop.of;
|
|
412
|
-
if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return false;
|
|
413
|
-
}
|
|
414
|
-
return true;
|
|
415
|
-
});
|
|
524
|
+
"Modified",
|
|
525
|
+
{ align: "right", width: "flex-shrink-0 w-20", widthPx: 80 },
|
|
526
|
+
PRIORITY_DATE
|
|
527
|
+
);
|
|
528
|
+
if (date) cols.push(date);
|
|
416
529
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
530
|
+
return cols;
|
|
531
|
+
}, [columnMode, resolvedCollection, slotKeys.tagsKey, statusPropertyKey, datePropertyKey, sortableKeys]);
|
|
532
|
+
|
|
533
|
+
/** What the row already shows without a column of its own. */
|
|
534
|
+
const shownKeys = useMemo(() => new Set<string | undefined>([
|
|
535
|
+
titleColumn?.key,
|
|
536
|
+
imagePropertyKey,
|
|
537
|
+
subtitleKey,
|
|
538
|
+
...declaredColumns.map(col => col.key),
|
|
539
|
+
// The relation chips under the title are as visible as any column.
|
|
540
|
+
...(columnMode ? [] : slotKeys.relationKeys)
|
|
541
|
+
]), [titleColumn, imagePropertyKey, subtitleKey, declaredColumns, columnMode, slotKeys.relationKeys]);
|
|
542
|
+
|
|
543
|
+
/** The property the sort names, when no cell already shows it. */
|
|
544
|
+
const sortedKey = useMemo(() => {
|
|
545
|
+
const key = sortBy?.[0];
|
|
546
|
+
return key && !shownKeys.has(key) ? key : undefined;
|
|
547
|
+
}, [sortBy, shownKeys]);
|
|
548
|
+
|
|
549
|
+
/** The properties the filters name, minus the ones a cell already shows. */
|
|
550
|
+
const filteredKeys = useMemo(
|
|
551
|
+
() => Object.keys(filterValues ?? {}).filter(key => !shownKeys.has(key)),
|
|
552
|
+
[filterValues, shownKeys]
|
|
553
|
+
);
|
|
427
554
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
555
|
+
/**
|
|
556
|
+
* The last property the sort named, and the last set the filters named.
|
|
557
|
+
*
|
|
558
|
+
* A column earns its place when a sort or a filter names it, and keeps it
|
|
559
|
+
* once that sort or filter is cleared: taking it back would move every
|
|
560
|
+
* column right of it at the moment the user is comparing values across rows,
|
|
561
|
+
* and clearing a sort says nothing about wanting to stop seeing the property
|
|
562
|
+
* it sorted on.
|
|
563
|
+
*
|
|
564
|
+
* What it does *not* do is accumulate. There is only ever one sort, so
|
|
565
|
+
* ordering by four properties in turn is changing one's mind four times, not
|
|
566
|
+
* asking for four columns — and asking for four is how the row lost its
|
|
567
|
+
* status and its date to a wall of numbers. A new sort therefore replaces
|
|
568
|
+
* the property the last one left behind, and a new set of filters replaces
|
|
569
|
+
* the set before it. At most one sort column and one set of filter columns
|
|
570
|
+
* outlive their request.
|
|
571
|
+
*
|
|
572
|
+
* The list remounts per collection (it is keyed by path), so this is the
|
|
573
|
+
* memory of one visit to one collection, not a preference.
|
|
574
|
+
*/
|
|
575
|
+
const [lastSortedKey, setLastSortedKey] = useState<string | undefined>(undefined);
|
|
576
|
+
const [lastFilteredKeys, setLastFilteredKeys] = useState<string[]>([]);
|
|
441
577
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
cols.push({
|
|
446
|
-
key: datePropertyKey,
|
|
447
|
-
label: prop.name || "Modified",
|
|
448
|
-
property: prop,
|
|
449
|
-
isDate: true,
|
|
450
|
-
align: "right",
|
|
451
|
-
width: "flex-shrink-0 w-28"
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
}
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
if (sortedKey) setLastSortedKey(sortedKey);
|
|
580
|
+
}, [sortedKey]);
|
|
455
581
|
|
|
456
|
-
|
|
457
|
-
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
if (filteredKeys.length === 0) return;
|
|
584
|
+
// Same members, same array: `filteredKeys` is rebuilt every render, and
|
|
585
|
+
// storing an equal copy would re-render every consumer of the columns.
|
|
586
|
+
setLastFilteredKeys(previous =>
|
|
587
|
+
previous.length === filteredKeys.length && previous.every((key, i) => key === filteredKeys[i])
|
|
588
|
+
? previous
|
|
589
|
+
: filteredKeys);
|
|
590
|
+
}, [filteredKeys]);
|
|
458
591
|
|
|
459
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Columns for what the user asked to sort or filter by and cannot see.
|
|
594
|
+
*
|
|
595
|
+
* Ordering a list by a property no cell shows produces a shuffle with no
|
|
596
|
+
* explanation in it — the rows move and nothing on screen says why. So the
|
|
597
|
+
* property joins the row as its own column, last: the filters' first, in
|
|
598
|
+
* their own order, then the sort's.
|
|
599
|
+
*/
|
|
600
|
+
const requestedColumns = useMemo<ListColumn[]>(() => {
|
|
601
|
+
// The live request where there is one, the remembered one otherwise —
|
|
602
|
+
// read in that order rather than from state alone, because the effects
|
|
603
|
+
// above have not run yet on the render that first sees a request, and
|
|
604
|
+
// waiting a frame would flash the row without its new column.
|
|
605
|
+
const active = new Set([...filteredKeys, ...(sortedKey ? [sortedKey] : [])]);
|
|
606
|
+
const keys: string[] = [];
|
|
607
|
+
const request = (key: string | undefined) => {
|
|
608
|
+
if (!key || keys.includes(key) || shownKeys.has(key)) return;
|
|
609
|
+
keys.push(key);
|
|
610
|
+
};
|
|
611
|
+
(filteredKeys.length > 0 ? filteredKeys : lastFilteredKeys).forEach(request);
|
|
612
|
+
request(sortedKey ?? lastSortedKey);
|
|
460
613
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
614
|
+
return keys.flatMap((key, index) => {
|
|
615
|
+
const property = getResolvedPropertyInPath(resolvedCollection.properties, key) as Property | undefined;
|
|
616
|
+
if (!property) return [];
|
|
617
|
+
return [{
|
|
618
|
+
key,
|
|
619
|
+
label: property.name || key,
|
|
620
|
+
property,
|
|
621
|
+
align: (property.type === "number" || property.type === "date" ? "right" : "left") as ListColumn["align"],
|
|
622
|
+
...getIdealColumnWidth(property),
|
|
623
|
+
sortable: sortableKeys.has(key),
|
|
624
|
+
priority: (active.has(key) ? PRIORITY_ACTIVE_REQUEST : PRIORITY_STALE_REQUEST) - index
|
|
625
|
+
}];
|
|
626
|
+
});
|
|
627
|
+
}, [filteredKeys, sortedKey, lastFilteredKeys, lastSortedKey, shownKeys, resolvedCollection, sortableKeys]);
|
|
467
628
|
|
|
468
|
-
|
|
469
|
-
|
|
629
|
+
// ── Compute list-view-visible actions per entity ──
|
|
630
|
+
const getListViewActions = useCallback((entity: Entity<M>): EntityAction[] => {
|
|
631
|
+
if (!getActionsForEntity) return EMPTY_LIST_VIEW_ACTIONS;
|
|
632
|
+
const customEntityActions = (collection.entityActions ?? [])
|
|
633
|
+
.map(action => resolveEntityAction(action, customizationController.entityActions))
|
|
634
|
+
.filter(Boolean) as EntityAction<M>[];
|
|
635
|
+
const allActions = getActionsForEntity({ entity,
|
|
636
|
+
customEntityActions });
|
|
637
|
+
return allActions.filter(a => a.showActionsInListView);
|
|
638
|
+
}, [getActionsForEntity, collection.entityActions, customizationController.entityActions]);
|
|
470
639
|
|
|
471
|
-
|
|
472
|
-
const baseWidth = 40 + (selectionEnabled ? 40 : 0) + (showImage ? 56 : 0) + 200;
|
|
473
|
-
const availableForExtra = Math.max(0, containerWidth - baseWidth);
|
|
640
|
+
const showImage = size !== "xs";
|
|
474
641
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
642
|
+
/**
|
|
643
|
+
* Does the checkbox ride on the thumbnail rather than sit beside it?
|
|
644
|
+
*
|
|
645
|
+
* Only in the split's compact list — the one layout where the list is a
|
|
646
|
+
* column beside a record rather than the page itself, and where the 48px of
|
|
647
|
+
* checkbox cell is 48px the title does not get. Everywhere else the list has
|
|
648
|
+
* the room, and a checkbox that is always there is easier to find than one
|
|
649
|
+
* that appears under the pointer.
|
|
650
|
+
*
|
|
651
|
+
* Needs a thumbnail to ride on: at `xs` there is none, so the checkbox keeps
|
|
652
|
+
* its cell.
|
|
653
|
+
*/
|
|
654
|
+
const combineSelection = openEntityMode === "split" && selectedEntityId !== undefined && showImage;
|
|
478
655
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
656
|
+
/**
|
|
657
|
+
* How much room the action buttons take, reserved identically in the header
|
|
658
|
+
* and in every row so the columns line up above one another whether or not a
|
|
659
|
+
* given record has actions.
|
|
660
|
+
*/
|
|
661
|
+
const actionsWidth = useMemo(() => {
|
|
662
|
+
if (!getActionsForEntity) return 0;
|
|
663
|
+
let most = 0;
|
|
664
|
+
for (const entity of data.slice(0, ACTIONS_SAMPLE_SIZE)) {
|
|
665
|
+
most = Math.max(most, getListViewActions(entity).length);
|
|
482
666
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
667
|
+
return most === 0 ? 0 : most * ACTION_BUTTON_WIDTH + (most - 1) * ACTION_BUTTON_GAP;
|
|
668
|
+
}, [data, getActionsForEntity, getListViewActions]);
|
|
669
|
+
|
|
670
|
+
// Responsive: keep only the columns the row can actually pay for.
|
|
671
|
+
//
|
|
672
|
+
// The title is what a row is *for* — it is the one cell that identifies the
|
|
673
|
+
// record — so it is not one competitor among the columns: it takes its
|
|
674
|
+
// comfortable share off the top, and the columns divide what is left. What
|
|
675
|
+
// gives way when there is not enough is decided by `priority` rather than by
|
|
676
|
+
// position, so the column the user just sorted by outlives the inferred
|
|
677
|
+
// ones; the survivors keep their declared order, because a row that
|
|
678
|
+
// reordered itself as it resized would be unreadable.
|
|
679
|
+
const visibleColumns = useMemo(() => {
|
|
680
|
+
const columns = [...declaredColumns, ...requestedColumns];
|
|
681
|
+
if (columns.length === 0) return columns;
|
|
682
|
+
|
|
683
|
+
// Unmeasured: the title alone, which is the answer we would rather flash
|
|
684
|
+
// than a row of columns squeezing it down to an ellipsis.
|
|
685
|
+
if (containerWidth === undefined) return [];
|
|
686
|
+
|
|
687
|
+
const chrome = ROW_PADDING_WIDTH
|
|
688
|
+
+ (selectionEnabled && !combineSelection ? CHECKBOX_WIDTH + COLUMN_GAP : 0)
|
|
689
|
+
+ (showImage ? IMAGE_WIDTH + COLUMN_GAP : 0)
|
|
690
|
+
+ (actionsWidth > 0 ? actionsWidth + COLUMN_GAP : 0);
|
|
691
|
+
const available = containerWidth - chrome - TITLE_COMFORTABLE_WIDTH;
|
|
692
|
+
|
|
693
|
+
const cost = (col: ListColumn) => col.widthPx + COLUMN_GAP;
|
|
694
|
+
let total = columns.reduce((acc, col) => acc + cost(col), 0);
|
|
695
|
+
if (total <= available) return columns;
|
|
696
|
+
|
|
697
|
+
const dropped = new Set<string>();
|
|
698
|
+
for (const col of [...columns].sort((a, b) => a.priority - b.priority)) {
|
|
699
|
+
if (total <= available) break;
|
|
700
|
+
dropped.add(col.key);
|
|
701
|
+
total -= cost(col);
|
|
702
|
+
}
|
|
703
|
+
return columns.filter(col => !dropped.has(col.key));
|
|
704
|
+
}, [declaredColumns, requestedColumns, containerWidth, selectionEnabled, combineSelection, showImage, actionsWidth]);
|
|
489
705
|
|
|
490
706
|
const handleEntityClick = useCallback((entity: Entity<M>) => {
|
|
491
707
|
analyticsController.onAnalyticsEvent?.("entity_click", {
|
|
@@ -507,16 +723,6 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
507
723
|
return highlightedEntities?.some(e => e.id === entity.id && e.path === entity.path) ?? false;
|
|
508
724
|
}, [highlightedEntities]);
|
|
509
725
|
|
|
510
|
-
// ── Compute list-view-visible actions per entity ──
|
|
511
|
-
const getListViewActions = useCallback((entity: Entity<M>): EntityAction[] => {
|
|
512
|
-
if (!getActionsForEntity) return EMPTY_LIST_VIEW_ACTIONS;
|
|
513
|
-
const customEntityActions = (collection.entityActions ?? [])
|
|
514
|
-
.map(action => resolveEntityAction(action, customizationController.entityActions))
|
|
515
|
-
.filter(Boolean) as EntityAction<M>[];
|
|
516
|
-
const allActions = getActionsForEntity({ entity,
|
|
517
|
-
customEntityActions });
|
|
518
|
-
return allActions.filter(a => a.showActionsInListView);
|
|
519
|
-
}, [getActionsForEntity, collection.entityActions, customizationController.entityActions]);
|
|
520
726
|
|
|
521
727
|
const rowClasses = getRowClasses(size);
|
|
522
728
|
|
|
@@ -527,56 +733,111 @@ customEntityActions });
|
|
|
527
733
|
handleSelectionChange(entity, selected);
|
|
528
734
|
}, [handleSelectionChange]);
|
|
529
735
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
);
|
|
577
|
-
}, [resolvedCollection, selectionEnabled, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick])}
|
|
736
|
+
/**
|
|
737
|
+
* Order by a column, on the same cycle a table header runs: unordered →
|
|
738
|
+
* ascending → descending → unordered. Clicking a header here and clicking it
|
|
739
|
+
* in the table view have to mean the same thing, because they are the same
|
|
740
|
+
* collection under the same controller.
|
|
741
|
+
*/
|
|
742
|
+
const onColumnSort = useCallback((key: string) => {
|
|
743
|
+
if (!setSortBy) return;
|
|
744
|
+
const active = sortBy?.[0] === key ? sortBy[1] : undefined;
|
|
745
|
+
const next: [string, "asc" | "desc"] | undefined = active === "asc"
|
|
746
|
+
? [key, "desc"]
|
|
747
|
+
: active === "desc"
|
|
748
|
+
? undefined
|
|
749
|
+
: [key, "asc"];
|
|
750
|
+
setSortBy(next);
|
|
751
|
+
// Re-ordering a partially loaded collection invalidates the pages
|
|
752
|
+
// already fetched — they are no longer the rows the query answers with —
|
|
753
|
+
// so pagination starts over, as it does for the table and the sort menu.
|
|
754
|
+
setItemCount?.(pageSize);
|
|
755
|
+
}, [setSortBy, sortBy, setItemCount, pageSize]);
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Whether a sort can be offered at all next to the filter already applied.
|
|
759
|
+
* A driver may refuse the combination; the table clears the filter to make
|
|
760
|
+
* room, which is a heavier answer than a header click asks for, so the
|
|
761
|
+
* header simply stops offering it — the same choice the sort menu makes.
|
|
762
|
+
*/
|
|
763
|
+
const sortIsAvailable = useCallback((key: string) => {
|
|
764
|
+
if (!setSortBy) return false;
|
|
765
|
+
if (!checkFilterCombination) return true;
|
|
766
|
+
const active = sortBy?.[0] === key ? sortBy[1] : undefined;
|
|
767
|
+
// Clearing the sort is always available: it asks nothing of the driver.
|
|
768
|
+
if (active === "desc") return true;
|
|
769
|
+
return checkFilterCombination(filterValues ?? {}, [key, active === "asc" ? "desc" : "asc"]);
|
|
770
|
+
}, [setSortBy, checkFilterCombination, filterValues, sortBy]);
|
|
771
|
+
|
|
772
|
+
const header = (titleColumn || visibleColumns.length > 0) && (
|
|
773
|
+
<ListHeader
|
|
774
|
+
titleColumn={titleColumn}
|
|
775
|
+
columns={visibleColumns}
|
|
776
|
+
selectionEnabled={selectionEnabled && !combineSelection}
|
|
777
|
+
showImage={showImage}
|
|
778
|
+
actionsWidth={actionsWidth}
|
|
779
|
+
sortBy={sortBy as [string, "asc" | "desc"] | undefined}
|
|
780
|
+
onColumnSort={onColumnSort}
|
|
781
|
+
sortIsAvailable={sortIsAvailable}
|
|
578
782
|
/>
|
|
579
783
|
);
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<div ref={containerRef} className="w-full">
|
|
787
|
+
<ListView<Entity<M>>
|
|
788
|
+
data={data}
|
|
789
|
+
dataLoading={dataLoading}
|
|
790
|
+
noMoreToLoad={noMoreToLoad}
|
|
791
|
+
dataLoadingError={dataLoadingError}
|
|
792
|
+
itemCount={itemCount}
|
|
793
|
+
setItemCount={setItemCount}
|
|
794
|
+
pageSize={pageSize}
|
|
795
|
+
paginationEnabled={paginationEnabled}
|
|
796
|
+
onItemClick={handleEntityClick}
|
|
797
|
+
selectedIds={selectedIds}
|
|
798
|
+
highlightedIds={highlightedIds}
|
|
799
|
+
selectionEnabled={selectionEnabled}
|
|
800
|
+
emptyComponent={emptyComponent}
|
|
801
|
+
size={size}
|
|
802
|
+
selectedEntityId={selectedEntityId}
|
|
803
|
+
header={header}
|
|
804
|
+
renderRow={useCallback(({ item: entity, style, className, selected, highlighted, isLast, onClick, onSelectionChange }) => {
|
|
805
|
+
return (
|
|
806
|
+
<div
|
|
807
|
+
key={entity.id}
|
|
808
|
+
style={style}
|
|
809
|
+
className={className}
|
|
810
|
+
>
|
|
811
|
+
<ListRow
|
|
812
|
+
entity={entity}
|
|
813
|
+
collection={resolvedCollection}
|
|
814
|
+
searchString={searchString}
|
|
815
|
+
onClick={handleEntityClick}
|
|
816
|
+
selected={selected}
|
|
817
|
+
highlighted={highlighted}
|
|
818
|
+
onSelectionChange={handleRowSelectionChange}
|
|
819
|
+
selectionEnabled={selectionEnabled}
|
|
820
|
+
combineSelection={combineSelection}
|
|
821
|
+
columns={visibleColumns}
|
|
822
|
+
slotKeys={slotKeys}
|
|
823
|
+
rowClasses={rowClasses}
|
|
824
|
+
showImage={showImage}
|
|
825
|
+
size={size}
|
|
826
|
+
isLast={isLast}
|
|
827
|
+
isActive={selectedEntityId !== undefined && entity.id === selectedEntityId}
|
|
828
|
+
listViewActions={getListViewActions(entity)}
|
|
829
|
+
actionsWidth={actionsWidth}
|
|
830
|
+
context={context}
|
|
831
|
+
path={path}
|
|
832
|
+
selectionController={selectionController}
|
|
833
|
+
openEntityMode={openEntityMode}
|
|
834
|
+
/>
|
|
835
|
+
</div>
|
|
836
|
+
);
|
|
837
|
+
}, [resolvedCollection, selectionEnabled, combineSelection, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, actionsWidth, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick, searchString])}
|
|
838
|
+
/>
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
580
841
|
}
|
|
581
842
|
|
|
582
843
|
/**
|
|
@@ -595,6 +856,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
595
856
|
highlighted,
|
|
596
857
|
onSelectionChange,
|
|
597
858
|
selectionEnabled,
|
|
859
|
+
combineSelection = false,
|
|
598
860
|
columns,
|
|
599
861
|
slotKeys,
|
|
600
862
|
rowClasses,
|
|
@@ -603,19 +865,30 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
603
865
|
isLast,
|
|
604
866
|
isActive = false,
|
|
605
867
|
listViewActions = [],
|
|
868
|
+
actionsWidth = 0,
|
|
606
869
|
context,
|
|
607
870
|
path,
|
|
608
871
|
selectionController,
|
|
609
|
-
openEntityMode
|
|
872
|
+
openEntityMode,
|
|
873
|
+
searchString
|
|
610
874
|
}: {
|
|
611
875
|
entity: Entity<M>;
|
|
876
|
+
/** The active search, so a row can show where it matched. */
|
|
877
|
+
searchString?: string;
|
|
612
878
|
collection: AdminCollection<M>;
|
|
613
879
|
onClick?: (entity: Entity<M>) => void;
|
|
614
880
|
selected?: boolean;
|
|
615
881
|
highlighted?: boolean;
|
|
616
882
|
onSelectionChange?: (entity: Entity<M>, selected: boolean) => void;
|
|
617
883
|
selectionEnabled?: boolean;
|
|
618
|
-
|
|
884
|
+
/**
|
|
885
|
+
* Fold the selection checkbox onto the thumbnail instead of giving it a cell
|
|
886
|
+
* of its own, buying the row back 48px for its title. Set where the row is
|
|
887
|
+
* paying for width it does not have — see the split's compact list in
|
|
888
|
+
* {@link CollectionListViewBinding}.
|
|
889
|
+
*/
|
|
890
|
+
combineSelection?: boolean;
|
|
891
|
+
columns: ListColumn[];
|
|
619
892
|
slotKeys: CollectionSlotKeys;
|
|
620
893
|
rowClasses: string;
|
|
621
894
|
showImage: boolean;
|
|
@@ -623,18 +896,31 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
623
896
|
isLast: boolean;
|
|
624
897
|
isActive?: boolean;
|
|
625
898
|
listViewActions?: EntityAction[];
|
|
899
|
+
/** Reserved width of the actions cell, shared with the header. */
|
|
900
|
+
actionsWidth?: number;
|
|
626
901
|
context?: ReturnType<typeof useAdminContext>;
|
|
627
902
|
path?: string;
|
|
628
903
|
selectionController?: SelectionController<M>;
|
|
629
904
|
openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
630
905
|
}) {
|
|
631
|
-
// ── Resolve slots
|
|
632
|
-
|
|
906
|
+
// ── Resolve slots ──
|
|
907
|
+
// A hook now, not a pure call: a `display` resolver may have to fetch what
|
|
908
|
+
// fills a slot, so the row subscribes to the result and re-renders when it
|
|
909
|
+
// lands. `ListRow` is memoized, which is what keeps that to the one row.
|
|
910
|
+
const slots = useEntitySlots(
|
|
633
911
|
entity as Entity<Record<string, unknown>>,
|
|
634
912
|
collection as AdminCollection<Record<string, unknown>>,
|
|
635
913
|
slotKeys
|
|
636
914
|
);
|
|
637
915
|
|
|
916
|
+
// Why is this row here? Shared with cards and board — see SearchExplanation.
|
|
917
|
+
const { terms, offSlot } = useSearchExplanation(
|
|
918
|
+
entity,
|
|
919
|
+
collection.properties as Record<string, unknown>,
|
|
920
|
+
[slotKeys.titleKey, slotKeys.subtitleKey],
|
|
921
|
+
searchString
|
|
922
|
+
);
|
|
923
|
+
|
|
638
924
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
639
925
|
// Cmd+click (Mac) or Ctrl+click (Windows) toggles selection
|
|
640
926
|
if ((e.metaKey || e.ctrlKey) && selectionEnabled) {
|
|
@@ -653,9 +939,14 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
653
939
|
onSelectionChange?.(entity, checked);
|
|
654
940
|
}, [entity, onSelectionChange]);
|
|
655
941
|
|
|
942
|
+
/** The thumbnail-as-checkbox cell: the square toggles, and never opens the record. */
|
|
943
|
+
const handleSelectionCellClick = useCallback((e: React.MouseEvent) => {
|
|
944
|
+
e.stopPropagation();
|
|
945
|
+
onSelectionChange?.(entity, !selected);
|
|
946
|
+
}, [entity, onSelectionChange, selected]);
|
|
947
|
+
|
|
656
948
|
// Developer-defined column mode (listProperties is explicitly set)
|
|
657
949
|
const useColumnMode = !!collection.listProperties && collection.listProperties.length > 0;
|
|
658
|
-
const extraColumns = useColumnMode ? columns.filter(c => !c.isTitle) : [];
|
|
659
950
|
|
|
660
951
|
return (
|
|
661
952
|
<div
|
|
@@ -677,8 +968,9 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
677
968
|
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-primary-500 rounded-r-full"/>
|
|
678
969
|
)}
|
|
679
970
|
|
|
680
|
-
{/* Selection Checkbox
|
|
681
|
-
|
|
971
|
+
{/* Selection Checkbox — its own cell only when it is not riding on
|
|
972
|
+
the thumbnail. See {@link ListRowProps.combineSelection}. */}
|
|
973
|
+
{selectionEnabled && !combineSelection && (
|
|
682
974
|
<div
|
|
683
975
|
className="flex-shrink-0 w-8"
|
|
684
976
|
onClick={handleCheckboxClick}
|
|
@@ -693,10 +985,10 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
693
985
|
|
|
694
986
|
{/* MEDIA slot → Image / Icon */}
|
|
695
987
|
{showImage && (
|
|
696
|
-
<div className="flex-shrink-0">
|
|
988
|
+
<div className="flex-shrink-0 relative w-10 h-10">
|
|
697
989
|
{slots.image ? (
|
|
698
990
|
<div className={cls("w-10 h-10 rounded-lg border relative overflow-hidden bg-surface-100 dark:bg-surface-900", defaultBorderMixin)}>
|
|
699
|
-
<
|
|
991
|
+
<SlotValue slot={slots.image} size="small" fill={true}/>
|
|
700
992
|
</div>
|
|
701
993
|
) : (
|
|
702
994
|
<div className={cls("w-10 h-10 rounded-lg bg-surface-100 dark:bg-surface-900 flex items-center justify-center border", defaultBorderMixin)}>
|
|
@@ -707,21 +999,50 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
707
999
|
/>
|
|
708
1000
|
</div>
|
|
709
1001
|
)}
|
|
1002
|
+
|
|
1003
|
+
{/* The checkbox, over the thumbnail rather than beside it.
|
|
1004
|
+
Hidden until the row is hovered or the record is already
|
|
1005
|
+
selected: at rest the cell is the picture it was, and the
|
|
1006
|
+
row spends the reclaimed width on its title instead. The
|
|
1007
|
+
scrim is what makes an unchecked box legible over a photo. */}
|
|
1008
|
+
{selectionEnabled && combineSelection && (
|
|
1009
|
+
<div
|
|
1010
|
+
className={cls(
|
|
1011
|
+
"absolute inset-0 flex items-center justify-center rounded-lg transition-opacity duration-150",
|
|
1012
|
+
"bg-surface-950/55",
|
|
1013
|
+
selected
|
|
1014
|
+
? "opacity-100"
|
|
1015
|
+
: "opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
|
1016
|
+
)}
|
|
1017
|
+
// The whole square toggles, not just the box drawn in
|
|
1018
|
+
// it: at 40px the cell is the target the pointer is
|
|
1019
|
+
// already over, and a click that lands beside a 16px
|
|
1020
|
+
// checkbox and does nothing reads as a broken row.
|
|
1021
|
+
// The box itself takes no pointer events, so one
|
|
1022
|
+
// click is one toggle — it stays focusable, and space
|
|
1023
|
+
// still works, for the keyboard.
|
|
1024
|
+
onClick={handleSelectionCellClick}
|
|
1025
|
+
>
|
|
1026
|
+
<div className="pointer-events-none">
|
|
1027
|
+
<Checkbox
|
|
1028
|
+
checked={selected ?? false}
|
|
1029
|
+
onCheckedChange={handleCheckboxChange}
|
|
1030
|
+
size="smallest"
|
|
1031
|
+
/>
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
)}
|
|
710
1035
|
</div>
|
|
711
1036
|
)}
|
|
712
1037
|
|
|
713
1038
|
{/* PRIMARY slot → Title + subtitle + byline */}
|
|
714
|
-
<div className=
|
|
1039
|
+
<div className="flex-1 min-w-0 overflow-hidden">
|
|
715
1040
|
<div className="truncate">
|
|
716
1041
|
{slots.title?.value !== undefined ? (
|
|
717
1042
|
<Typography component="div" variant="body2" className="font-semibold text-surface-900 dark:text-surface-50 truncate transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
property={slots.title.property}
|
|
722
|
-
size="small"
|
|
723
|
-
textOnly={true}
|
|
724
|
-
/>
|
|
1043
|
+
{terms.length > 0 && typeof slots.title?.value === "string"
|
|
1044
|
+
? <Highlighted text={slots.title.value} terms={terms}/>
|
|
1045
|
+
: <SlotValue slot={slots.title} size="small"/>}
|
|
725
1046
|
</Typography>
|
|
726
1047
|
) : (
|
|
727
1048
|
<Typography component="div" variant="body2" className="font-semibold text-surface-500 dark:text-surface-400 font-mono text-xs transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
|
@@ -730,17 +1051,21 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
730
1051
|
)}
|
|
731
1052
|
</div>
|
|
732
1053
|
|
|
733
|
-
{/* SUBTITLE slot
|
|
734
|
-
|
|
1054
|
+
{/* SUBTITLE slot — or, while searching, where the match was.
|
|
1055
|
+
A hit in a field the row does not display makes the row look
|
|
1056
|
+
arbitrary; the subtitle is the one line already reserved for
|
|
1057
|
+
secondary context, so it carries the explanation instead. */}
|
|
1058
|
+
{offSlot ? (
|
|
1059
|
+
<MatchExplanation
|
|
1060
|
+
match={offSlot}
|
|
1061
|
+
label={fieldLabel(collection.properties as Record<string, unknown>, offSlot.field)}
|
|
1062
|
+
/>
|
|
1063
|
+
) : slots.subtitle && (
|
|
735
1064
|
<div className="truncate mt-0.5">
|
|
736
1065
|
<Typography variant="caption" component="div" className="text-surface-500 dark:text-surface-400 truncate">
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
property={slots.subtitle.property}
|
|
741
|
-
size="small"
|
|
742
|
-
textOnly={true}
|
|
743
|
-
/>
|
|
1066
|
+
{terms.length > 0 && typeof slots.subtitle.value === "string"
|
|
1067
|
+
? <Highlighted text={slots.subtitle.value} terms={terms}/>
|
|
1068
|
+
: <SlotValue slot={slots.subtitle} size="small"/>}
|
|
744
1069
|
</Typography>
|
|
745
1070
|
</div>
|
|
746
1071
|
)}
|
|
@@ -769,85 +1094,34 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
769
1094
|
)}
|
|
770
1095
|
</div>
|
|
771
1096
|
|
|
772
|
-
{
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const value = getValueInPath(entity.values, col.key);
|
|
777
|
-
|
|
778
|
-
if (col.isStatus || (col.property.type === "string" && "enum" in col.property)) {
|
|
779
|
-
return (
|
|
780
|
-
<div key={col.key} className={cls(col.width, "flex", col.align === "center" ? "justify-center" : col.align === "right" ? "justify-end" : "justify-start")}>
|
|
781
|
-
{value ? (
|
|
782
|
-
<PropertyPreview propertyKey={col.key} value={value} property={col.property} size="small"/>
|
|
783
|
-
) : <span className="text-surface-400">—</span>}
|
|
784
|
-
</div>
|
|
785
|
-
);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (col.isDate || col.property.type === "date") {
|
|
789
|
-
return (
|
|
790
|
-
<div key={col.key} className={cls(col.width, col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
|
|
791
|
-
<Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
|
|
792
|
-
{formatDateValue(value) ?? "—"}
|
|
793
|
-
</Typography>
|
|
794
|
-
</div>
|
|
795
|
-
);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Complex types → compact single-line summary
|
|
799
|
-
if (isComplexPropertyType(col.property)) {
|
|
800
|
-
const summary = compactValueSummary(value, col.property);
|
|
801
|
-
return (
|
|
802
|
-
<div key={col.key} className={cls(col.width, "truncate", col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
|
|
803
|
-
<Typography variant="caption" className="text-surface-500 dark:text-surface-400 truncate">
|
|
804
|
-
{summary ?? "—"}
|
|
805
|
-
</Typography>
|
|
806
|
-
</div>
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// Simple scalar → PropertyPreview (single-line)
|
|
811
|
-
return (
|
|
812
|
-
<div key={col.key} className={cls(col.width, "truncate", col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
|
|
813
|
-
<Typography component="div" variant="body2" className="text-surface-600 dark:text-surface-300 truncate">
|
|
814
|
-
{value !== undefined ? (
|
|
815
|
-
<PropertyPreview propertyKey={col.key} value={value} property={col.property} size="small"/>
|
|
816
|
-
) : "—"}
|
|
817
|
-
</Typography>
|
|
818
|
-
</div>
|
|
819
|
-
);
|
|
820
|
-
})}
|
|
821
|
-
</>
|
|
822
|
-
) : (
|
|
823
|
-
/* ── SLOT MODE (editorial scanner layout) ── */
|
|
1097
|
+
{/* TRAILING COLUMNS — the same definitions the header labels, in the
|
|
1098
|
+
same order and at the same widths, so a label and the values
|
|
1099
|
+
under it cannot come apart. */}
|
|
1100
|
+
{columns.length > 0 && (
|
|
824
1101
|
<div className="flex items-center gap-4 flex-shrink-0 ml-auto">
|
|
825
|
-
{
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1102
|
+
{columns.map((col) => (
|
|
1103
|
+
<div
|
|
1104
|
+
key={col.key}
|
|
1105
|
+
className={cls(
|
|
1106
|
+
col.width,
|
|
1107
|
+
"flex items-center overflow-hidden",
|
|
1108
|
+
col.align === "center" ? "justify-center" : col.align === "right" ? "justify-end" : "justify-start"
|
|
1109
|
+
)}
|
|
1110
|
+
>
|
|
1111
|
+
<ListCell column={col} entity={entity} slots={slots}/>
|
|
834
1112
|
</div>
|
|
835
|
-
)}
|
|
836
|
-
|
|
837
|
-
{/* DATE slot */}
|
|
838
|
-
{slots.date && (
|
|
839
|
-
<div className="flex-shrink-0 text-right w-[80px] @max-[500px]:hidden">
|
|
840
|
-
<Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
|
|
841
|
-
{slots.date.formatted ?? "—"}
|
|
842
|
-
</Typography>
|
|
843
|
-
</div>
|
|
844
|
-
)}
|
|
1113
|
+
))}
|
|
845
1114
|
</div>
|
|
846
1115
|
)}
|
|
847
1116
|
|
|
848
|
-
{/* LIST VIEW ACTIONS — always visible on each row
|
|
849
|
-
|
|
850
|
-
|
|
1117
|
+
{/* LIST VIEW ACTIONS — always visible on each row.
|
|
1118
|
+
Width is reserved from the widest row rather than taken from
|
|
1119
|
+
this one, so a record with fewer actions does not slide its
|
|
1120
|
+
columns out from under the header. */}
|
|
1121
|
+
{actionsWidth > 0 && (
|
|
1122
|
+
<div className="flex items-center justify-end gap-0.5 flex-shrink-0 ml-auto"
|
|
1123
|
+
style={{ minWidth: actionsWidth }}
|
|
1124
|
+
onClick={(e) => e.stopPropagation()}>
|
|
851
1125
|
{listViewActions.map((action, index) => (
|
|
852
1126
|
<Tooltip key={action.key ?? index} title={action.name} asChild>
|
|
853
1127
|
<IconButton
|
|
@@ -876,12 +1150,15 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
876
1150
|
}) as <M extends Record<string, unknown>>(props: {
|
|
877
1151
|
entity: Entity<M>;
|
|
878
1152
|
collection: AdminCollection<M>;
|
|
1153
|
+
/** The active search, so a row can show where it matched. */
|
|
1154
|
+
searchString?: string;
|
|
879
1155
|
onClick?: (entity: Entity<M>) => void;
|
|
880
1156
|
selected?: boolean;
|
|
881
1157
|
highlighted?: boolean;
|
|
882
1158
|
onSelectionChange?: (entity: Entity<M>, selected: boolean) => void;
|
|
883
1159
|
selectionEnabled?: boolean;
|
|
884
|
-
|
|
1160
|
+
combineSelection?: boolean;
|
|
1161
|
+
columns: ListColumn[];
|
|
885
1162
|
slotKeys: CollectionSlotKeys;
|
|
886
1163
|
rowClasses: string;
|
|
887
1164
|
showImage: boolean;
|
|
@@ -889,9 +1166,200 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
|
|
|
889
1166
|
isLast: boolean;
|
|
890
1167
|
isActive?: boolean;
|
|
891
1168
|
listViewActions?: EntityAction[];
|
|
1169
|
+
/** Reserved width of the actions cell, shared with the header. */
|
|
1170
|
+
actionsWidth?: number;
|
|
892
1171
|
context?: ReturnType<typeof useAdminContext>;
|
|
893
1172
|
path?: string;
|
|
894
1173
|
selectionController?: SelectionController<M>;
|
|
895
1174
|
openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
896
1175
|
}) => React.ReactElement;
|
|
897
1176
|
|
|
1177
|
+
/**
|
|
1178
|
+
* One cell of one row: whatever the column says fills it.
|
|
1179
|
+
*
|
|
1180
|
+
* A slot column renders the slot — a status keeps its coloured chip and a date
|
|
1181
|
+
* its relative wording wherever the value came from — and a property column
|
|
1182
|
+
* reads the record. Both go through here so the header above them is labelling
|
|
1183
|
+
* one thing, not two that happen to line up.
|
|
1184
|
+
*/
|
|
1185
|
+
function ListCell<M extends Record<string, unknown>>({
|
|
1186
|
+
column,
|
|
1187
|
+
entity,
|
|
1188
|
+
slots
|
|
1189
|
+
}: {
|
|
1190
|
+
column: ListColumn;
|
|
1191
|
+
entity: Entity<M>;
|
|
1192
|
+
slots: EntityPreviewSlots;
|
|
1193
|
+
}) {
|
|
1194
|
+
if (column.slot === "tags") {
|
|
1195
|
+
if (!slots.tags) return null;
|
|
1196
|
+
return (
|
|
1197
|
+
<div className="flex items-center gap-1 overflow-hidden">
|
|
1198
|
+
<TagChips slot={slots.tags} max={3}/>
|
|
1199
|
+
</div>
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (column.slot === "status") {
|
|
1204
|
+
if (!slots.status) return <EmptyCell/>;
|
|
1205
|
+
return <SlotValue slot={slots.status} size="small"/>;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (column.slot === "date") {
|
|
1209
|
+
return (
|
|
1210
|
+
<Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
|
|
1211
|
+
{slots.date?.formatted ?? "—"}
|
|
1212
|
+
</Typography>
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const property = column.property;
|
|
1217
|
+
if (!property) return null;
|
|
1218
|
+
|
|
1219
|
+
const value = getValueInPath(entity.values, column.key);
|
|
1220
|
+
|
|
1221
|
+
if (property.type === "date") {
|
|
1222
|
+
return (
|
|
1223
|
+
<Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
|
|
1224
|
+
{formatDateValue(value) ?? "—"}
|
|
1225
|
+
</Typography>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Complex types → compact single-line summary, so a row keeps its height.
|
|
1230
|
+
if (isComplexPropertyType(property)) {
|
|
1231
|
+
const summary = compactValueSummary(value, property);
|
|
1232
|
+
return (
|
|
1233
|
+
<Typography variant="caption" className="text-surface-500 dark:text-surface-400 truncate">
|
|
1234
|
+
{summary ?? "—"}
|
|
1235
|
+
</Typography>
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (value === undefined || value === null) return <EmptyCell/>;
|
|
1240
|
+
|
|
1241
|
+
return (
|
|
1242
|
+
<Typography component="div" variant="body2" className="text-surface-600 dark:text-surface-300 truncate">
|
|
1243
|
+
<PropertyPreview propertyKey={column.key} value={value} property={property} size="small"/>
|
|
1244
|
+
</Typography>
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** What a column shows for a record that has nothing in it. */
|
|
1249
|
+
function EmptyCell() {
|
|
1250
|
+
return <span className="text-surface-400 dark:text-surface-600">—</span>;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The list's column header.
|
|
1255
|
+
*
|
|
1256
|
+
* Quiet by design: a list is read row by row, and the header is there to name
|
|
1257
|
+
* the columns and to be clicked, not to compete with the rows. It lays out
|
|
1258
|
+
* against the same widths the rows do, and skips the checkbox and the image —
|
|
1259
|
+
* neither is a column, and neither has anything to be called.
|
|
1260
|
+
*/
|
|
1261
|
+
function ListHeader({
|
|
1262
|
+
titleColumn,
|
|
1263
|
+
columns,
|
|
1264
|
+
selectionEnabled,
|
|
1265
|
+
showImage,
|
|
1266
|
+
actionsWidth,
|
|
1267
|
+
sortBy,
|
|
1268
|
+
onColumnSort,
|
|
1269
|
+
sortIsAvailable
|
|
1270
|
+
}: {
|
|
1271
|
+
titleColumn?: ListColumn;
|
|
1272
|
+
columns: ListColumn[];
|
|
1273
|
+
selectionEnabled?: boolean;
|
|
1274
|
+
showImage: boolean;
|
|
1275
|
+
actionsWidth: number;
|
|
1276
|
+
sortBy?: [string, "asc" | "desc"];
|
|
1277
|
+
onColumnSort: (key: string) => void;
|
|
1278
|
+
sortIsAvailable: (key: string) => boolean;
|
|
1279
|
+
}) {
|
|
1280
|
+
const headerCell = (column: ListColumn) => (
|
|
1281
|
+
<ListHeaderLabel
|
|
1282
|
+
column={column}
|
|
1283
|
+
direction={sortBy?.[0] === column.key ? sortBy[1] : undefined}
|
|
1284
|
+
onSort={column.sortable && sortIsAvailable(column.key) ? () => onColumnSort(column.key) : undefined}
|
|
1285
|
+
/>
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
return (
|
|
1289
|
+
<div className={cls(
|
|
1290
|
+
"flex items-center gap-4 px-5 py-1.5 select-none border-b bg-surface-50 dark:bg-surface-900",
|
|
1291
|
+
defaultBorderMixin
|
|
1292
|
+
)}>
|
|
1293
|
+
{selectionEnabled && <div className="flex-shrink-0 w-8"/>}
|
|
1294
|
+
{showImage && <div className="flex-shrink-0 w-10"/>}
|
|
1295
|
+
|
|
1296
|
+
<div className="flex-1 min-w-0 flex items-center">
|
|
1297
|
+
{titleColumn && headerCell(titleColumn)}
|
|
1298
|
+
</div>
|
|
1299
|
+
|
|
1300
|
+
{columns.length > 0 && (
|
|
1301
|
+
<div className="flex items-center gap-4 flex-shrink-0 ml-auto">
|
|
1302
|
+
{columns.map(column => (
|
|
1303
|
+
<div
|
|
1304
|
+
key={column.key}
|
|
1305
|
+
className={cls(
|
|
1306
|
+
column.width,
|
|
1307
|
+
"flex items-center overflow-hidden",
|
|
1308
|
+
column.align === "center" ? "justify-center" : column.align === "right" ? "justify-end" : "justify-start"
|
|
1309
|
+
)}
|
|
1310
|
+
>
|
|
1311
|
+
{headerCell(column)}
|
|
1312
|
+
</div>
|
|
1313
|
+
))}
|
|
1314
|
+
</div>
|
|
1315
|
+
)}
|
|
1316
|
+
|
|
1317
|
+
{actionsWidth > 0 && <div className="flex-shrink-0 ml-auto" style={{ minWidth: actionsWidth }}/>}
|
|
1318
|
+
</div>
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* One header label, clickable when the collection can be ordered by it.
|
|
1324
|
+
*
|
|
1325
|
+
* The arrow shows only on the column currently ordering the list: three arrows
|
|
1326
|
+
* in a header row are three invitations, and only one of them is ever the
|
|
1327
|
+
* answer to "how is this sorted".
|
|
1328
|
+
*/
|
|
1329
|
+
function ListHeaderLabel({
|
|
1330
|
+
column,
|
|
1331
|
+
direction,
|
|
1332
|
+
onSort
|
|
1333
|
+
}: {
|
|
1334
|
+
column: ListColumn;
|
|
1335
|
+
direction?: "asc" | "desc";
|
|
1336
|
+
onSort?: () => void;
|
|
1337
|
+
}) {
|
|
1338
|
+
const content = (
|
|
1339
|
+
<>
|
|
1340
|
+
<span className="truncate">{column.label}</span>
|
|
1341
|
+
{direction === "asc" && <ArrowUpIcon size={12} className="flex-shrink-0"/>}
|
|
1342
|
+
{direction === "desc" && <ArrowDownIcon size={12} className="flex-shrink-0"/>}
|
|
1343
|
+
</>
|
|
1344
|
+
);
|
|
1345
|
+
|
|
1346
|
+
const base = "inline-flex items-center gap-1 max-w-full text-[11px] leading-none uppercase tracking-wider font-medium";
|
|
1347
|
+
const tone = direction
|
|
1348
|
+
? "text-surface-600 dark:text-surface-300"
|
|
1349
|
+
: "text-surface-400 dark:text-surface-500";
|
|
1350
|
+
|
|
1351
|
+
if (!onSort) {
|
|
1352
|
+
return <span className={cls(base, tone)}>{content}</span>;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return (
|
|
1356
|
+
<button
|
|
1357
|
+
type="button"
|
|
1358
|
+
title={`Sort by ${column.label}`}
|
|
1359
|
+
onClick={onSort}
|
|
1360
|
+
className={cls(base, tone, "cursor-pointer hover:text-surface-700 dark:hover:text-surface-200 transition-colors")}
|
|
1361
|
+
>
|
|
1362
|
+
{content}
|
|
1363
|
+
</button>
|
|
1364
|
+
);
|
|
1365
|
+
}
|