@rebasepro/admin 0.13.1-canary.gf57a27e → 0.14.0
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-BnfOcqM_.js → CollectionEditorDialog-CSheud_E.js} +19 -9
- package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
- package/dist/{PropertyEditView-vLXglzIy.js → PropertyEditView-CWZlC1gq.js} +5 -5
- package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-DF3aiOuv.js → RouterCollectionsStudioView-CwTpdzWF.js} +4 -4
- package/dist/{RouterCollectionsStudioView-DF3aiOuv.js.map → RouterCollectionsStudioView-CwTpdzWF.js.map} +1 -1
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/RelationSelector.d.ts +13 -0
- package/dist/data_export/export/export.d.ts +7 -0
- package/dist/data_export/export/fetch_export_data.d.ts +48 -0
- package/dist/data_export/export/index.d.ts +1 -0
- package/dist/data_import/utils/csv.d.ts +30 -0
- package/dist/data_import/utils/index.d.ts +2 -0
- package/dist/data_import/utils/save_entities.d.ts +38 -0
- package/dist/data_import/utils/transforms.d.ts +9 -0
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/{export-C26xQN-g.js → export-L53WektO.js} +175 -39
- package/dist/export-L53WektO.js.map +1 -0
- package/dist/{history-DSS-Ebwh.js → history-D5SKygpJ.js} +2 -2
- package/dist/{history-DSS-Ebwh.js.map → history-D5SKygpJ.js.map} +1 -1
- package/dist/{import-Dhx96B6x.js → import-C_4edkoD.js} +5 -4
- package/dist/import-C_4edkoD.js.map +1 -0
- package/dist/index.js +93 -74
- package/dist/index.js.map +1 -1
- package/dist/preview/components/date_fns_locales.d.ts +9 -0
- package/dist/{util-Bt6VadeZ.js → util-C-D5yD2n.js} +567 -113
- package/dist/util-C-D5yD2n.js.map +1 -0
- package/package.json +10 -9
- package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
- package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
- package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
- package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +1 -0
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
- package/src/components/DefaultDrawer.tsx +7 -0
- package/src/components/DrawerNavigationItem.tsx +12 -0
- package/src/components/RelationSelector.tsx +52 -5
- package/src/components/SearchIconsView.tsx +14 -33
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
- package/src/components/UserSelector.tsx +47 -5
- package/src/data_export/export/ExportCollectionAction.tsx +101 -48
- package/src/data_export/export/export.ts +63 -16
- package/src/data_export/export/fetch_export_data.ts +90 -0
- package/src/data_export/export/index.ts +1 -0
- package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
- package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
- package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
- package/src/data_import/import/ImportCollectionAction.tsx +13 -2
- package/src/data_import/utils/csv.ts +153 -0
- package/src/data_import/utils/data.ts +7 -1
- package/src/data_import/utils/file_to_json.ts +77 -6
- package/src/data_import/utils/index.ts +2 -0
- package/src/data_import/utils/save_entities.ts +138 -0
- package/src/data_import/utils/transforms.ts +23 -0
- package/src/editor/components/image-bubble.tsx +2 -0
- package/src/editor/selectors/link-selector.tsx +1 -0
- package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
- package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
- package/src/preview/components/DatePreview.tsx +15 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/date_fns_locales.ts +125 -0
- package/dist/CollectionEditorDialog-BnfOcqM_.js.map +0 -1
- package/dist/PropertyEditView-vLXglzIy.js.map +0 -1
- package/dist/export-C26xQN-g.js.map +0 -1
- package/dist/import-Dhx96B6x.js.map +0 -1
- package/dist/util-Bt6VadeZ.js.map +0 -1
|
@@ -24,6 +24,12 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
|
|
|
24
24
|
* the options offered.
|
|
25
25
|
*/
|
|
26
26
|
properties: Properties;
|
|
27
|
+
/**
|
|
28
|
+
* Toolbar showing icon-only controls (split view, or a layout too narrow
|
|
29
|
+
* for the filters button's label). Only decides the icon size, which
|
|
30
|
+
* follows that button — see `CollectionViewStartActions`.
|
|
31
|
+
*/
|
|
32
|
+
compact?: boolean;
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -40,7 +46,8 @@ export type SortButtonProps<M extends Record<string, unknown>> = {
|
|
|
40
46
|
*/
|
|
41
47
|
export function SortButton<M extends Record<string, unknown>>({
|
|
42
48
|
tableController,
|
|
43
|
-
properties
|
|
49
|
+
properties,
|
|
50
|
+
compact
|
|
44
51
|
}: SortButtonProps<M>) {
|
|
45
52
|
|
|
46
53
|
const { t } = useTranslation();
|
|
@@ -97,6 +104,8 @@ export function SortButton<M extends Record<string, unknown>>({
|
|
|
97
104
|
// `asChild` — is what toggles it. A handler of our own would toggle it a
|
|
98
105
|
// second time in the same click and the popover would never open; a
|
|
99
106
|
// `Tooltip` in between would swallow the trigger's ref.
|
|
107
|
+
const arrowSize = compact ? iconSize.smallest : iconSize.small;
|
|
108
|
+
|
|
100
109
|
const trigger = (
|
|
101
110
|
<IconButton
|
|
102
111
|
size={"small"}
|
|
@@ -104,11 +113,14 @@ export function SortButton<M extends Record<string, unknown>>({
|
|
|
104
113
|
title={label}
|
|
105
114
|
className={cls(activeOption && "text-primary")}
|
|
106
115
|
>
|
|
116
|
+
{/* Same icon size as the filter controls it sits between: 16 in the
|
|
117
|
+
split view's narrow toolbar, 20 everywhere else. It was a flat
|
|
118
|
+
16, which read as a smaller button next to them. */}
|
|
107
119
|
{activeDirection === "desc"
|
|
108
|
-
? <ArrowDownIcon size={
|
|
120
|
+
? <ArrowDownIcon size={arrowSize}/>
|
|
109
121
|
: activeDirection === "asc"
|
|
110
|
-
? <ArrowUpIcon size={
|
|
111
|
-
: <ArrowUpDownIcon size={
|
|
122
|
+
? <ArrowUpIcon size={arrowSize}/>
|
|
123
|
+
: <ArrowUpDownIcon size={arrowSize}/>}
|
|
112
124
|
</IconButton>
|
|
113
125
|
);
|
|
114
126
|
|
|
@@ -79,12 +79,17 @@ export function ViewModeToggle({
|
|
|
79
79
|
const { t } = useTranslation();
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
// Get icon for current view mode
|
|
82
|
+
// Get icon for current view mode.
|
|
83
|
+
//
|
|
84
|
+
// `small` on the trigger, matching the filters button beside it — the two
|
|
85
|
+
// labelled buttons open the toolbar together, and at 16 against its 20 this
|
|
86
|
+
// one read as the smaller of the pair. The options inside the popover stay
|
|
87
|
+
// at `smallest`, which is the size every other menu icon uses.
|
|
83
88
|
const getViewModeIcon = () => {
|
|
84
|
-
if (viewMode === "kanban") return <KanbanIcon size={iconSize.
|
|
85
|
-
if (viewMode === "cards") return <LayoutGridIcon size={iconSize.
|
|
86
|
-
if (viewMode === "table") return <TableIcon size={iconSize.
|
|
87
|
-
return <ListIcon size={iconSize.
|
|
89
|
+
if (viewMode === "kanban") return <KanbanIcon size={iconSize.small}/>;
|
|
90
|
+
if (viewMode === "cards") return <LayoutGridIcon size={iconSize.small}/>;
|
|
91
|
+
if (viewMode === "table") return <TableIcon size={iconSize.small}/>;
|
|
92
|
+
return <ListIcon size={iconSize.small}/>;
|
|
88
93
|
};
|
|
89
94
|
|
|
90
95
|
const getViewModeName = () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { Entity, FilterValues } from "@rebasepro/types";
|
|
2
|
+
import { Entity, FilterValues, MAX_LIST_LIMIT } from "@rebasepro/types";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { getRelationIncludeParams, useData, useRebaseContext } from "@rebasepro/app";
|
|
5
5
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
@@ -220,6 +220,14 @@ export function useBoardDataController<M extends Record<string, unknown> = any,
|
|
|
220
220
|
// Skip if we're in the middle of cleanup
|
|
221
221
|
if (isCleaningUpRef.current) return;
|
|
222
222
|
|
|
223
|
+
// `itemCount` grows by one page per "load more" click with nothing
|
|
224
|
+
// stopping it, and a read above `MAX_LIST_LIMIT` is refused rather than
|
|
225
|
+
// trimmed — a column would stop loading with an error instead of
|
|
226
|
+
// stopping at the ceiling. Ask for what the platform serves; `hasMore`
|
|
227
|
+
// below still compares against the count the board asked for, so the
|
|
228
|
+
// column reports itself exhausted exactly where it did before.
|
|
229
|
+
const requestedCount = Math.min(itemCount, MAX_LIST_LIMIT);
|
|
230
|
+
|
|
223
231
|
const currentDataClient = dataClientRef.current;
|
|
224
232
|
const currentCollection = collectionRef.current;
|
|
225
233
|
const currentContext = contextRef.current;
|
|
@@ -406,7 +414,7 @@ values: { ...e.values,
|
|
|
406
414
|
|
|
407
415
|
const fetchOnce = () => accessor.find({
|
|
408
416
|
where: whereFilter,
|
|
409
|
-
limit:
|
|
417
|
+
limit: requestedCount,
|
|
410
418
|
orderBy: orderByParam,
|
|
411
419
|
include: includeParams,
|
|
412
420
|
searchString: currentSearchString
|
|
@@ -445,7 +453,7 @@ values: { ...e.values,
|
|
|
445
453
|
let liveDataReceived = false;
|
|
446
454
|
const unsubscribe = accessor.listen({
|
|
447
455
|
where: whereFilter,
|
|
448
|
-
limit:
|
|
456
|
+
limit: requestedCount,
|
|
449
457
|
orderBy: orderByParam,
|
|
450
458
|
include: includeParams,
|
|
451
459
|
// Read into a local at the top of this function and then used
|
|
@@ -274,13 +274,20 @@ export function DrawerLogo({
|
|
|
274
274
|
}) {
|
|
275
275
|
|
|
276
276
|
const showFullContent = drawerOpen || drawerHovered;
|
|
277
|
+
const { t } = useTranslation();
|
|
277
278
|
|
|
278
279
|
return (
|
|
279
280
|
<div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
|
|
280
281
|
{/* Logo — always visible */}
|
|
282
|
+
{/* Named explicitly because neither branch below carries text: the
|
|
283
|
+
stock `RebaseLogo` is a bare <svg> with no <title>, and a custom
|
|
284
|
+
`logo` only ever gets the generic alt "Logo", which names the image
|
|
285
|
+
rather than saying where the link goes. The title link beside it is
|
|
286
|
+
no help either — it renders only when a `title` is set. */}
|
|
281
287
|
<Link
|
|
282
288
|
className="shrink-0 flex items-center justify-center w-[56px] h-[40px]"
|
|
283
289
|
to={logoDestination}
|
|
290
|
+
aria-label={t("home") || "Home"}
|
|
284
291
|
>
|
|
285
292
|
{logo
|
|
286
293
|
? <img src={logo} alt="Logo" className="w-[28px] h-[28px] object-contain"/>
|
|
@@ -61,6 +61,18 @@ export function DrawerNavigationItem({
|
|
|
61
61
|
const listItem = <div>
|
|
62
62
|
<NavLink
|
|
63
63
|
onClick={onClick}
|
|
64
|
+
// The label below is `hidden` (display:none) while the rail is
|
|
65
|
+
// collapsed, which takes it out of the accessibility tree along with
|
|
66
|
+
// the row's only text — a collapsed rail was a column of links with no
|
|
67
|
+
// accessible name at all. The tooltip does not cover this: Radix wires
|
|
68
|
+
// a trigger to its content with `aria-describedby`, which is a
|
|
69
|
+
// description rather than a name, and only while the tooltip is open —
|
|
70
|
+
// and this one is masked shut whenever the drawer is open.
|
|
71
|
+
//
|
|
72
|
+
// Named unconditionally rather than only when collapsed: expanded, this
|
|
73
|
+
// is the same string the row already displays, so the accessible name
|
|
74
|
+
// stays stable across the transition instead of appearing with it.
|
|
75
|
+
aria-label={name}
|
|
64
76
|
style={{
|
|
65
77
|
width: "100%",
|
|
66
78
|
transition: drawerOpen ? "width 150ms ease-in" : undefined
|
|
@@ -29,7 +29,6 @@ import { EntityPreviewBindingData } from "./EntityPreviewBinding";
|
|
|
29
29
|
import { useData, useRelationSelector } from "@rebasepro/app";
|
|
30
30
|
import { useSidePanel } from "../hooks/useSidePanel";
|
|
31
31
|
import { normalizeToEntityRelation } from "@rebasepro/common";
|
|
32
|
-
import { EmptyValue } from "../preview";
|
|
33
32
|
|
|
34
33
|
/** Whether an authored relation yields many rows. Derived from its kind. */
|
|
35
34
|
function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
|
|
@@ -91,7 +90,20 @@ export interface RelationSelectorProps {
|
|
|
91
90
|
pageSize?: number;
|
|
92
91
|
emptyPlaceholder?: string;
|
|
93
92
|
searchPlaceholder?: string;
|
|
93
|
+
/** Shown in the open list when a search matched nothing. */
|
|
94
94
|
noResultsText?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Shown on the closed trigger when nothing is selected. Says only that the
|
|
97
|
+
* field is empty — see the note by `resolvedPlaceholder` for why it cannot
|
|
98
|
+
* say whether there is anything to pick.
|
|
99
|
+
*/
|
|
100
|
+
emptyText?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Shown in the open list when the target collection turned out to hold no
|
|
103
|
+
* rows at all, as opposed to a search simply not matching. Defaults to a
|
|
104
|
+
* sentence naming the target collection.
|
|
105
|
+
*/
|
|
106
|
+
emptyCollectionText?: string;
|
|
95
107
|
loadingText?: string;
|
|
96
108
|
}
|
|
97
109
|
|
|
@@ -115,7 +127,9 @@ export const RelationSelector = React.forwardRef<
|
|
|
115
127
|
pageSize,
|
|
116
128
|
emptyPlaceholder,
|
|
117
129
|
searchPlaceholder = "Search...",
|
|
118
|
-
noResultsText = "No
|
|
130
|
+
noResultsText = "No matches.",
|
|
131
|
+
emptyText = "Select…",
|
|
132
|
+
emptyCollectionText,
|
|
119
133
|
loadingText = "Loading..."
|
|
120
134
|
},
|
|
121
135
|
ref
|
|
@@ -129,6 +143,11 @@ export const RelationSelector = React.forwardRef<
|
|
|
129
143
|
|
|
130
144
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
131
145
|
const isPopoverOpenRef = useRef(false);
|
|
146
|
+
// The list is only worth fetching once the picker has been opened. A
|
|
147
|
+
// table cell mounts one of these per row, and the selected value is
|
|
148
|
+
// resolved by its own effect below, not from the list. Latches on the
|
|
149
|
+
// first open so a closed popover keeps its results.
|
|
150
|
+
const [listRequested, setListRequested] = useState(false);
|
|
132
151
|
const [selectedItems, setSelectedItems] = useState<RelationItem[]>([]);
|
|
133
152
|
const [isLoadingSelectedItems, setIsLoadingSelectedItems] = useState(false);
|
|
134
153
|
const [searchString, setSearchString] = useState<string>("");
|
|
@@ -151,7 +170,8 @@ export const RelationSelector = React.forwardRef<
|
|
|
151
170
|
path: getCollectionDataPath(collection),
|
|
152
171
|
collection,
|
|
153
172
|
fixedFilter,
|
|
154
|
-
pageSize
|
|
173
|
+
pageSize,
|
|
174
|
+
enabled: listRequested
|
|
155
175
|
});
|
|
156
176
|
|
|
157
177
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
@@ -418,6 +438,7 @@ relation } as RelationItem;
|
|
|
418
438
|
if (next) {
|
|
419
439
|
// Capture current selection so we can pin those items to the top of the list
|
|
420
440
|
pinnedIdsRef.current = new Set(selectedItems.map(i => String(i.id)));
|
|
441
|
+
setListRequested(true);
|
|
421
442
|
setIsPopoverOpen(true);
|
|
422
443
|
isPopoverOpenRef.current = true;
|
|
423
444
|
}
|
|
@@ -462,7 +483,22 @@ relation } as RelationItem;
|
|
|
462
483
|
pinnedIdsRef.current = null;
|
|
463
484
|
}, []);
|
|
464
485
|
|
|
465
|
-
|
|
486
|
+
// Text, not `EmptyValue`. That component draws a small grey rounded bar
|
|
487
|
+
// with no text in it, which is the same shape the `Skeleton` component
|
|
488
|
+
// uses while data is in flight — so a relation you simply had not filled
|
|
489
|
+
// in was indistinguishable from one that was still loading, and an empty
|
|
490
|
+
// Author or Tags field read as permanently stuck. It also left the trigger
|
|
491
|
+
// button with no accessible name, since the bar contains no text at all.
|
|
492
|
+
//
|
|
493
|
+
// This says "not filled in" rather than "nothing to choose from": which of
|
|
494
|
+
// those it is cannot be known here. The list is deliberately not fetched
|
|
495
|
+
// until the picker is first opened (`enabled: listRequested`, guarded by
|
|
496
|
+
// relation_cell_picker_gating.test.tsx, because a table renders one of
|
|
497
|
+
// these per row), so before that `availableItems` is empty because nobody
|
|
498
|
+
// asked, not because the target collection is. The genuine "nothing to
|
|
499
|
+
// pick yet" state is reported inside the popover below, where the fetch
|
|
500
|
+
// has actually happened.
|
|
501
|
+
const resolvedPlaceholder = placeholder || emptyPlaceholder || emptyText;
|
|
466
502
|
|
|
467
503
|
// Whatever dialog, side dialog or sheet we are inside offers as its
|
|
468
504
|
// portal host, falling back to `document.body`. This is not cosmetic:
|
|
@@ -484,6 +520,10 @@ relation } as RelationItem;
|
|
|
484
520
|
disabled={disabled}
|
|
485
521
|
onClick={() => {
|
|
486
522
|
if (disabled) return;
|
|
523
|
+
// Unconditional: the toggle can only close a
|
|
524
|
+
// popover that was opened, which requested the
|
|
525
|
+
// list already.
|
|
526
|
+
setListRequested(true);
|
|
487
527
|
setIsPopoverOpen(o => {
|
|
488
528
|
const next = !o;
|
|
489
529
|
isPopoverOpenRef.current = next;
|
|
@@ -666,10 +706,17 @@ relation } as RelationItem;
|
|
|
666
706
|
className="ml-2 text-sm text-text-secondary dark:text-text-secondary-dark">{loadingText}</span>
|
|
667
707
|
</div>
|
|
668
708
|
)}
|
|
709
|
+
{/* Two different nothings, which used to share one
|
|
710
|
+
message ("No relations found.") even though the
|
|
711
|
+
fix for each is the opposite: clear the search, or
|
|
712
|
+
go and create the first row. With the fetch done,
|
|
713
|
+
the search string is what tells them apart. */}
|
|
669
714
|
{!isLoading && availableItems.length === 0 && (
|
|
670
715
|
<CommandPrimitive.Empty
|
|
671
716
|
className="px-4 py-6 text-center text-text-secondary dark:text-text-secondary-dark">
|
|
672
|
-
{
|
|
717
|
+
{searchString
|
|
718
|
+
? noResultsText
|
|
719
|
+
: (emptyCollectionText ?? `No ${collection.name ?? "entries"} yet.`)}
|
|
673
720
|
</CommandPrimitive.Empty>
|
|
674
721
|
)}
|
|
675
722
|
<CommandPrimitive.Group>
|
|
@@ -4,9 +4,8 @@ import {
|
|
|
4
4
|
coolIconKeys,
|
|
5
5
|
debounce,
|
|
6
6
|
IconButton,
|
|
7
|
-
iconKeys,
|
|
8
7
|
iconSize,
|
|
9
|
-
|
|
8
|
+
LucideIconByName,
|
|
10
9
|
SearchBar,
|
|
11
10
|
Tooltip
|
|
12
11
|
} from "@rebasepro/ui";
|
|
@@ -15,20 +14,6 @@ import { useTranslation } from "@rebasepro/app";
|
|
|
15
14
|
|
|
16
15
|
const UPDATE_SEARCH_INDEX_WAIT_MS = 220;
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
function toPascalCase(str: string): string {
|
|
20
|
-
return str.split(/[-_]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function resolveIcon(iconKey: string): React.ComponentType<{ size: number }> | null {
|
|
24
|
-
const allIcons = lucideIcons as Record<string, React.ComponentType<{ size: number }>>;
|
|
25
|
-
let icon = allIcons[iconKey];
|
|
26
|
-
if (!icon) {
|
|
27
|
-
icon = allIcons[toPascalCase(iconKey)];
|
|
28
|
-
}
|
|
29
|
-
return icon ?? null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
17
|
export interface SearchIconsProps {
|
|
33
18
|
selectedIcon?: string;
|
|
34
19
|
onIconSelected?: (icon: string) => void;
|
|
@@ -74,23 +59,19 @@ export function SearchIconsView({
|
|
|
74
59
|
/>
|
|
75
60
|
|
|
76
61
|
<div className={"flex max-w-full flex-wrap mt-4"}>
|
|
77
|
-
{icons.map((icon: string) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</IconButton>
|
|
91
|
-
</Tooltip>
|
|
92
|
-
);
|
|
93
|
-
})}
|
|
62
|
+
{icons.map((icon: string) => (
|
|
63
|
+
<Tooltip title={icon} key={icon}
|
|
64
|
+
asChild={true}>
|
|
65
|
+
<IconButton
|
|
66
|
+
shape={"square"}
|
|
67
|
+
toggled={selectedIcon === icon}
|
|
68
|
+
onClick={onIconSelected ? () => onIconSelected(icon) : undefined}
|
|
69
|
+
className="box-content m-1"
|
|
70
|
+
>
|
|
71
|
+
<LucideIconByName name={icon} size={iconSize.medium}/>
|
|
72
|
+
</IconButton>
|
|
73
|
+
</Tooltip>
|
|
74
|
+
))}
|
|
94
75
|
</div>
|
|
95
76
|
</>
|
|
96
77
|
);
|
|
@@ -190,6 +190,7 @@ export function StringNumberFilterField({
|
|
|
190
190
|
|
|
191
191
|
{!enumValues && <TextField
|
|
192
192
|
size={"medium"}
|
|
193
|
+
aria-label={`${title ?? name} filter value`}
|
|
193
194
|
type={type === "number" ? "number" : undefined}
|
|
194
195
|
value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
|
|
195
196
|
disabled={isNullOperation}
|
|
@@ -23,6 +23,7 @@ import { Command as CommandPrimitive } from "cmdk";
|
|
|
23
23
|
import { User } from "@rebasepro/types";
|
|
24
24
|
import { apiBaseOf, useRebaseClient, useAuthController, UserDisplay } from "@rebasepro/app";
|
|
25
25
|
import { EmptyValue } from "../preview";
|
|
26
|
+
import { useResolvedUser } from "../hooks/useResolvedUsers";
|
|
26
27
|
|
|
27
28
|
interface UserSelectorItem {
|
|
28
29
|
uid: string;
|
|
@@ -46,10 +47,19 @@ function useUserSelector({ pageSize = DEFAULT_PAGE_SIZE }: { pageSize?: number }
|
|
|
46
47
|
const [currentSearch, setCurrentSearch] = useState("");
|
|
47
48
|
const offsetRef = useRef(0);
|
|
48
49
|
const userCacheRef = useRef<Map<string, User>>(new Map());
|
|
50
|
+
// Stamped by every request; only the newest one is allowed to write state.
|
|
51
|
+
// Typing "alice" issues one request per keystroke and nothing cancels them,
|
|
52
|
+
// so the response for "al" landing after the response for "alice" replaced
|
|
53
|
+
// the list — and `offsetRef` — with results for a string the user had
|
|
54
|
+
// already moved past. Same shape as `useRelationSelector`.
|
|
55
|
+
const requestIdRef = useRef(0);
|
|
56
|
+
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
57
|
|
|
50
58
|
const fetchUsers = useCallback(async (searchStr: string, offset: number, append: boolean) => {
|
|
51
59
|
const apiBase = apiBaseOf(client);
|
|
52
60
|
if (!apiBase) return;
|
|
61
|
+
const requestId = ++requestIdRef.current;
|
|
62
|
+
const superseded = () => requestId !== requestIdRef.current;
|
|
53
63
|
setIsLoading(true);
|
|
54
64
|
try {
|
|
55
65
|
const token = await getAuthToken?.();
|
|
@@ -68,6 +78,7 @@ function useUserSelector({ pageSize = DEFAULT_PAGE_SIZE }: { pageSize?: number }
|
|
|
68
78
|
});
|
|
69
79
|
if (!response.ok) throw new Error("Failed to fetch users");
|
|
70
80
|
const data = await response.json();
|
|
81
|
+
if (superseded()) return;
|
|
71
82
|
const rows: Record<string, unknown>[] = data.data ?? data.users ?? [];
|
|
72
83
|
const newItems: UserSelectorItem[] = rows.map((r: Record<string, unknown>) => {
|
|
73
84
|
const user: User = {
|
|
@@ -88,9 +99,10 @@ user };
|
|
|
88
99
|
setHasMore(newItems.length >= pageSize);
|
|
89
100
|
offsetRef.current = offset + newItems.length;
|
|
90
101
|
} catch {
|
|
102
|
+
if (superseded()) return;
|
|
91
103
|
setHasMore(false);
|
|
92
104
|
} finally {
|
|
93
|
-
setIsLoading(false);
|
|
105
|
+
if (!superseded()) setIsLoading(false);
|
|
94
106
|
}
|
|
95
107
|
}, [client?.baseUrl, client?.apiPath, getAuthToken, pageSize]);
|
|
96
108
|
|
|
@@ -100,11 +112,23 @@ user };
|
|
|
100
112
|
}, [fetchUsers]);
|
|
101
113
|
|
|
102
114
|
const search = useCallback((searchStr: string) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
// Debounced like the relation picker's type-ahead: a request per
|
|
116
|
+
// keystroke is both wasteful and the thing that makes an out-of-order
|
|
117
|
+
// response likely in the first place.
|
|
118
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
119
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
120
|
+
setCurrentSearch(searchStr);
|
|
121
|
+
offsetRef.current = 0;
|
|
122
|
+
fetchUsers(searchStr, 0, false);
|
|
123
|
+
}, searchStr.trim() ? 300 : 0);
|
|
106
124
|
}, [fetchUsers]);
|
|
107
125
|
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
return () => {
|
|
128
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
129
|
+
};
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
108
132
|
const loadMore = useCallback(() => {
|
|
109
133
|
fetchUsers(currentSearch, offsetRef.current, true);
|
|
110
134
|
}, [fetchUsers, currentSearch]);
|
|
@@ -200,9 +224,19 @@ export const UserSelector = React.forwardRef<
|
|
|
200
224
|
const selectedUser: User | null = value ? getUser(value) ?? null : null;
|
|
201
225
|
|
|
202
226
|
// If the user isn't in the cache, try to find in available items
|
|
203
|
-
const
|
|
227
|
+
const listedUser: User | null = selectedUser
|
|
204
228
|
?? (value ? availableItems.find(i => i.uid === value)?.user ?? null : null);
|
|
205
229
|
|
|
230
|
+
// Neither the loaded page nor the cache holds it: ask for that uid
|
|
231
|
+
// specifically. The list is one page of ten, so any installation with
|
|
232
|
+
// more users than that rendered an assigned field as *empty* — and an
|
|
233
|
+
// editor who is shown "no value" sets one, overwriting an assignment
|
|
234
|
+
// the UI never admitted was there. Lookups are coalesced and cached
|
|
235
|
+
// per API base by `useResolvedUser`, so a table of these costs one
|
|
236
|
+
// request.
|
|
237
|
+
const fetchedUser = useResolvedUser(value && !listedUser ? value : undefined);
|
|
238
|
+
const resolvedUser: User | null = listedUser ?? fetchedUser;
|
|
239
|
+
|
|
206
240
|
// IntersectionObserver for infinite scroll
|
|
207
241
|
const sentinelCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
|
208
242
|
if (observerRef.current) {
|
|
@@ -342,6 +376,14 @@ export const UserSelector = React.forwardRef<
|
|
|
342
376
|
<div className="flex flex-row items-center gap-1 truncate flex-1 min-w-0 mr-2">
|
|
343
377
|
<UserDisplay user={resolvedUser}/>
|
|
344
378
|
</div>
|
|
379
|
+
) : value ? (
|
|
380
|
+
// A uid we could not resolve — deleted, or
|
|
381
|
+
// hidden from this viewer. Show it raw: the
|
|
382
|
+
// placeholder here would claim the field is
|
|
383
|
+
// unset, which is the one thing it is not.
|
|
384
|
+
<span className="text-sm truncate text-text-primary dark:text-text-primary-dark">
|
|
385
|
+
{value}
|
|
386
|
+
</span>
|
|
345
387
|
) : (
|
|
346
388
|
<span className="text-sm text-text-secondary dark:text-text-secondary-dark">
|
|
347
389
|
{resolvedPlaceholder}
|