@rebasepro/admin 0.14.0 → 0.14.1-canary.g7e666eb

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 (26) hide show
  1. package/dist/{CollectionEditorDialog-CSheud_E.js → CollectionEditorDialog-BykOIZH4.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CSheud_E.js.map → CollectionEditorDialog-BykOIZH4.js.map} +1 -1
  3. package/dist/{PropertyEditView-CWZlC1gq.js → PropertyEditView-C9Der_Ej.js} +2 -2
  4. package/dist/{PropertyEditView-CWZlC1gq.js.map → PropertyEditView-C9Der_Ej.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-CwTpdzWF.js → RouterCollectionsStudioView-CfTHbBCA.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-CwTpdzWF.js.map → RouterCollectionsStudioView-CfTHbBCA.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-BDynF1En.js} +2 -2
  11. package/dist/{export-L53WektO.js.map → export-BDynF1En.js.map} +1 -1
  12. package/dist/{history-D5SKygpJ.js → history-DS2iXiGi.js} +2 -2
  13. package/dist/{history-D5SKygpJ.js.map → history-DS2iXiGi.js.map} +1 -1
  14. package/dist/{import-C_4edkoD.js → import-J5vb86xj.js} +2 -2
  15. package/dist/{import-C_4edkoD.js.map → import-J5vb86xj.js.map} +1 -1
  16. package/dist/index.js +6 -6
  17. package/dist/{util-C-D5yD2n.js → util-WQP-ljKW.js} +206 -90
  18. package/dist/util-WQP-ljKW.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 +80 -39
  22. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +19 -8
  23. package/src/components/CollectionViewBinding/FilterPresetsButton.tsx +6 -5
  24. package/src/components/CollectionViewBinding/SortButton.tsx +149 -37
  25. package/src/components/SelectableTable/SelectableTable.tsx +1 -1
  26. package/dist/util-C-D5yD2n.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import type { Properties } from "@rebasepro/types";
1
+ import type { OrderByTuple, Properties } from "@rebasepro/types";
2
2
 
3
3
  import React, { useCallback, useMemo, useState } from "react";
4
4
  import { EntityTableController } from "@rebasepro/admin-types";
@@ -7,11 +7,14 @@ import {
7
7
  ArrowUpDownIcon,
8
8
  ArrowUpIcon,
9
9
  Button,
10
+ ChevronDownIcon,
11
+ ChevronUpIcon,
10
12
  cls,
11
13
  iconSize,
12
14
  IconButton,
13
15
  Popover,
14
- Typography
16
+ Typography,
17
+ XIcon
15
18
  } from "@rebasepro/ui";
16
19
  import { useTranslation } from "@rebasepro/app";
17
20
  import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
@@ -33,16 +36,26 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
33
36
  };
34
37
 
35
38
  /**
36
- * Toolbar control that orders the collection by one property.
39
+ * Toolbar control that orders the collection.
37
40
  *
38
41
  * The table orders by clicking a column header, which leaves every other view
39
42
  * mode — list, cards, and either of them inside a split view — with no way to
40
43
  * order at all, even though the controller has always carried `sortBy` and the
41
44
  * query has always honoured it. This binds a control to it.
42
45
  *
43
- * Clicking the active property flips its direction, so ascending → descending
44
- * is one click rather than a trip through a direction selector; clicking a
45
- * different one starts it ascending. The popover stays open through both.
46
+ * It edits the *whole* order rather than a single key. A sort can have several
47
+ * keys, applied in the order shown: the second decides between rows the first
48
+ * calls equal, the third between rows the first two do. So the active keys sit
49
+ * at the top, numbered and movable, and the properties below them are the ones
50
+ * that can still be added under the last. Shift-clicking a column header does
51
+ * the same thing from the table; this is the view for the modes that have no
52
+ * headers, and the only place the order between keys can be changed.
53
+ *
54
+ * Clicking an active key flips its direction, so ascending → descending is one
55
+ * click rather than a trip through a direction selector; clicking an inactive
56
+ * one appends it ascending. The popover stays open through all of it — building
57
+ * a two-key order out of a control that closes on every click is four round
58
+ * trips through a button.
46
59
  */
47
60
  export function SortButton<M extends Record<string, unknown>>({
48
61
  tableController,
@@ -64,11 +77,18 @@ export function SortButton<M extends Record<string, unknown>>({
64
77
  checkFilterCombination
65
78
  } = tableController;
66
79
 
67
- const activeKey = sortBy?.[0];
68
- const activeDirection = sortBy?.[1];
80
+ const keys = useMemo(() => (sortBy ?? []) as OrderByTuple[], [sortBy]);
81
+ const activeIndex = useMemo(() => {
82
+ const index = new Map<string, { direction: "asc" | "desc"; position: number }>();
83
+ keys.forEach(([key, direction], position) => {
84
+ if (!index.has(key)) index.set(key, { direction,
85
+ position });
86
+ });
87
+ return index;
88
+ }, [keys]);
69
89
 
70
- const applySort = useCallback((next?: [string, "asc" | "desc"]) => {
71
- setSortBy?.(next);
90
+ const applySort = useCallback((next?: OrderByTuple[]) => {
91
+ setSortBy?.((next && next.length > 0 ? next : undefined) as Parameters<NonNullable<typeof setSortBy>>[0]);
72
92
  // Re-ordering a partially loaded collection is the same event as
73
93
  // clicking a table header: the rows already fetched are no longer the
74
94
  // ones the query answers with, so pagination starts over rather than
@@ -78,21 +98,41 @@ export function SortButton<M extends Record<string, unknown>>({
78
98
  setItemCount?.(pageSize);
79
99
  }, [setSortBy, setItemCount, pageSize]);
80
100
 
101
+ /** Flip an active key's direction, or append an inactive one ascending. */
81
102
  const onOptionClick = useCallback((key: string) => {
82
- if (key === activeKey) {
83
- applySort([key, activeDirection === "asc" ? "desc" : "asc"]);
84
- } else {
85
- applySort([key, "asc"]);
103
+ const active = activeIndex.get(key);
104
+ if (!active) {
105
+ applySort([...keys, [key, "asc"]]);
106
+ return;
86
107
  }
87
- }, [activeKey, activeDirection, applySort]);
108
+ applySort(keys.map(entry => entry[0] === key
109
+ ? [key, active.direction === "asc" ? "desc" : "asc"] as OrderByTuple
110
+ : entry));
111
+ }, [activeIndex, applySort, keys]);
112
+
113
+ const removeKey = useCallback((key: string) => {
114
+ applySort(keys.filter(([existing]) => existing !== key));
115
+ }, [applySort, keys]);
116
+
117
+ /** Swap a key with its neighbour, which is what changes which one decides. */
118
+ const moveKey = useCallback((position: number, delta: -1 | 1) => {
119
+ const target = position + delta;
120
+ if (target < 0 || target >= keys.length) return;
121
+ const next = [...keys];
122
+ [next[position], next[target]] = [next[target], next[position]];
123
+ applySort(next);
124
+ }, [applySort, keys]);
88
125
 
89
126
  if (!setSortBy || options.length === 0) {
90
127
  return null;
91
128
  }
92
129
 
93
- const activeOption = options.find(option => option.key === activeKey);
94
- const label = activeOption
95
- ? `${t("sort_by")}: ${activeOption.title}`
130
+ const primary = keys[0];
131
+ const primaryOption = primary ? options.find(option => option.key === primary[0]) : undefined;
132
+ const label = primaryOption
133
+ ? keys.length > 1
134
+ ? `${t("sort_by")}: ${primaryOption.title} +${keys.length - 1}`
135
+ : `${t("sort_by")}: ${primaryOption.title}`
96
136
  : t("sort");
97
137
 
98
138
  // Icon only, at every width. The arrow says which way the order runs; which
@@ -105,25 +145,28 @@ export function SortButton<M extends Record<string, unknown>>({
105
145
  // second time in the same click and the popover would never open; a
106
146
  // `Tooltip` in between would swallow the trigger's ref.
107
147
  const arrowSize = compact ? iconSize.smallest : iconSize.small;
148
+ const primaryDirection = primary?.[1];
108
149
 
109
150
  const trigger = (
110
151
  <IconButton
111
152
  size={"small"}
112
153
  aria-label={label}
113
154
  title={label}
114
- className={cls(activeOption && "text-primary")}
155
+ className={cls(primaryOption && "text-primary")}
115
156
  >
116
157
  {/* Same icon size as the filter controls it sits between: 16 in the
117
158
  split view's narrow toolbar, 20 everywhere else. It was a flat
118
159
  16, which read as a smaller button next to them. */}
119
- {activeDirection === "desc"
160
+ {primaryDirection === "desc"
120
161
  ? <ArrowDownIcon size={arrowSize}/>
121
- : activeDirection === "asc"
162
+ : primaryDirection === "asc"
122
163
  ? <ArrowUpIcon size={arrowSize}/>
123
164
  : <ArrowUpDownIcon size={arrowSize}/>}
124
165
  </IconButton>
125
166
  );
126
167
 
168
+ const inactiveOptions = options.filter(option => !activeIndex.has(option.key));
169
+
127
170
  return (
128
171
  <Popover
129
172
  open={open}
@@ -133,7 +176,7 @@ export function SortButton<M extends Record<string, unknown>>({
133
176
  align="start"
134
177
  trigger={trigger}
135
178
  >
136
- <div className="py-2 min-w-[240px] flex flex-col">
179
+ <div className="py-2 min-w-[280px] max-w-[360px] flex flex-col">
137
180
  <Typography
138
181
  variant="caption"
139
182
  color="secondary"
@@ -142,43 +185,112 @@ export function SortButton<M extends Record<string, unknown>>({
142
185
  {t("sort_by")}
143
186
  </Typography>
144
187
 
145
- <div className="max-h-[320px] overflow-y-auto flex flex-col">
146
- {options.map(({ key, title, property }) => {
147
- const active = key === activeKey;
188
+ {keys.length > 0 && (
189
+ <div className="flex flex-col">
190
+ {keys.map(([key, direction], position) => {
191
+ const option = options.find(o => o.key === key);
192
+ return (
193
+ <div
194
+ key={key}
195
+ className="flex items-center gap-1 pl-3 pr-2 py-1 text-sm text-primary"
196
+ >
197
+ {/* The rank, not decoration: it is the only
198
+ thing on screen saying which key wins. */}
199
+ <span className="flex-shrink-0 w-4 text-xs tabular-nums text-text-secondary dark:text-text-secondary-dark">
200
+ {position + 1}
201
+ </span>
202
+ <button
203
+ type="button"
204
+ onClick={() => onOptionClick(key)}
205
+ title={direction === "asc" ? t("sort_descending") : t("sort_ascending")}
206
+ className="flex items-center gap-2 flex-grow min-w-0 text-left cursor-pointer"
207
+ >
208
+ {option && (
209
+ <span className="flex-shrink-0 flex items-center">
210
+ {getIconForProperty(option.property, "smallest")}
211
+ </span>
212
+ )}
213
+ <span className="flex-grow truncate">{option?.title ?? key}</span>
214
+ <span className="flex-shrink-0 flex items-center">
215
+ {direction === "desc"
216
+ ? <ArrowDownIcon size={iconSize.smallest}/>
217
+ : <ArrowUpIcon size={iconSize.smallest}/>}
218
+ </span>
219
+ </button>
220
+ {keys.length > 1 && (
221
+ <>
222
+ <IconButton
223
+ size="smallest"
224
+ aria-label={t("sort_move_up")}
225
+ title={t("sort_move_up")}
226
+ disabled={position === 0}
227
+ onClick={() => moveKey(position, -1)}
228
+ >
229
+ <ChevronUpIcon size={iconSize.smallest}/>
230
+ </IconButton>
231
+ <IconButton
232
+ size="smallest"
233
+ aria-label={t("sort_move_down")}
234
+ title={t("sort_move_down")}
235
+ disabled={position === keys.length - 1}
236
+ onClick={() => moveKey(position, 1)}
237
+ >
238
+ <ChevronDownIcon size={iconSize.smallest}/>
239
+ </IconButton>
240
+ </>
241
+ )}
242
+ <IconButton
243
+ size="smallest"
244
+ aria-label={t("sort_remove_key")}
245
+ title={t("sort_remove_key")}
246
+ onClick={() => removeKey(key)}
247
+ >
248
+ <XIcon size={iconSize.smallest}/>
249
+ </IconButton>
250
+ </div>
251
+ );
252
+ })}
253
+ </div>
254
+ )}
255
+
256
+ {inactiveOptions.length > 0 && (
257
+ <Typography
258
+ variant="caption"
259
+ color="secondary"
260
+ className="font-medium uppercase tracking-wider px-3 pt-2 pb-1"
261
+ >
262
+ {keys.length > 0 ? t("sort_then_by") : ""}
263
+ </Typography>
264
+ )}
265
+
266
+ <div className="max-h-[280px] overflow-y-auto flex flex-col">
267
+ {inactiveOptions.map(({ key, title, property }) => {
148
268
  // A driver may refuse a sort next to the filter that is
149
269
  // already applied. Offering it would turn the click
150
270
  // into a failed request, so it is disabled with the
151
271
  // same authority the table headers ask.
152
272
  const disabled = checkFilterCombination
153
- ? !checkFilterCombination(filterValues ?? {}, [key, active && activeDirection === "asc" ? "desc" : "asc"])
273
+ ? !checkFilterCombination(filterValues ?? {}, [...keys, [key, "asc"]])
154
274
  : false;
155
275
  return (
156
276
  <button
157
277
  key={key}
158
278
  type="button"
159
279
  role="menuitemradio"
160
- aria-checked={active}
280
+ aria-checked={false}
161
281
  disabled={disabled}
162
282
  onClick={() => onOptionClick(key)}
163
283
  className={cls(
164
284
  "flex items-center gap-2 px-3 py-1.5 text-sm text-left w-full",
165
285
  disabled
166
286
  ? "opacity-50 cursor-not-allowed"
167
- : "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900",
168
- active && "text-primary"
287
+ : "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900"
169
288
  )}
170
289
  >
171
290
  <span className="flex-shrink-0 flex items-center">
172
291
  {getIconForProperty(property, "smallest")}
173
292
  </span>
174
293
  <span className="flex-grow truncate">{title}</span>
175
- {active && (
176
- <span className="flex-shrink-0 flex items-center">
177
- {activeDirection === "desc"
178
- ? <ArrowDownIcon size={iconSize.smallest}/>
179
- : <ArrowUpIcon size={iconSize.smallest}/>}
180
- </span>
181
- )}
182
294
  </button>
183
295
  );
184
296
  })}
@@ -189,7 +301,7 @@ export function SortButton<M extends Record<string, unknown>>({
189
301
  variant="text"
190
302
  size="small"
191
303
  className="w-full"
192
- disabled={!activeOption}
304
+ disabled={keys.length === 0}
193
305
  onClick={() => applySort(undefined)}
194
306
  >
195
307
  {t("clear_sort")}
@@ -284,7 +284,7 @@ export const SelectableTable = function SelectableTable<M extends Record<string,
284
284
  filter={filterValues as any}
285
285
  onFilterUpdate={setFilterValues ? onFilterUpdate : undefined}
286
286
  sortBy={sortBy}
287
- onSortByUpdate={setSortBy as ((sortBy?: [string, "asc" | "desc"]) => void)}
287
+ onSortByUpdate={setSortBy as ((sortBy?: [string, "asc" | "desc"][]) => void)}
288
288
  hoverRow={hoverRow}
289
289
  initialScroll={initialScroll}
290
290
  onScroll={onScroll}