@rebasepro/admin 0.14.0 → 0.14.1
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-CSheud_E.js → CollectionEditorDialog-BeS4lxGh.js} +3 -3
- package/dist/{CollectionEditorDialog-CSheud_E.js.map → CollectionEditorDialog-BeS4lxGh.js.map} +1 -1
- package/dist/{PropertyEditView-CWZlC1gq.js → PropertyEditView-BFJ6H9-Q.js} +2 -2
- package/dist/{PropertyEditView-CWZlC1gq.js.map → PropertyEditView-BFJ6H9-Q.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-CwTpdzWF.js → RouterCollectionsStudioView-sfsUpEJz.js} +4 -4
- package/dist/{RouterCollectionsStudioView-CwTpdzWF.js.map → RouterCollectionsStudioView-sfsUpEJz.js.map} +1 -1
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +2 -1
- package/dist/components/CollectionViewBinding/SortButton.d.ts +14 -4
- package/dist/{export-L53WektO.js → export-KEf57Jso.js} +2 -2
- package/dist/{export-L53WektO.js.map → export-KEf57Jso.js.map} +1 -1
- package/dist/{history-D5SKygpJ.js → history-Ctd2cRn_.js} +2 -2
- package/dist/{history-D5SKygpJ.js.map → history-Ctd2cRn_.js.map} +1 -1
- package/dist/{import-C_4edkoD.js → import-BujhycMq.js} +2 -2
- package/dist/{import-C_4edkoD.js.map → import-BujhycMq.js.map} +1 -1
- package/dist/index.js +6 -6
- package/dist/{util-C-D5yD2n.js → util-bL-fk6C6.js} +217 -92
- package/dist/util-bL-fk6C6.js.map +1 -0
- package/package.json +9 -9
- package/src/collection_editor/ui/EditorCollectionActionStart.tsx +3 -2
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +104 -39
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +19 -8
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +11 -8
- package/src/components/CollectionViewBinding/FilterPresetsButton.tsx +6 -5
- package/src/components/CollectionViewBinding/SortButton.tsx +191 -38
- package/src/components/DetailViewBinding.tsx +6 -1
- package/src/components/SelectableTable/SelectableTable.tsx +1 -1
- package/dist/util-C-D5yD2n.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/admin",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.1",
|
|
5
5
|
"description": "Rebase CMS — content management views, forms, and routing",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -83,14 +83,14 @@
|
|
|
83
83
|
"react-dropzone": "^19.1.1",
|
|
84
84
|
"react-use-measure": "^2.1.7",
|
|
85
85
|
"zod": "^4.4.3",
|
|
86
|
-
"@rebasepro/admin-types": "0.14.
|
|
87
|
-
"@rebasepro/
|
|
88
|
-
"@rebasepro/
|
|
89
|
-
"@rebasepro/
|
|
90
|
-
"@rebasepro/
|
|
91
|
-
"@rebasepro/
|
|
92
|
-
"@rebasepro/
|
|
93
|
-
"@rebasepro/
|
|
86
|
+
"@rebasepro/admin-types": "0.14.1",
|
|
87
|
+
"@rebasepro/forms": "0.14.1",
|
|
88
|
+
"@rebasepro/app": "0.14.1",
|
|
89
|
+
"@rebasepro/common": "0.14.1",
|
|
90
|
+
"@rebasepro/inference": "0.14.1",
|
|
91
|
+
"@rebasepro/types": "0.14.1",
|
|
92
|
+
"@rebasepro/ui": "0.14.1",
|
|
93
|
+
"@rebasepro/utils": "0.14.1"
|
|
94
94
|
},
|
|
95
95
|
"peerDependencies": {
|
|
96
96
|
"react": ">=19.2.7",
|
|
@@ -12,6 +12,7 @@ import { Button, SaveIcon, Tooltip, UndoIcon } from "@rebasepro/ui";
|
|
|
12
12
|
import { useCollectionEditorController } from "../useCollectionEditorController";
|
|
13
13
|
import { useCollectionsConfigController } from "../useCollectionsConfigController";
|
|
14
14
|
import { mergeDeep } from "@rebasepro/utils";
|
|
15
|
+
import { normalizeOrderBy } from "@rebasepro/common";
|
|
15
16
|
|
|
16
17
|
export function EditorCollectionActionStart({
|
|
17
18
|
path,
|
|
@@ -30,7 +31,7 @@ export function EditorCollectionActionStart({
|
|
|
30
31
|
|
|
31
32
|
let saveDefaultFilterButton = null;
|
|
32
33
|
if (!equal(getObjectOrNull(tableController.filterValues), getObjectOrNull(collection.defaultFilter)) ||
|
|
33
|
-
!equal(getObjectOrNull(tableController.sortBy), getObjectOrNull(collection.sort))) {
|
|
34
|
+
!equal(getObjectOrNull(tableController.sortBy), getObjectOrNull(normalizeOrderBy(collection.sort)))) {
|
|
34
35
|
saveDefaultFilterButton = <>
|
|
35
36
|
<Tooltip
|
|
36
37
|
asChild={true}
|
|
@@ -68,7 +69,7 @@ parentEntityIds,
|
|
|
68
69
|
if (collection?.defaultFilter)
|
|
69
70
|
tableController.setFilterValues?.(collection?.defaultFilter);
|
|
70
71
|
if (collection?.sort)
|
|
71
|
-
tableController.setSortBy?.(collection
|
|
72
|
+
tableController.setSortBy?.(normalizeOrderBy(collection.sort) as Parameters<NonNullable<typeof tableController.setSortBy>>[0]);
|
|
72
73
|
}}>
|
|
73
74
|
<UndoIcon/>
|
|
74
75
|
</Button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import type { Properties, Property } from "@rebasepro/types";
|
|
2
|
+
import type { OrderByTuple, 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";
|
|
@@ -24,6 +24,7 @@ import { useAnalyticsController } from "@rebasepro/app";
|
|
|
24
24
|
import { IconForView } from "@rebasepro/app";
|
|
25
25
|
import { getIcon } from "@rebasepro/app";
|
|
26
26
|
import { hasDeclaredDisplay } from "@rebasepro/app";
|
|
27
|
+
import { useTranslation } from "@rebasepro/app";
|
|
27
28
|
import { formatRelativeTime, getValueInPath } from "@rebasepro/utils";
|
|
28
29
|
import { useCollectionSlotKeys, useEntitySlots, type CollectionSlotKeys, type EntityPreviewSlots } from "./usePreviewSlots";
|
|
29
30
|
import { SlotValue, TagChips } from "./SlotValue";
|
|
@@ -540,11 +541,11 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
540
541
|
...(columnMode ? [] : slotKeys.relationKeys)
|
|
541
542
|
]), [titleColumn, imagePropertyKey, subtitleKey, declaredColumns, columnMode, slotKeys.relationKeys]);
|
|
542
543
|
|
|
543
|
-
/** The
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
544
|
+
/** The properties the sort names, minus the ones a cell already shows. */
|
|
545
|
+
const sortedKeys = useMemo(
|
|
546
|
+
() => (sortBy ?? []).map(([key]) => key as string).filter(key => !shownKeys.has(key)),
|
|
547
|
+
[sortBy, shownKeys]
|
|
548
|
+
);
|
|
548
549
|
|
|
549
550
|
/** The properties the filters name, minus the ones a cell already shows. */
|
|
550
551
|
const filteredKeys = useMemo(
|
|
@@ -561,23 +562,28 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
561
562
|
* and clearing a sort says nothing about wanting to stop seeing the property
|
|
562
563
|
* it sorted on.
|
|
563
564
|
*
|
|
564
|
-
* What it does *not* do is accumulate.
|
|
565
|
-
* ordering by four properties in turn is changing one's mind four
|
|
566
|
-
* asking for four columns — and asking for four is how the row
|
|
567
|
-
* status and its date to a wall of numbers. A new sort therefore
|
|
568
|
-
* the
|
|
569
|
-
* the set before it. At most one sort
|
|
570
|
-
* outlive their request.
|
|
565
|
+
* What it does *not* do is accumulate. A sort names the properties it names
|
|
566
|
+
* *now*, so ordering by four properties in turn is changing one's mind four
|
|
567
|
+
* times, not asking for four columns — and asking for four is how the row
|
|
568
|
+
* lost its status and its date to a wall of numbers. A new sort therefore
|
|
569
|
+
* replaces the properties the last one left behind, and a new set of filters
|
|
570
|
+
* replaces the set before it. At most one sort's columns and one set of
|
|
571
|
+
* filter columns outlive their request.
|
|
571
572
|
*
|
|
572
573
|
* The list remounts per collection (it is keyed by path), so this is the
|
|
573
574
|
* memory of one visit to one collection, not a preference.
|
|
574
575
|
*/
|
|
575
|
-
const [
|
|
576
|
+
const [lastSortedKeys, setLastSortedKeys] = useState<string[]>([]);
|
|
576
577
|
const [lastFilteredKeys, setLastFilteredKeys] = useState<string[]>([]);
|
|
577
578
|
|
|
578
579
|
useEffect(() => {
|
|
579
|
-
if (
|
|
580
|
-
|
|
580
|
+
if (sortedKeys.length === 0) return;
|
|
581
|
+
// Same members, same array — see the filter effect below.
|
|
582
|
+
setLastSortedKeys(previous =>
|
|
583
|
+
previous.length === sortedKeys.length && previous.every((key, i) => key === sortedKeys[i])
|
|
584
|
+
? previous
|
|
585
|
+
: sortedKeys);
|
|
586
|
+
}, [sortedKeys]);
|
|
581
587
|
|
|
582
588
|
useEffect(() => {
|
|
583
589
|
if (filteredKeys.length === 0) return;
|
|
@@ -602,14 +608,14 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
602
608
|
// read in that order rather than from state alone, because the effects
|
|
603
609
|
// above have not run yet on the render that first sees a request, and
|
|
604
610
|
// waiting a frame would flash the row without its new column.
|
|
605
|
-
const active = new Set([...filteredKeys, ...
|
|
611
|
+
const active = new Set([...filteredKeys, ...sortedKeys]);
|
|
606
612
|
const keys: string[] = [];
|
|
607
613
|
const request = (key: string | undefined) => {
|
|
608
614
|
if (!key || keys.includes(key) || shownKeys.has(key)) return;
|
|
609
615
|
keys.push(key);
|
|
610
616
|
};
|
|
611
617
|
(filteredKeys.length > 0 ? filteredKeys : lastFilteredKeys).forEach(request);
|
|
612
|
-
|
|
618
|
+
(sortedKeys.length > 0 ? sortedKeys : lastSortedKeys).forEach(request);
|
|
613
619
|
|
|
614
620
|
return keys.flatMap((key, index) => {
|
|
615
621
|
const property = getResolvedPropertyInPath(resolvedCollection.properties, key) as Property | undefined;
|
|
@@ -624,7 +630,7 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
|
|
|
624
630
|
priority: (active.has(key) ? PRIORITY_ACTIVE_REQUEST : PRIORITY_STALE_REQUEST) - index
|
|
625
631
|
}];
|
|
626
632
|
});
|
|
627
|
-
}, [filteredKeys,
|
|
633
|
+
}, [filteredKeys, sortedKeys, lastFilteredKeys, lastSortedKeys, shownKeys, resolvedCollection, sortableKeys]);
|
|
628
634
|
|
|
629
635
|
// ── Compute list-view-visible actions per entity ──
|
|
630
636
|
const getListViewActions = useCallback((entity: Entity<M>): EntityAction[] => {
|
|
@@ -733,26 +739,51 @@ customEntityActions });
|
|
|
733
739
|
handleSelectionChange(entity, selected);
|
|
734
740
|
}, [handleSelectionChange]);
|
|
735
741
|
|
|
742
|
+
/** Where each sorted column sits in the order, so a header can show its rank. */
|
|
743
|
+
const sortIndex = useMemo(() => {
|
|
744
|
+
const index = new Map<string, { direction: "asc" | "desc"; position: number }>();
|
|
745
|
+
(sortBy ?? []).forEach(([key, direction], position) => {
|
|
746
|
+
if (!index.has(key as string)) index.set(key as string, { direction,
|
|
747
|
+
position });
|
|
748
|
+
});
|
|
749
|
+
return index;
|
|
750
|
+
}, [sortBy]);
|
|
751
|
+
|
|
736
752
|
/**
|
|
737
753
|
* Order by a column, on the same cycle a table header runs: unordered →
|
|
738
|
-
* ascending → descending → unordered
|
|
754
|
+
* ascending → descending → unordered, and shift-click to add the column
|
|
755
|
+
* under the sort already in place. Clicking a header here and clicking it
|
|
739
756
|
* in the table view have to mean the same thing, because they are the same
|
|
740
757
|
* collection under the same controller.
|
|
741
758
|
*/
|
|
742
|
-
const onColumnSort = useCallback((key: string) => {
|
|
759
|
+
const onColumnSort = useCallback((key: string, additive = false) => {
|
|
743
760
|
if (!setSortBy) return;
|
|
744
|
-
const active =
|
|
745
|
-
const next:
|
|
746
|
-
?
|
|
761
|
+
const active = sortIndex.get(key)?.direction;
|
|
762
|
+
const next: "asc" | "desc" | undefined = active === "asc"
|
|
763
|
+
? "desc"
|
|
747
764
|
: active === "desc"
|
|
748
765
|
? undefined
|
|
749
|
-
:
|
|
750
|
-
|
|
766
|
+
: "asc";
|
|
767
|
+
|
|
768
|
+
const existing = (sortBy ?? []) as OrderByTuple[];
|
|
769
|
+
let updated: OrderByTuple[] | undefined;
|
|
770
|
+
if (!additive) {
|
|
771
|
+
updated = next ? [[key, next]] : undefined;
|
|
772
|
+
} else if (next === undefined) {
|
|
773
|
+
updated = existing.filter(([existingKey]) => existingKey !== key);
|
|
774
|
+
} else if (active === undefined) {
|
|
775
|
+
updated = [...existing, [key, next]];
|
|
776
|
+
} else {
|
|
777
|
+
updated = existing.map((entry) => entry[0] === key ? [key, next] as OrderByTuple : entry);
|
|
778
|
+
}
|
|
779
|
+
if (updated?.length === 0) updated = undefined;
|
|
780
|
+
|
|
781
|
+
setSortBy(updated as Parameters<NonNullable<typeof setSortBy>>[0]);
|
|
751
782
|
// Re-ordering a partially loaded collection invalidates the pages
|
|
752
783
|
// already fetched — they are no longer the rows the query answers with —
|
|
753
784
|
// so pagination starts over, as it does for the table and the sort menu.
|
|
754
785
|
setItemCount?.(pageSize);
|
|
755
|
-
}, [setSortBy, sortBy, setItemCount, pageSize]);
|
|
786
|
+
}, [setSortBy, sortBy, sortIndex, setItemCount, pageSize]);
|
|
756
787
|
|
|
757
788
|
/**
|
|
758
789
|
* Whether a sort can be offered at all next to the filter already applied.
|
|
@@ -763,11 +794,11 @@ customEntityActions });
|
|
|
763
794
|
const sortIsAvailable = useCallback((key: string) => {
|
|
764
795
|
if (!setSortBy) return false;
|
|
765
796
|
if (!checkFilterCombination) return true;
|
|
766
|
-
const active =
|
|
797
|
+
const active = sortIndex.get(key)?.direction;
|
|
767
798
|
// Clearing the sort is always available: it asks nothing of the driver.
|
|
768
799
|
if (active === "desc") return true;
|
|
769
|
-
return checkFilterCombination(filterValues ?? {}, [key, active === "asc" ? "desc" : "asc"]);
|
|
770
|
-
}, [setSortBy, checkFilterCombination, filterValues,
|
|
800
|
+
return checkFilterCombination(filterValues ?? {}, [[key, active === "asc" ? "desc" : "asc"]]);
|
|
801
|
+
}, [setSortBy, checkFilterCombination, filterValues, sortIndex]);
|
|
771
802
|
|
|
772
803
|
const header = (titleColumn || visibleColumns.length > 0) && (
|
|
773
804
|
<ListHeader
|
|
@@ -776,7 +807,7 @@ customEntityActions });
|
|
|
776
807
|
selectionEnabled={selectionEnabled && !combineSelection}
|
|
777
808
|
showImage={showImage}
|
|
778
809
|
actionsWidth={actionsWidth}
|
|
779
|
-
|
|
810
|
+
sortIndex={sortIndex}
|
|
780
811
|
onColumnSort={onColumnSort}
|
|
781
812
|
sortIsAvailable={sortIsAvailable}
|
|
782
813
|
/>
|
|
@@ -1264,7 +1295,7 @@ function ListHeader({
|
|
|
1264
1295
|
selectionEnabled,
|
|
1265
1296
|
showImage,
|
|
1266
1297
|
actionsWidth,
|
|
1267
|
-
|
|
1298
|
+
sortIndex,
|
|
1268
1299
|
onColumnSort,
|
|
1269
1300
|
sortIsAvailable
|
|
1270
1301
|
}: {
|
|
@@ -1273,18 +1304,26 @@ function ListHeader({
|
|
|
1273
1304
|
selectionEnabled?: boolean;
|
|
1274
1305
|
showImage: boolean;
|
|
1275
1306
|
actionsWidth: number;
|
|
1276
|
-
|
|
1277
|
-
onColumnSort: (key: string) => void;
|
|
1307
|
+
sortIndex: Map<string, { direction: "asc" | "desc"; position: number }>;
|
|
1308
|
+
onColumnSort: (key: string, additive?: boolean) => void;
|
|
1278
1309
|
sortIsAvailable: (key: string) => boolean;
|
|
1279
1310
|
}) {
|
|
1280
1311
|
const headerCell = (column: ListColumn) => (
|
|
1281
1312
|
<ListHeaderLabel
|
|
1282
1313
|
column={column}
|
|
1283
|
-
direction={
|
|
1284
|
-
|
|
1314
|
+
direction={sortIndex.get(column.key)?.direction}
|
|
1315
|
+
position={sortIndex.size > 1 ? sortIndex.get(column.key)?.position : undefined}
|
|
1316
|
+
onSort={column.sortable && sortIsAvailable(column.key)
|
|
1317
|
+
? (additive: boolean) => onColumnSort(column.key, additive)
|
|
1318
|
+
: undefined}
|
|
1285
1319
|
/>
|
|
1286
1320
|
);
|
|
1287
1321
|
|
|
1322
|
+
// No `role="columnheader"` / `aria-sort` here, tempting as they are: both
|
|
1323
|
+
// are only valid inside a `row` inside a `table`/`grid`, and neither this
|
|
1324
|
+
// header nor the virtualized `ListView` under it declares one. An orphaned
|
|
1325
|
+
// columnheader is worse than none. The sort state and the rank ride on the
|
|
1326
|
+
// header button's `aria-label` instead — see `ListHeaderLabel`.
|
|
1288
1327
|
return (
|
|
1289
1328
|
<div className={cls(
|
|
1290
1329
|
"flex items-center gap-4 px-5 py-1.5 select-none border-b bg-surface-50 dark:bg-surface-900",
|
|
@@ -1329,17 +1368,27 @@ function ListHeader({
|
|
|
1329
1368
|
function ListHeaderLabel({
|
|
1330
1369
|
column,
|
|
1331
1370
|
direction,
|
|
1371
|
+
position,
|
|
1332
1372
|
onSort
|
|
1333
1373
|
}: {
|
|
1334
1374
|
column: ListColumn;
|
|
1335
1375
|
direction?: "asc" | "desc";
|
|
1336
|
-
|
|
1376
|
+
/** Rank in a multi-key sort, or `undefined` when one key says it all. */
|
|
1377
|
+
position?: number;
|
|
1378
|
+
onSort?: (additive: boolean) => void;
|
|
1337
1379
|
}) {
|
|
1380
|
+
const { t } = useTranslation();
|
|
1381
|
+
|
|
1338
1382
|
const content = (
|
|
1339
1383
|
<>
|
|
1340
1384
|
<span className="truncate">{column.label}</span>
|
|
1341
1385
|
{direction === "asc" && <ArrowUpIcon size={12} className="flex-shrink-0"/>}
|
|
1342
1386
|
{direction === "desc" && <ArrowDownIcon size={12} className="flex-shrink-0"/>}
|
|
1387
|
+
{direction && position !== undefined && (
|
|
1388
|
+
<span className="flex-shrink-0 text-[9px] font-bold leading-none tabular-nums">
|
|
1389
|
+
{position + 1}
|
|
1390
|
+
</span>
|
|
1391
|
+
)}
|
|
1343
1392
|
</>
|
|
1344
1393
|
);
|
|
1345
1394
|
|
|
@@ -1352,11 +1401,27 @@ function ListHeaderLabel({
|
|
|
1352
1401
|
return <span className={cls(base, tone)}>{content}</span>;
|
|
1353
1402
|
}
|
|
1354
1403
|
|
|
1404
|
+
// What the *next* click does, which is the only thing a user needs from a
|
|
1405
|
+
// control with three states. This said "Sort by <column>" in every state,
|
|
1406
|
+
// so on a descending column the tooltip promised a sort where the click
|
|
1407
|
+
// removed one — and it said it in English regardless of the panel's
|
|
1408
|
+
// language, alone among the toolbar's sort controls.
|
|
1409
|
+
const nextAction = direction === "asc"
|
|
1410
|
+
? t("sort_descending")
|
|
1411
|
+
: direction === "desc"
|
|
1412
|
+
? t("sort_remove")
|
|
1413
|
+
: t("sort_ascending");
|
|
1414
|
+
const rank = direction && position !== undefined
|
|
1415
|
+
? ` (${t("sort_key_position", { position: position + 1 })})`
|
|
1416
|
+
: "";
|
|
1417
|
+
const label = `${column.label}${rank} — ${nextAction}. ${t("sort_shift_click_hint")}`;
|
|
1418
|
+
|
|
1355
1419
|
return (
|
|
1356
1420
|
<button
|
|
1357
1421
|
type="button"
|
|
1358
|
-
title={
|
|
1359
|
-
|
|
1422
|
+
title={label}
|
|
1423
|
+
aria-label={label}
|
|
1424
|
+
onClick={(event) => onSort(event.shiftKey)}
|
|
1360
1425
|
className={cls(base, tone, "cursor-pointer hover:text-surface-700 dark:hover:text-surface-200 transition-colors")}
|
|
1361
1426
|
>
|
|
1362
1427
|
{content}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Property } from "@rebasepro/types";
|
|
1
|
+
import type { OrderByTuple, Property } from "@rebasepro/types";
|
|
2
|
+
import { serializeOrderBy } from "@rebasepro/common";
|
|
2
3
|
import type { AdditionalFieldDelegate, EntityAction, AdminCollection } from "@rebasepro/admin-types";
|
|
3
4
|
import {
|
|
4
5
|
Entity,
|
|
@@ -1322,7 +1323,7 @@ function DefaultCollectionEmptyState({
|
|
|
1322
1323
|
|
|
1323
1324
|
/**
|
|
1324
1325
|
* Inflight count request deduplication map.
|
|
1325
|
-
* Keyed by `path|filterKey|
|
|
1326
|
+
* Keyed by `path|filterKey|sort|search` so that concurrent
|
|
1326
1327
|
* callers (e.g. React StrictMode double-mount) share the same promise.
|
|
1327
1328
|
*/
|
|
1328
1329
|
const inflightCountRequests = new Map<string, Promise<number>>();
|
|
@@ -1338,7 +1339,7 @@ export function EntitiesCount({
|
|
|
1338
1339
|
path: string,
|
|
1339
1340
|
collection: AdminCollection,
|
|
1340
1341
|
filter?: FilterValues<any>,
|
|
1341
|
-
sortBy?: [
|
|
1342
|
+
sortBy?: OrderByTuple[],
|
|
1342
1343
|
/**
|
|
1343
1344
|
* Required, not optional. The term sits in the same scope as the element
|
|
1344
1345
|
* that mounts this and is passed to the toolbar and the empty state beside
|
|
@@ -1352,8 +1353,9 @@ export function EntitiesCount({
|
|
|
1352
1353
|
|
|
1353
1354
|
const dataClient = useData();
|
|
1354
1355
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1356
|
+
// The whole sort as one string: it keys the dedup cache and drives the
|
|
1357
|
+
// effect, and a `sortBy` array is a new reference on every render.
|
|
1358
|
+
const sortKey = React.useMemo(() => sortBy ? serializeOrderBy(sortBy) ?? "" : "", [sortBy]);
|
|
1357
1359
|
|
|
1358
1360
|
// Use refs for values that should NOT trigger re-fetches
|
|
1359
1361
|
const dataClientRef = React.useRef(dataClient);
|
|
@@ -1375,14 +1377,16 @@ export function EntitiesCount({
|
|
|
1375
1377
|
|
|
1376
1378
|
// filterValues is already FilterValues — pass directly
|
|
1377
1379
|
const whereParams = filter && Object.keys(filter).length > 0 ? filter : undefined;
|
|
1378
|
-
const orderByParams
|
|
1380
|
+
const orderByParams = sortBy && sortBy.length > 0
|
|
1381
|
+
? sortBy.map(([field, direction]) => [String(field), direction] as OrderByTuple)
|
|
1382
|
+
: undefined;
|
|
1379
1383
|
|
|
1380
1384
|
// Deduplicate inflight count requests (e.g. React StrictMode double-mount)
|
|
1381
1385
|
// The search term is part of the query, so it is part of the key. The
|
|
1382
1386
|
// cache is module-level and outlives an unmount, so a key that omits it
|
|
1383
1387
|
// does not merely lose precision — it answers one search with a
|
|
1384
1388
|
// different search's total.
|
|
1385
|
-
const cacheKey = `${path}|${filterKey}|${
|
|
1389
|
+
const cacheKey = `${path}|${filterKey}|${sortKey}|${searchString ?? ""}`;
|
|
1386
1390
|
let countPromise = inflightCountRequests.get(cacheKey);
|
|
1387
1391
|
if (!countPromise) {
|
|
1388
1392
|
countPromise = accessor.count({
|
|
@@ -1403,7 +1407,14 @@ export function EntitiesCount({
|
|
|
1403
1407
|
});
|
|
1404
1408
|
|
|
1405
1409
|
return () => { cancelled = true; };
|
|
1406
|
-
|
|
1410
|
+
// `filterKey` and `sortKey` ARE `filter` and `sortBy` — the memoized
|
|
1411
|
+
// serializations a few lines up, and the only stable identity either
|
|
1412
|
+
// has. Both arrive as freshly built objects on every render, so
|
|
1413
|
+
// depending on them directly would re-run this effect each time and
|
|
1414
|
+
// fire a count request per render. The keys change exactly when the
|
|
1415
|
+
// values do, which is the condition this effect actually wants.
|
|
1416
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1417
|
+
}, [path, filterKey, sortKey, searchString]);
|
|
1407
1418
|
|
|
1408
1419
|
// Count is now displayed in the breadcrumb bar, this component only fetches and reports
|
|
1409
1420
|
return null;
|
|
@@ -141,15 +141,18 @@ parentEntityIds,
|
|
|
141
141
|
</Tooltip>
|
|
142
142
|
);
|
|
143
143
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// toolbar instead.
|
|
144
|
+
// Everywhere a `sortBy` means something — which is every view but the
|
|
145
|
+
// board, whose columns are ordered by their own order key and which ignores
|
|
146
|
+
// `sortBy` entirely, so the control would appear to do nothing.
|
|
148
147
|
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
148
|
+
// Including the table, whose headers were once thought to cover it. They
|
|
149
|
+
// build a multi-key sort (shift-click) and they show its ranks, but they
|
|
150
|
+
// cannot *re-rank* it: promoting the third key above the first means
|
|
151
|
+
// clearing the sort and shift-clicking all three back in the new order.
|
|
152
|
+
// Nor can they remove a middle key without cycling it through descending
|
|
153
|
+
// first. This popover is the only place either is one click, and a table
|
|
154
|
+
// with a three-key sort needs it more than a card grid does, not less.
|
|
155
|
+
const sortButton = resolvedProperties && viewMode !== "kanban" && (
|
|
153
156
|
<SortButton
|
|
154
157
|
key={"sort_button"}
|
|
155
158
|
tableController={tableController}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useMemo } from "react";
|
|
2
2
|
import { CheckIcon, ChevronsUpDownIcon, cls, FilterChip, Menu, MenuItem, Tooltip } from "@rebasepro/ui";
|
|
3
|
-
import type { FilterValues, FilterPreset } from "@rebasepro/types";
|
|
3
|
+
import type { FilterValues, FilterPreset, OrderByTuple } from "@rebasepro/types";
|
|
4
|
+
import { normalizeOrderBy } from "@rebasepro/common";
|
|
4
5
|
import type { EntityTableController, PropertyPath } from "@rebasepro/admin-types";
|
|
5
6
|
|
|
6
7
|
export interface FilterPresetsButtonProps<M extends Record<string, unknown>> {
|
|
@@ -216,19 +217,19 @@ export function FilterPresetsButton<M extends Record<string, unknown>>({
|
|
|
216
217
|
// otherwise find sort from remaining active presets
|
|
217
218
|
if (!wasActive && preset.sort) {
|
|
218
219
|
tableController.setSortBy?.(
|
|
219
|
-
preset.sort as
|
|
220
|
+
normalizeOrderBy(preset.sort) as OrderByTuple<Extract<keyof M, string> | (string & {})>[]
|
|
220
221
|
);
|
|
221
222
|
} else if (wasActive) {
|
|
222
|
-
let remainingSort: [
|
|
223
|
+
let remainingSort: OrderByTuple[] | undefined;
|
|
223
224
|
for (let i = 0; i < filterPresets.length; i++) {
|
|
224
225
|
if (i === index) continue;
|
|
225
226
|
const other = filterPresets[i] as FilterPreset<string>;
|
|
226
227
|
if (isPresetActive(other, currentFilters) && other.sort) {
|
|
227
|
-
remainingSort = other.sort;
|
|
228
|
+
remainingSort = normalizeOrderBy(other.sort);
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
tableController.setSortBy?.(
|
|
231
|
-
remainingSort as
|
|
232
|
+
remainingSort as OrderByTuple<Extract<keyof M, string> | (string & {})>[] | undefined
|
|
232
233
|
);
|
|
233
234
|
}
|
|
234
235
|
}
|