@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.
Files changed (85) hide show
  1. package/dist/{CollectionEditorDialog-BnfOcqM_.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-vLXglzIy.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-DF3aiOuv.js → RouterCollectionsStudioView-CwTpdzWF.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-DF3aiOuv.js.map → RouterCollectionsStudioView-CwTpdzWF.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  9. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  10. package/dist/components/RelationSelector.d.ts +13 -0
  11. package/dist/data_export/export/export.d.ts +7 -0
  12. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  13. package/dist/data_export/export/index.d.ts +1 -0
  14. package/dist/data_import/utils/csv.d.ts +30 -0
  15. package/dist/data_import/utils/index.d.ts +2 -0
  16. package/dist/data_import/utils/save_entities.d.ts +38 -0
  17. package/dist/data_import/utils/transforms.d.ts +9 -0
  18. package/dist/editor.js +3 -0
  19. package/dist/editor.js.map +1 -1
  20. package/dist/{export-C26xQN-g.js → export-L53WektO.js} +175 -39
  21. package/dist/export-L53WektO.js.map +1 -0
  22. package/dist/{history-DSS-Ebwh.js → history-D5SKygpJ.js} +2 -2
  23. package/dist/{history-DSS-Ebwh.js.map → history-D5SKygpJ.js.map} +1 -1
  24. package/dist/{import-Dhx96B6x.js → import-C_4edkoD.js} +5 -4
  25. package/dist/import-C_4edkoD.js.map +1 -0
  26. package/dist/index.js +93 -74
  27. package/dist/index.js.map +1 -1
  28. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  29. package/dist/{util-Bt6VadeZ.js → util-C-D5yD2n.js} +567 -113
  30. package/dist/util-C-D5yD2n.js.map +1 -0
  31. package/package.json +10 -9
  32. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  33. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  34. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  35. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  36. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  37. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  38. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  39. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  40. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  41. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  42. package/src/components/ClearFilterSortButton.tsx +16 -7
  43. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  44. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  45. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +1 -0
  46. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  47. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  48. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  49. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  50. package/src/components/DefaultDrawer.tsx +7 -0
  51. package/src/components/DrawerNavigationItem.tsx +12 -0
  52. package/src/components/RelationSelector.tsx +52 -5
  53. package/src/components/SearchIconsView.tsx +14 -33
  54. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  55. package/src/components/UserSelector.tsx +47 -5
  56. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  57. package/src/data_export/export/export.ts +63 -16
  58. package/src/data_export/export/fetch_export_data.ts +90 -0
  59. package/src/data_export/export/index.ts +1 -0
  60. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  61. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  62. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  63. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  64. package/src/data_import/utils/csv.ts +153 -0
  65. package/src/data_import/utils/data.ts +7 -1
  66. package/src/data_import/utils/file_to_json.ts +77 -6
  67. package/src/data_import/utils/index.ts +2 -0
  68. package/src/data_import/utils/save_entities.ts +138 -0
  69. package/src/data_import/utils/transforms.ts +23 -0
  70. package/src/editor/components/image-bubble.tsx +2 -0
  71. package/src/editor/selectors/link-selector.tsx +1 -0
  72. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  73. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  74. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  75. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  76. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  77. package/src/preview/components/DatePreview.tsx +15 -2
  78. package/src/preview/components/ReferencePreview.tsx +5 -8
  79. package/src/preview/components/RelationPreview.tsx +8 -7
  80. package/src/preview/components/date_fns_locales.ts +125 -0
  81. package/dist/CollectionEditorDialog-BnfOcqM_.js.map +0 -1
  82. package/dist/PropertyEditView-vLXglzIy.js.map +0 -1
  83. package/dist/export-C26xQN-g.js.map +0 -1
  84. package/dist/import-Dhx96B6x.js.map +0 -1
  85. 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={iconSize.smallest}/>
120
+ ? <ArrowDownIcon size={arrowSize}/>
109
121
  : activeDirection === "asc"
110
- ? <ArrowUpIcon size={iconSize.smallest}/>
111
- : <ArrowUpDownIcon size={iconSize.smallest}/>}
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.smallest}/>;
85
- if (viewMode === "cards") return <LayoutGridIcon size={iconSize.smallest}/>;
86
- if (viewMode === "table") return <TableIcon size={iconSize.smallest}/>;
87
- return <ListIcon size={iconSize.smallest}/>;
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: itemCount,
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: itemCount,
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 relations found.",
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
- const resolvedPlaceholder = placeholder || emptyPlaceholder || <EmptyValue className={"ml-2"}/>;
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
- {noResultsText}
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
- lucideIcons,
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
- const LucideIcon = resolveIcon(icon);
79
- if (!LucideIcon) return null;
80
- return (
81
- <Tooltip title={icon} key={icon}
82
- asChild={true}>
83
- <IconButton
84
- shape={"square"}
85
- toggled={selectedIcon === icon}
86
- onClick={onIconSelected ? () => onIconSelected(icon) : undefined}
87
- className="box-content m-1"
88
- >
89
- <LucideIcon size={iconSize.medium}/>
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
- setCurrentSearch(searchStr);
104
- offsetRef.current = 0;
105
- fetchUsers(searchStr, 0, false);
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 resolvedUser: User | null = selectedUser
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}