@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
@@ -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,15 @@ import {
7
7
  ArrowUpDownIcon,
8
8
  ArrowUpIcon,
9
9
  Button,
10
+ ChevronDownIcon,
11
+ ChevronUpIcon,
10
12
  cls,
13
+ defaultBorderMixin,
11
14
  iconSize,
12
15
  IconButton,
13
16
  Popover,
14
- Typography
17
+ Typography,
18
+ XIcon
15
19
  } from "@rebasepro/ui";
16
20
  import { useTranslation } from "@rebasepro/app";
17
21
  import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
@@ -33,16 +37,26 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
33
37
  };
34
38
 
35
39
  /**
36
- * Toolbar control that orders the collection by one property.
40
+ * Toolbar control that orders the collection.
37
41
  *
38
42
  * The table orders by clicking a column header, which leaves every other view
39
43
  * mode — list, cards, and either of them inside a split view — with no way to
40
44
  * order at all, even though the controller has always carried `sortBy` and the
41
45
  * query has always honoured it. This binds a control to it.
42
46
  *
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.
47
+ * It edits the *whole* order rather than a single key. A sort can have several
48
+ * keys, applied in the order shown: the second decides between rows the first
49
+ * calls equal, the third between rows the first two do. So the active keys sit
50
+ * at the top, numbered and movable, and the properties below them are the ones
51
+ * that can still be added under the last. Shift-clicking a column header does
52
+ * the same thing from the table; this is the view for the modes that have no
53
+ * headers, and the only place the order between keys can be changed.
54
+ *
55
+ * Clicking an active key flips its direction, so ascending → descending is one
56
+ * click rather than a trip through a direction selector; clicking an inactive
57
+ * one appends it ascending. The popover stays open through all of it — building
58
+ * a two-key order out of a control that closes on every click is four round
59
+ * trips through a button.
46
60
  */
47
61
  export function SortButton<M extends Record<string, unknown>>({
48
62
  tableController,
@@ -64,11 +78,18 @@ export function SortButton<M extends Record<string, unknown>>({
64
78
  checkFilterCombination
65
79
  } = tableController;
66
80
 
67
- const activeKey = sortBy?.[0];
68
- const activeDirection = sortBy?.[1];
81
+ const keys = useMemo(() => (sortBy ?? []) as OrderByTuple[], [sortBy]);
82
+ const activeIndex = useMemo(() => {
83
+ const index = new Map<string, { direction: "asc" | "desc"; position: number }>();
84
+ keys.forEach(([key, direction], position) => {
85
+ if (!index.has(key)) index.set(key, { direction,
86
+ position });
87
+ });
88
+ return index;
89
+ }, [keys]);
69
90
 
70
- const applySort = useCallback((next?: [string, "asc" | "desc"]) => {
71
- setSortBy?.(next);
91
+ const applySort = useCallback((next?: OrderByTuple[]) => {
92
+ setSortBy?.((next && next.length > 0 ? next : undefined) as Parameters<NonNullable<typeof setSortBy>>[0]);
72
93
  // Re-ordering a partially loaded collection is the same event as
73
94
  // clicking a table header: the rows already fetched are no longer the
74
95
  // ones the query answers with, so pagination starts over rather than
@@ -78,21 +99,54 @@ export function SortButton<M extends Record<string, unknown>>({
78
99
  setItemCount?.(pageSize);
79
100
  }, [setSortBy, setItemCount, pageSize]);
80
101
 
102
+ /** Flip an active key's direction, or append an inactive one ascending. */
81
103
  const onOptionClick = useCallback((key: string) => {
82
- if (key === activeKey) {
83
- applySort([key, activeDirection === "asc" ? "desc" : "asc"]);
84
- } else {
85
- applySort([key, "asc"]);
104
+ const active = activeIndex.get(key);
105
+ if (!active) {
106
+ applySort([...keys, [key, "asc"]]);
107
+ return;
86
108
  }
87
- }, [activeKey, activeDirection, applySort]);
109
+ applySort(keys.map(entry => entry[0] === key
110
+ ? [key, active.direction === "asc" ? "desc" : "asc"] as OrderByTuple
111
+ : entry));
112
+ }, [activeIndex, applySort, keys]);
113
+
114
+ const removeKey = useCallback((key: string) => {
115
+ applySort(keys.filter(([existing]) => existing !== key));
116
+ }, [applySort, keys]);
117
+
118
+ /** What a click on an active key will do to it, for the tooltip and the label. */
119
+ const flipLabel = useCallback(
120
+ (direction: "asc" | "desc") => direction === "asc" ? t("sort_descending") : t("sort_ascending"),
121
+ [t]
122
+ );
123
+
124
+ /** Swap a key with its neighbour, which is what changes which one decides. */
125
+ const moveKey = useCallback((position: number, delta: -1 | 1) => {
126
+ const target = position + delta;
127
+ if (target < 0 || target >= keys.length) return;
128
+ const next = [...keys];
129
+ [next[position], next[target]] = [next[target], next[position]];
130
+ applySort(next);
131
+ }, [applySort, keys]);
88
132
 
89
133
  if (!setSortBy || options.length === 0) {
90
134
  return null;
91
135
  }
92
136
 
93
- const activeOption = options.find(option => option.key === activeKey);
94
- const label = activeOption
95
- ? `${t("sort_by")}: ${activeOption.title}`
137
+ const primary = keys[0];
138
+ // The key's own name when it has no option to be titled by — a sort can
139
+ // outlive the column it names (a property hidden since, a `collection.sort`
140
+ // on something the panel does not render). Falling back to "Sort" there
141
+ // showed the neutral icon and the inactive label over a collection that
142
+ // *was* sorted, which is the one thing this control exists to report.
143
+ const primaryTitle = primary
144
+ ? options.find(option => option.key === primary[0])?.title ?? primary[0]
145
+ : undefined;
146
+ const label = primaryTitle
147
+ ? keys.length > 1
148
+ ? `${t("sort_by")}: ${primaryTitle} +${keys.length - 1}`
149
+ : `${t("sort_by")}: ${primaryTitle}`
96
150
  : t("sort");
97
151
 
98
152
  // Icon only, at every width. The arrow says which way the order runs; which
@@ -105,25 +159,28 @@ export function SortButton<M extends Record<string, unknown>>({
105
159
  // second time in the same click and the popover would never open; a
106
160
  // `Tooltip` in between would swallow the trigger's ref.
107
161
  const arrowSize = compact ? iconSize.smallest : iconSize.small;
162
+ const primaryDirection = primary?.[1];
108
163
 
109
164
  const trigger = (
110
165
  <IconButton
111
166
  size={"small"}
112
167
  aria-label={label}
113
168
  title={label}
114
- className={cls(activeOption && "text-primary")}
169
+ className={cls(primaryTitle && "text-primary")}
115
170
  >
116
171
  {/* Same icon size as the filter controls it sits between: 16 in the
117
172
  split view's narrow toolbar, 20 everywhere else. It was a flat
118
173
  16, which read as a smaller button next to them. */}
119
- {activeDirection === "desc"
174
+ {primaryDirection === "desc"
120
175
  ? <ArrowDownIcon size={arrowSize}/>
121
- : activeDirection === "asc"
176
+ : primaryDirection === "asc"
122
177
  ? <ArrowUpIcon size={arrowSize}/>
123
178
  : <ArrowUpDownIcon size={arrowSize}/>}
124
179
  </IconButton>
125
180
  );
126
181
 
182
+ const inactiveOptions = options.filter(option => !activeIndex.has(option.key));
183
+
127
184
  return (
128
185
  <Popover
129
186
  open={open}
@@ -133,7 +190,7 @@ export function SortButton<M extends Record<string, unknown>>({
133
190
  align="start"
134
191
  trigger={trigger}
135
192
  >
136
- <div className="py-2 min-w-[240px] flex flex-col">
193
+ <div className="py-2 min-w-[280px] max-w-[360px] flex flex-col">
137
194
  <Typography
138
195
  variant="caption"
139
196
  color="secondary"
@@ -142,43 +199,139 @@ export function SortButton<M extends Record<string, unknown>>({
142
199
  {t("sort_by")}
143
200
  </Typography>
144
201
 
145
- <div className="max-h-[320px] overflow-y-auto flex flex-col">
146
- {options.map(({ key, title, property }) => {
147
- const active = key === activeKey;
202
+ {keys.length > 0 && (
203
+ // Bounded for the same reason the option list below is: the
204
+ // active keys are as many as the collection has sortable
205
+ // columns, and an unbounded column pushed "Clear sort" off
206
+ // the bottom of the screen on a wide collection.
207
+ <div className="flex flex-col max-h-[200px] overflow-y-auto">
208
+ {keys.map(([key, direction], position) => {
209
+ const option = options.find(o => o.key === key);
210
+ return (
211
+ <div
212
+ key={key}
213
+ className="flex items-center gap-1 pl-3 pr-2 py-1 text-sm text-primary"
214
+ >
215
+ {/* The rank, not decoration: it is the only
216
+ thing on screen saying which key wins. */}
217
+ <span className="flex-shrink-0 w-4 text-xs tabular-nums text-text-secondary dark:text-text-secondary-dark">
218
+ {position + 1}
219
+ </span>
220
+ <button
221
+ type="button"
222
+ onClick={() => onOptionClick(key)}
223
+ title={flipLabel(direction)}
224
+ aria-label={`${option?.title ?? key} — ${t("sort_key_position", { position: position + 1 })}. ${flipLabel(direction)}`}
225
+ className="flex items-center gap-2 flex-grow min-w-0 text-left cursor-pointer"
226
+ >
227
+ {option && (
228
+ <span className="flex-shrink-0 flex items-center">
229
+ {getIconForProperty(option.property, "smallest")}
230
+ </span>
231
+ )}
232
+ <span className="flex-grow truncate">{option?.title ?? key}</span>
233
+ <span className="flex-shrink-0 flex items-center">
234
+ {direction === "desc"
235
+ ? <ArrowDownIcon size={iconSize.smallest}/>
236
+ : <ArrowUpIcon size={iconSize.smallest}/>}
237
+ </span>
238
+ </button>
239
+ {/* Ruled off from the key itself. The row
240
+ carries two arrows that mean different
241
+ things — the direction this key runs, and
242
+ which key outranks which — and side by
243
+ side at 14px they read as one control
244
+ with a stutter. The rule says where the
245
+ key ends and the controls on it begin. */}
246
+ <div className={cls(
247
+ "flex-shrink-0 flex items-center gap-0.5 ml-1 pl-1 border-l",
248
+ defaultBorderMixin
249
+ )}>
250
+ {keys.length > 1 && (
251
+ <>
252
+ <IconButton
253
+ size="smallest"
254
+ aria-label={t("sort_move_up")}
255
+ title={t("sort_move_up")}
256
+ disabled={position === 0}
257
+ onClick={() => moveKey(position, -1)}
258
+ >
259
+ <ChevronUpIcon size={iconSize.smallest}/>
260
+ </IconButton>
261
+ <IconButton
262
+ size="smallest"
263
+ aria-label={t("sort_move_down")}
264
+ title={t("sort_move_down")}
265
+ disabled={position === keys.length - 1}
266
+ onClick={() => moveKey(position, 1)}
267
+ >
268
+ <ChevronDownIcon size={iconSize.smallest}/>
269
+ </IconButton>
270
+ </>
271
+ )}
272
+ <IconButton
273
+ size="smallest"
274
+ aria-label={t("sort_remove_key")}
275
+ title={t("sort_remove_key")}
276
+ onClick={() => removeKey(key)}
277
+ >
278
+ <XIcon size={iconSize.smallest}/>
279
+ </IconButton>
280
+ </div>
281
+ </div>
282
+ );
283
+ })}
284
+ </div>
285
+ )}
286
+
287
+ {/* Only once there is a key to come *after*. With none, the
288
+ "Sort by" heading above already names the list, and an empty
289
+ caption here left a band of padding that read as a divider
290
+ between two groups where there was only one. */}
291
+ {keys.length > 0 && inactiveOptions.length > 0 && (
292
+ <Typography
293
+ variant="caption"
294
+ color="secondary"
295
+ className="font-medium uppercase tracking-wider px-3 pt-2 pb-1"
296
+ >
297
+ {t("sort_then_by")}
298
+ </Typography>
299
+ )}
300
+
301
+ <div className="max-h-[280px] overflow-y-auto flex flex-col">
302
+ {inactiveOptions.map(({ key, title, property }) => {
148
303
  // A driver may refuse a sort next to the filter that is
149
304
  // already applied. Offering it would turn the click
150
305
  // into a failed request, so it is disabled with the
151
306
  // same authority the table headers ask.
152
307
  const disabled = checkFilterCombination
153
- ? !checkFilterCombination(filterValues ?? {}, [key, active && activeDirection === "asc" ? "desc" : "asc"])
308
+ ? !checkFilterCombination(filterValues ?? {}, [...keys, [key, "asc"]])
154
309
  : false;
155
310
  return (
156
311
  <button
157
312
  key={key}
158
313
  type="button"
159
- role="menuitemradio"
160
- aria-checked={active}
314
+ // Not `menuitemradio`: a sort is a *list* of
315
+ // keys, so picking one does not unpick another,
316
+ // and the role announced the opposite. There is
317
+ // no `role="menu"` around this either, which
318
+ // left the radio orphaned as well as wrong.
319
+ aria-label={keys.length > 0
320
+ ? `${t("sort_then_by")} ${title}`
321
+ : `${t("sort_by")} ${title}`}
161
322
  disabled={disabled}
162
323
  onClick={() => onOptionClick(key)}
163
324
  className={cls(
164
325
  "flex items-center gap-2 px-3 py-1.5 text-sm text-left w-full",
165
326
  disabled
166
327
  ? "opacity-50 cursor-not-allowed"
167
- : "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900",
168
- active && "text-primary"
328
+ : "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900"
169
329
  )}
170
330
  >
171
331
  <span className="flex-shrink-0 flex items-center">
172
332
  {getIconForProperty(property, "smallest")}
173
333
  </span>
174
334
  <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
335
  </button>
183
336
  );
184
337
  })}
@@ -189,7 +342,7 @@ export function SortButton<M extends Record<string, unknown>>({
189
342
  variant="text"
190
343
  size="small"
191
344
  className="w-full"
192
- disabled={!activeOption}
345
+ disabled={keys.length === 0}
193
346
  onClick={() => applySort(undefined)}
194
347
  >
195
348
  {t("clear_sort")}
@@ -383,7 +383,12 @@ entityId }
383
383
  openEntityMode={layout} />
384
384
  : <div className="flex items-center justify-center w-full h-full p-3">
385
385
  <Typography variant={"label"}>
386
- {t("save_entity_before_subcollections") ?? "You need to save your entity before adding additional collections"}
386
+ {/* No `?? "…"` here: `t` returns the key itself
387
+ when it cannot find one, never null, so the
388
+ fallback never ran and this rendered the raw
389
+ `save_entity_before_subcollections`. The key
390
+ exists now, in all seven locales. */}
391
+ {t("save_entity_before_subcollections")}
387
392
  </Typography>
388
393
  </div>)
389
394
  }
@@ -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}