@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.
Files changed (28) hide show
  1. package/dist/{CollectionEditorDialog-CSheud_E.js → CollectionEditorDialog-BeS4lxGh.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CSheud_E.js.map → CollectionEditorDialog-BeS4lxGh.js.map} +1 -1
  3. package/dist/{PropertyEditView-CWZlC1gq.js → PropertyEditView-BFJ6H9-Q.js} +2 -2
  4. package/dist/{PropertyEditView-CWZlC1gq.js.map → PropertyEditView-BFJ6H9-Q.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-CwTpdzWF.js → RouterCollectionsStudioView-sfsUpEJz.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-CwTpdzWF.js.map → RouterCollectionsStudioView-sfsUpEJz.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +2 -1
  9. package/dist/components/CollectionViewBinding/SortButton.d.ts +14 -4
  10. package/dist/{export-L53WektO.js → export-KEf57Jso.js} +2 -2
  11. package/dist/{export-L53WektO.js.map → export-KEf57Jso.js.map} +1 -1
  12. package/dist/{history-D5SKygpJ.js → history-Ctd2cRn_.js} +2 -2
  13. package/dist/{history-D5SKygpJ.js.map → history-Ctd2cRn_.js.map} +1 -1
  14. package/dist/{import-C_4edkoD.js → import-BujhycMq.js} +2 -2
  15. package/dist/{import-C_4edkoD.js.map → import-BujhycMq.js.map} +1 -1
  16. package/dist/index.js +6 -6
  17. package/dist/{util-C-D5yD2n.js → util-bL-fk6C6.js} +217 -92
  18. package/dist/util-bL-fk6C6.js.map +1 -0
  19. package/package.json +9 -9
  20. package/src/collection_editor/ui/EditorCollectionActionStart.tsx +3 -2
  21. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +104 -39
  22. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +19 -8
  23. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +11 -8
  24. package/src/components/CollectionViewBinding/FilterPresetsButton.tsx +6 -5
  25. package/src/components/CollectionViewBinding/SortButton.tsx +191 -38
  26. package/src/components/DetailViewBinding.tsx +6 -1
  27. package/src/components/SelectableTable/SelectableTable.tsx +1 -1
  28. 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.0",
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.0",
87
- "@rebasepro/app": "0.14.0",
88
- "@rebasepro/forms": "0.14.0",
89
- "@rebasepro/inference": "0.14.0",
90
- "@rebasepro/types": "0.14.0",
91
- "@rebasepro/utils": "0.14.0",
92
- "@rebasepro/common": "0.14.0",
93
- "@rebasepro/ui": "0.14.0"
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?.sort);
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 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]);
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. 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.
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 [lastSortedKey, setLastSortedKey] = useState<string | undefined>(undefined);
576
+ const [lastSortedKeys, setLastSortedKeys] = useState<string[]>([]);
576
577
  const [lastFilteredKeys, setLastFilteredKeys] = useState<string[]>([]);
577
578
 
578
579
  useEffect(() => {
579
- if (sortedKey) setLastSortedKey(sortedKey);
580
- }, [sortedKey]);
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, ...(sortedKey ? [sortedKey] : [])]);
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
- request(sortedKey ?? lastSortedKey);
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, sortedKey, lastFilteredKeys, lastSortedKey, shownKeys, resolvedCollection, sortableKeys]);
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. Clicking a header here and clicking it
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 = sortBy?.[0] === key ? sortBy[1] : undefined;
745
- const next: [string, "asc" | "desc"] | undefined = active === "asc"
746
- ? [key, "desc"]
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
- : [key, "asc"];
750
- setSortBy(next);
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 = sortBy?.[0] === key ? sortBy[1] : undefined;
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, sortBy]);
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
- sortBy={sortBy as [string, "asc" | "desc"] | undefined}
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
- sortBy,
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
- sortBy?: [string, "asc" | "desc"];
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={sortBy?.[0] === column.key ? sortBy[1] : undefined}
1284
- onSort={column.sortable && sortIsAvailable(column.key) ? () => onColumnSort(column.key) : undefined}
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
- onSort?: () => void;
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={`Sort by ${column.label}`}
1359
- onClick={onSort}
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|sortByProperty|sortDir` so that concurrent
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?: [string, "asc" | "desc"],
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
- const sortByProperty = sortBy ? sortBy[0] : undefined;
1356
- const currentSort = sortBy ? sortBy[1] : undefined;
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: [string, "asc" | "desc"] | undefined = sortByProperty && currentSort ? [String(sortByProperty), currentSort] : undefined;
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}|${sortByProperty ?? ""}|${currentSort ?? ""}|${searchString ?? ""}`;
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
- }, [path, filterKey, sortByProperty, currentSort, searchString]);
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
- // Ordering the collection is a click on a column header which only the
145
- // table has. The list and card views showed rows in whatever order the
146
- // query returned with no way to change it, so they get the control in the
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
- // Not the table, where the headers already do this and say which column is
150
- // sorted; and not the board, which orders its columns by its own order key
151
- // and ignores `sortBy` entirely, so the control would appear to do nothing.
152
- const sortButton = resolvedProperties && (viewMode === "list" || viewMode === "cards") && (
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 [Extract<keyof M, string> | (string & {}), "asc" | "desc"]
220
+ normalizeOrderBy(preset.sort) as OrderByTuple<Extract<keyof M, string> | (string & {})>[]
220
221
  );
221
222
  } else if (wasActive) {
222
- let remainingSort: [string, "asc" | "desc"] | undefined;
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 [Extract<keyof M, string> | (string & {}), "asc" | "desc"] | undefined
232
+ remainingSort as OrderByTuple<Extract<keyof M, string> | (string & {})>[] | undefined
232
233
  );
233
234
  }
234
235
  }