@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
|
@@ -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
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
68
|
-
const
|
|
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?: [
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
const active = activeIndex.get(key);
|
|
105
|
+
if (!active) {
|
|
106
|
+
applySort([...keys, [key, "asc"]]);
|
|
107
|
+
return;
|
|
86
108
|
}
|
|
87
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
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
|
-
{
|
|
174
|
+
{primaryDirection === "desc"
|
|
120
175
|
? <ArrowDownIcon size={arrowSize}/>
|
|
121
|
-
:
|
|
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-[
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
308
|
+
? !checkFilterCombination(filterValues ?? {}, [...keys, [key, "asc"]])
|
|
154
309
|
: false;
|
|
155
310
|
return (
|
|
156
311
|
<button
|
|
157
312
|
key={key}
|
|
158
313
|
type="button"
|
|
159
|
-
|
|
160
|
-
|
|
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={
|
|
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
|
-
{
|
|
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}
|