@object-ui/fields 16.1.0 → 17.1.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 (44) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/dist/FieldEditWidget.d.ts +7 -0
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +5 -5
  5. package/dist/index.d.ts +14 -11
  6. package/dist/index.js +2428 -2100
  7. package/dist/widgets/FilterConditionField.d.ts +32 -0
  8. package/dist/widgets/ImageLightbox.d.ts +30 -0
  9. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  10. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  11. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  12. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  13. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  14. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  15. package/dist/widgets/file-value.d.ts +112 -0
  16. package/dist/widgets/file-value.test.d.ts +1 -0
  17. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  18. package/package.json +9 -8
  19. package/src/FieldEditWidget.test.ts +74 -2
  20. package/src/FieldEditWidget.tsx +44 -4
  21. package/src/field-type-alias.ts +100 -0
  22. package/src/index.tsx +94 -133
  23. package/src/widgets/CheckboxesField.tsx +8 -5
  24. package/src/widgets/FileField.tsx +88 -57
  25. package/src/widgets/FilterConditionField.tsx +109 -9
  26. package/src/widgets/ImageField.tsx +71 -33
  27. package/src/widgets/ImageLightbox.test.tsx +52 -0
  28. package/src/widgets/ImageLightbox.tsx +108 -0
  29. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  30. package/src/widgets/LookupField.idField.test.tsx +61 -0
  31. package/src/widgets/LookupField.tsx +106 -9
  32. package/src/widgets/MultiSelectField.tsx +6 -3
  33. package/src/widgets/ObjectRefField.tsx +9 -4
  34. package/src/widgets/PeoplePicker.test.tsx +20 -0
  35. package/src/widgets/PeoplePicker.tsx +15 -3
  36. package/src/widgets/RadioField.tsx +6 -3
  37. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  38. package/src/widgets/RecipientPickerField.tsx +36 -16
  39. package/src/widgets/TextAreaField.tsx +4 -1
  40. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  41. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  42. package/src/widgets/file-value.test.ts +230 -0
  43. package/src/widgets/file-value.ts +230 -0
  44. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -0,0 +1,136 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Regression (#3108): a multi-value lookup hydrated its committed ids with a
5
+ * SERIAL `findOne` per id — 41 selected users meant 41 sequential round-trips,
6
+ * during which the trigger rendered the bare "Select…" placeholder,
7
+ * indistinguishable from having no value at all.
8
+ *
9
+ * Fixed behaviour under test:
10
+ * 1. unresolved ids are fetched in ONE batched `$in` query (chunked), not
11
+ * one findOne per id;
12
+ * 2. while hydration is in flight the trigger shows a loading indicator and
13
+ * the committed-value count instead of the empty placeholder;
14
+ * 3. the loading state settles (falls back to placeholder) when hydration
15
+ * fails, instead of spinning forever.
16
+ */
17
+
18
+ import * as React from 'react';
19
+ import { describe, it, expect, vi, afterEach } from 'vitest';
20
+ import { render, cleanup, waitFor, screen } from '@testing-library/react';
21
+ import { LookupField } from './LookupField';
22
+
23
+ afterEach(cleanup);
24
+
25
+ const USERS = [
26
+ { id: 'u1', name: 'Ada Lovelace' },
27
+ { id: 'u2', name: 'Grace Hopper' },
28
+ { id: 'u3', name: 'Margaret Hamilton' },
29
+ ];
30
+
31
+ describe('LookupField — multi-value hydration batches and shows loading (#3108)', () => {
32
+ it('hydrates several ids with one $in query instead of a findOne per id', async () => {
33
+ const find = vi.fn(async (_obj: string, params: any) => {
34
+ const wanted: any[] = params?.$filter?.id?.$in ?? [];
35
+ return { data: USERS.filter((u) => wanted.includes(u.id)) };
36
+ });
37
+ const findOne = vi.fn(async () => null);
38
+ render(
39
+ <LookupField
40
+ value={['u1', 'u2', 'u3']}
41
+ onChange={() => {}}
42
+ dataSource={{ find, findOne } as never}
43
+ field={{ reference_to: 'sys_user', multiple: true } as never}
44
+ />,
45
+ );
46
+
47
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
48
+ expect(screen.getByText('Grace Hopper')).toBeTruthy();
49
+ expect(screen.getByText('Margaret Hamilton')).toBeTruthy();
50
+
51
+ // One batched round-trip, zero per-id GETs.
52
+ expect(findOne).not.toHaveBeenCalled();
53
+ const hydrationCalls = find.mock.calls.filter((c) => c[1]?.$filter?.id?.$in);
54
+ expect(hydrationCalls).toHaveLength(1);
55
+ expect(hydrationCalls[0][1].$filter.id.$in).toEqual(['u1', 'u2', 'u3']);
56
+ });
57
+
58
+ it('shows the committed count + spinner while hydration is in flight, never the empty placeholder', async () => {
59
+ let resolveFind!: (v: any) => void;
60
+ const find = vi.fn(
61
+ () => new Promise((resolve) => { resolveFind = resolve; }),
62
+ );
63
+ render(
64
+ <LookupField
65
+ value={['u1', 'u2', 'u3']}
66
+ onChange={() => {}}
67
+ dataSource={{ find } as never}
68
+ field={{ reference_to: 'sys_user', multiple: true } as never}
69
+ />,
70
+ );
71
+
72
+ // In-flight: loading indicator + raw committed count, no "Select…".
73
+ await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
74
+ expect(screen.queryByText('Select...')).toBeNull();
75
+ expect(screen.getByText(/3/)).toBeTruthy();
76
+
77
+ resolveFind({ data: USERS });
78
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
79
+ expect(screen.queryByTestId('lookup-hydrating')).toBeNull();
80
+ });
81
+
82
+ it('settles (no infinite spinner) when the hydration fetch fails', async () => {
83
+ const find = vi.fn(async (_obj: string, params: any) => {
84
+ if (params?.$filter?.id?.$in) throw new Error('boom');
85
+ return { data: [] };
86
+ });
87
+ render(
88
+ <LookupField
89
+ value={['u1', 'u2']}
90
+ onChange={() => {}}
91
+ dataSource={{ find } as never}
92
+ field={{ reference_to: 'sys_user', multiple: true } as never}
93
+ />,
94
+ );
95
+
96
+ await waitFor(() => expect(find).toHaveBeenCalled());
97
+ // The failed attempt is marked settled — the spinner clears instead of
98
+ // spinning forever (pre-existing fallback: unresolvable ids drop).
99
+ await waitFor(() => expect(screen.queryByTestId('lookup-hydrating')).toBeNull());
100
+ });
101
+
102
+ it('readonly mode shows the loading count instead of an empty value while hydrating', async () => {
103
+ let resolveFind!: (v: any) => void;
104
+ const find = vi.fn(
105
+ () => new Promise((resolve) => { resolveFind = resolve; }),
106
+ );
107
+ render(
108
+ <LookupField
109
+ value={['u1', 'u2']}
110
+ onChange={() => {}}
111
+ readonly
112
+ dataSource={{ find } as never}
113
+ field={{ reference_to: 'sys_user', multiple: true } as never}
114
+ />,
115
+ );
116
+
117
+ await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
118
+
119
+ resolveFind({ data: USERS.slice(0, 2) });
120
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
121
+ });
122
+
123
+ it('keeps the cheap findOne GET for a single unresolved primary id', async () => {
124
+ const find = vi.fn(async () => ({ data: [] }));
125
+ const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
126
+ render(
127
+ <LookupField
128
+ value={['u1']}
129
+ onChange={() => {}}
130
+ dataSource={{ find, findOne } as never}
131
+ field={{ reference_to: 'sys_user', multiple: true } as never}
132
+ />,
133
+ );
134
+ await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
135
+ });
136
+ });
@@ -0,0 +1,61 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Regression (objectstack #3508): a lookup whose committed value is NOT the
5
+ * primary id (`id_field: 'name'` — e.g. approval `position` approvers, which
6
+ * the engine routes by machine name) must hydrate its display label by
7
+ * FILTERING on that field. The old path always called `findOne(object, value)`
8
+ * — a primary-id GET — so a stored machine name never resolved and the field
9
+ * showed an empty placeholder over a real value.
10
+ */
11
+
12
+ import * as React from 'react';
13
+ import { describe, it, expect, vi, afterEach } from 'vitest';
14
+ import { render, cleanup, waitFor } from '@testing-library/react';
15
+ import { LookupField } from './LookupField';
16
+
17
+ afterEach(cleanup);
18
+
19
+ describe('LookupField — id_field hydration (#3508)', () => {
20
+ it('hydrates by filtering on id_field when it is not the primary id', async () => {
21
+ const find = vi.fn(async () => ({
22
+ data: [{ id: 'pos_1', name: 'sales_manager', label: 'Sales Manager' }],
23
+ }));
24
+ const findOne = vi.fn(async () => null);
25
+ render(
26
+ <LookupField
27
+ value="sales_manager"
28
+ onChange={() => {}}
29
+ dataSource={{ find, findOne } as never}
30
+ field={{
31
+ reference_to: 'sys_position',
32
+ id_field: 'name',
33
+ display_field: 'label',
34
+ multiple: false,
35
+ } as never}
36
+ />,
37
+ );
38
+ await waitFor(() => expect(find).toHaveBeenCalled());
39
+ // The stored machine name is not a primary id — findOne(object, name)
40
+ // would 404; hydration must go through $filter on the committed field.
41
+ expect(findOne).not.toHaveBeenCalled();
42
+ const hydration = find.mock.calls.find(
43
+ (c) => (c as unknown[])[1] && ((c as never[])[1] as { $filter?: Record<string, unknown> }).$filter?.name === 'sales_manager',
44
+ );
45
+ expect(hydration).toBeTruthy();
46
+ });
47
+
48
+ it('keeps findOne hydration for the default primary-id lookup', async () => {
49
+ const find = vi.fn(async () => ({ data: [] }));
50
+ const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
51
+ render(
52
+ <LookupField
53
+ value="u1"
54
+ onChange={() => {}}
55
+ dataSource={{ find, findOne } as never}
56
+ field={{ reference_to: 'sys_user', multiple: false } as never}
57
+ />,
58
+ );
59
+ await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
60
+ });
61
+ });
@@ -205,6 +205,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
205
205
  // even when the record wasn't part of the Level 1 popover fetch.
206
206
  const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
207
207
 
208
+ // Ids whose hydration attempt has FINISHED — found or not — keyed by
209
+ // String(id). Distinguishes "still loading" from "unresolvable" (deleted
210
+ // record, fetch failure) so the trigger's hydrating state can't stick
211
+ // forever on an id that will never resolve (#3108).
212
+ const [hydrationSettled, setHydrationSettled] = useState<Set<string>>(() => new Set());
213
+
208
214
  // Arrow-key active index (-1 = none)
209
215
  const [activeIndex, setActiveIndex] = useState(-1);
210
216
  const listRef = useRef<HTMLDivElement>(null);
@@ -476,8 +482,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
476
482
  (async () => {
477
483
  try {
478
484
  const fetched: LookupOption[] = [];
479
- for (const id of unresolved) {
480
- if (typeof (dataSource as any).findOne === 'function') {
485
+ // Single id: the pre-existing cheap paths — a primary-id `findOne`
486
+ // GET, or an equality filter when the field commits a different
487
+ // column (`id_field: 'name'` — e.g. position machine names,
488
+ // objectstack #3508).
489
+ if (unresolved.length === 1) {
490
+ const id = unresolved[0];
491
+ if (typeof (dataSource as any).findOne === 'function' && idField === 'id') {
481
492
  const rec = await (dataSource as any).findOne(referenceTo, id);
482
493
  if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
483
494
  } else {
@@ -485,9 +496,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
485
496
  $filter: { [idField]: id },
486
497
  $top: 1,
487
498
  } as QueryParams);
488
- const rows = res?.data ?? res ?? [];
499
+ const rows = (res as any)?.data ?? res ?? [];
489
500
  if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
490
501
  }
502
+ } else {
503
+ // SEVERAL unresolved ids: one `$in` query per chunk. A multi-value
504
+ // field can hold dozens of ids, and the old serial findOne-per-id
505
+ // took seconds while the trigger sat on the empty "Select…"
506
+ // placeholder (#3108).
507
+ // Chunked to LOOKUP_PAGE_SIZE so no request exceeds the page size
508
+ // a server may cap `$top` at; chunks run in parallel.
509
+ const chunks: any[][] = [];
510
+ for (let i = 0; i < unresolved.length; i += LOOKUP_PAGE_SIZE) {
511
+ chunks.push(unresolved.slice(i, i + LOOKUP_PAGE_SIZE));
512
+ }
513
+ const results = await Promise.all(
514
+ chunks.map((chunk) =>
515
+ dataSource.find(referenceTo, {
516
+ $filter: { [idField]: { $in: chunk } },
517
+ $top: chunk.length,
518
+ } as QueryParams),
519
+ ),
520
+ );
521
+ for (const res of results) {
522
+ const rows = (res as any)?.data ?? res ?? [];
523
+ if (!Array.isArray(rows)) continue;
524
+ for (const row of rows) {
525
+ fetched.push(recordToOption(row, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
526
+ }
527
+ }
491
528
  }
492
529
  if (!cancelled && fetched.length) {
493
530
  setPickerResolvedRecords((prev) => {
@@ -498,6 +535,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
498
535
  }
499
536
  } catch {
500
537
  // Ignore — chip will fall back to showing the raw id.
538
+ } finally {
539
+ // Mark the attempt settled — found or not — so `hydrating` below
540
+ // clears even for ids that no longer resolve (deleted records,
541
+ // fetch failures) instead of spinning forever.
542
+ if (!cancelled) {
543
+ setHydrationSettled((prev) => {
544
+ const next = new Set(prev);
545
+ for (const id of unresolved) next.add(String(id));
546
+ return next;
547
+ });
548
+ }
501
549
  }
502
550
  })();
503
551
  return () => {
@@ -576,6 +624,31 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
576
624
  [findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
577
625
  );
578
626
 
627
+ // A value can hold bare ids that no option list resolves YET — the batch
628
+ // hydration above is still in flight. While any such id is pending, the
629
+ // trigger must not present the field as empty: a 41-value lookup rendered
630
+ // the bare "Select…" placeholder for seconds, indistinguishable from
631
+ // having no value at all (#3108).
632
+ const hydrating = useMemo(() => {
633
+ if (!hasDataSource) return false;
634
+ const raw: any[] = multiple
635
+ ? Array.isArray(value) ? value : []
636
+ : value != null && value !== '' ? [value] : [];
637
+ return raw.some(
638
+ (v) =>
639
+ v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v) &&
640
+ !findOption(v) && !findOptionLoose(v) &&
641
+ !hydrationSettled.has(String(v)),
642
+ );
643
+ }, [hasDataSource, multiple, value, findOption, findOptionLoose, hydrationSettled]);
644
+
645
+ // Committed-value count. During hydration the resolved-option count
646
+ // undercounts (unresolved ids are filtered out below), so the trigger
647
+ // shows this raw count instead.
648
+ const rawSelectedCount = multiple
649
+ ? (Array.isArray(value) ? value : []).filter((v) => v != null && v !== '').length
650
+ : value != null && value !== '' ? 1 : 0;
651
+
579
652
  const selectedOptions = multiple
580
653
  ? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
581
654
  : value ? [resolveSelectedOption(value)].filter(Boolean) : [];
@@ -797,6 +870,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
797
870
 
798
871
  if (readonly) {
799
872
  if (!selectedOptions.length) {
873
+ if (hydrating) {
874
+ return (
875
+ <span
876
+ className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
877
+ data-testid="lookup-hydrating"
878
+ >
879
+ <Loader2 className="size-3.5 animate-spin" />
880
+ {multiple ? t('table.selected', { count: rawSelectedCount }) : t('lookup.loading')}
881
+ </span>
882
+ );
883
+ }
800
884
  return <EmptyValue />;
801
885
  }
802
886
 
@@ -842,15 +926,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
842
926
  ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
843
927
  : undefined}
844
928
  >
845
- <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
929
+ {hydrating ? (
930
+ <Loader2
931
+ className={cn('size-4 shrink-0 animate-spin text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')}
932
+ data-testid="lookup-hydrating"
933
+ />
934
+ ) : (
935
+ <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
936
+ )}
846
937
  <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
847
938
  {dependenciesMissing
848
939
  ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
849
- : compact && !multiple && selectedOptions.length > 0
850
- ? singleSelectedLabel
851
- : selectedOptions.length === 0
852
- ? lookupField?.placeholder || t('common.select')
853
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
940
+ : hydrating
941
+ // The value EXISTS but its labels are still loading — say so
942
+ // instead of the empty placeholder (#3108).
943
+ ? multiple
944
+ ? t('table.selected', { count: rawSelectedCount })
945
+ : t('lookup.loading')
946
+ : compact && !multiple && selectedOptions.length > 0
947
+ ? singleSelectedLabel
948
+ : selectedOptions.length === 0
949
+ ? lookupField?.placeholder || t('common.select')
950
+ : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
854
951
  </span>
855
952
  </Button>
856
953
  );
@@ -98,12 +98,15 @@ export function MultiSelectField({
98
98
  data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
99
  >
100
100
  {options.map((opt) => {
101
- const active = selected.includes(opt.value);
101
+ // Multi-value fields store string arrays — stringify the (possibly
102
+ // numeric, #3090-widened) authored value at this boundary.
103
+ const value = String(opt.value);
104
+ const active = selected.includes(value);
102
105
  return (
103
106
  <button
104
107
  type="button"
105
- key={opt.value}
106
- onClick={() => toggle(opt.value)}
108
+ key={value}
109
+ onClick={() => toggle(value)}
107
110
  disabled={(props as any).disabled}
108
111
  aria-pressed={active}
109
112
  data-testid={`multiselect-option-${opt.value}`}
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { FieldWidgetProps } from './types';
5
+ import { useFieldTranslation } from './useFieldTranslation';
5
6
 
6
7
  /**
7
8
  * ObjectRefField — object-name picker for form fields that store the machine
@@ -33,6 +34,7 @@ export function ObjectRefField({
33
34
  ...props
34
35
  }: FieldWidgetProps<string>) {
35
36
  const ctx = React.useContext(SchemaRendererContext);
37
+ const { t } = useFieldTranslation();
36
38
  const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
37
39
  const disabled = (props as any).disabled as boolean | undefined;
38
40
 
@@ -94,11 +96,14 @@ export function ObjectRefField({
94
96
  options={options}
95
97
  value={value ?? ''}
96
98
  onValueChange={(v) => onChange(v as any)}
97
- placeholder={objects === null ? 'Loading objects…' : 'Select an object'}
98
- searchPlaceholder="Search objects…"
99
- emptyText={objects === null ? 'Loading…' : 'No objects found'}
99
+ placeholder={objects === null ? t('fields.objectRef.loading') : t('fields.objectRef.placeholder')}
100
+ searchPlaceholder={t('fields.objectRef.search')}
101
+ emptyText={objects === null ? t('common.loading') : t('fields.objectRef.empty')}
100
102
  disabled={disabled}
101
- className={cn(className)}
103
+ // Fill the form column like every other input. Combobox's own
104
+ // `w-[200px]` is a component default that left this control stranded at
105
+ // a third of the row while `名称` beside it ran full width.
106
+ className={cn('w-full', className)}
102
107
  />
103
108
  );
104
109
  }
@@ -189,6 +189,26 @@ describe('PeoplePicker', () => {
189
189
  expect(onSelect).toHaveBeenCalledWith('u1');
190
190
  });
191
191
 
192
+ it('keyboard: cursor resets when the results are replaced by a search', async () => {
193
+ // Pins the semantics the render-phase reset must preserve: a NEW result
194
+ // set does not inherit the previous set's cursor. (The reset moved out of
195
+ // an effect into the render phase so it can never land AFTER a subsequent
196
+ // ArrowDown — the CI flake on the test above.)
197
+ const ds = makeDataSource();
198
+ render(
199
+ <PeoplePicker {...baseProps} dataSource={ds} onSelect={vi.fn()} onSelectRecords={vi.fn()} onOpenChange={vi.fn()} />,
200
+ );
201
+ await waitFor(() => expect(screen.getByText('Amy Lin')).toBeTruthy());
202
+ const search = screen.getByTestId('people-picker-search');
203
+ fireEvent.keyDown(search, { key: 'ArrowDown' });
204
+ await waitFor(() =>
205
+ expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).toBe('true'),
206
+ );
207
+ fireEvent.change(search, { target: { value: 'bob' } });
208
+ await waitFor(() => expect(screen.getAllByTestId('person-row')).toHaveLength(1));
209
+ expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).not.toBe('true');
210
+ });
211
+
192
212
  it('keyboard: Backspace on empty search removes the last chip (multi)', async () => {
193
213
  const ds = makeDataSource();
194
214
  render(
@@ -320,10 +320,22 @@ export function PeoplePicker({
320
320
  () => query.records.map(r => String(getPersonId(r, idField))).join(''),
321
321
  [query.records, idField],
322
322
  );
323
- useEffect(() => {
323
+ // The reset runs in the RENDER PHASE (the "adjusting state during render"
324
+ // pattern), NOT in an effect. An effect flushes asynchronously after the
325
+ // render that delivered the records — so a reset queued by their arrival
326
+ // could land AFTER a subsequent ArrowDown and eat the just-set cursor.
327
+ // That is the residual ArrowDown→Enter flake the signature key above did
328
+ // not close (CI reproduced it under load), and a real fast-fingers UX bug:
329
+ // rows appear, the user presses ArrowDown, the highlight vanishes.
330
+ // Resetting during the same render that shows the new rows makes the
331
+ // ordering deterministic — by the time the rows are visible, the reset
332
+ // has already happened.
333
+ const cursorEpoch = `${query.search}\u0000${recordsSignature}`;
334
+ const [seenCursorEpoch, setSeenCursorEpoch] = useState(cursorEpoch);
335
+ if (seenCursorEpoch !== cursorEpoch) {
336
+ setSeenCursorEpoch(cursorEpoch);
324
337
  setActiveIndex(-1);
325
- // eslint-disable-next-line react-hooks/exhaustive-deps
326
- }, [query.search, recordsSignature]);
338
+ }
327
339
  // Keep it in range if the list shrinks.
328
340
  useEffect(() => {
329
341
  setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
@@ -85,10 +85,13 @@ export function RadioField({
85
85
  className={className}
86
86
  >
87
87
  {options.map((opt) => {
88
- const id = `${groupId}-${opt.value}`;
88
+ // Radix speaks strings — stringify the (possibly numeric,
89
+ // #3090-widened) authored value at this boundary.
90
+ const value = String(opt.value);
91
+ const id = `${groupId}-${value}`;
89
92
  return (
90
- <div key={opt.value} className="flex items-center space-x-2">
91
- <RadioGroupItem value={opt.value} id={id} data-testid={`radio-option-${opt.value}`} />
93
+ <div key={value} className="flex items-center space-x-2">
94
+ <RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
92
95
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
93
96
  </div>
94
97
  );
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * RecipientPickerField — the sharing-rule recipient picker (objectstack#3821).
11
+ *
12
+ * Two regressions are pinned here:
13
+ * 1. the candidate query must use the STRUCTURED `$orderby` form. It used to
14
+ * pass the clause string `'name asc'`, which ApiDataSource walked
15
+ * character by character into `0 n,1 a,2 m,…`; the server then sorted by
16
+ * columns that don't exist and every recipient list came back empty, so
17
+ * the control read "No matches" forever and no rule could be saved.
18
+ * 2. the placeholder must come from a per-type i18n key, not from
19
+ * interpolating the enum value into an English sentence.
20
+ */
21
+ import { describe, it, expect, vi } from 'vitest';
22
+ import React from 'react';
23
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
24
+ import '@testing-library/jest-dom';
25
+ import { RecipientPickerField } from './RecipientPickerField';
26
+
27
+ const USERS = [
28
+ { id: 'usr_1', name: 'Ada Auditor' },
29
+ { id: 'usr_2', name: 'Bob Builder' },
30
+ ];
31
+
32
+ function mockDataSource(rows: any[] = USERS) {
33
+ return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
34
+ }
35
+
36
+ function renderPicker(recipientType: string, dataSource: any, value = '') {
37
+ return render(
38
+ <RecipientPickerField
39
+ value={value}
40
+ onChange={vi.fn()}
41
+ field={{ name: 'recipient_id' } as any}
42
+ dataSource={dataSource}
43
+ dependentValues={{ recipient_type: recipientType }}
44
+ />,
45
+ );
46
+ }
47
+
48
+ describe('RecipientPickerField — candidate query', () => {
49
+ it('orders candidates with the structured $orderby form, never a clause string', async () => {
50
+ const ds = mockDataSource();
51
+ renderPicker('user', ds);
52
+
53
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
54
+ const [object, params] = ds.find.mock.calls[0];
55
+ expect(object).toBe('sys_user');
56
+ expect(params.$orderby).toEqual({ name: 'asc' });
57
+ // A bare string is what regressed; assert the shape explicitly.
58
+ expect(typeof params.$orderby).not.toBe('string');
59
+ });
60
+
61
+ it('queries the object mapped to each recipient type', async () => {
62
+ for (const [type, object] of [
63
+ ['user', 'sys_user'],
64
+ ['team', 'sys_team'],
65
+ ['business_unit', 'sys_business_unit'],
66
+ ['unit_and_subordinates', 'sys_business_unit'],
67
+ ['position', 'sys_position'],
68
+ ] as const) {
69
+ const ds = mockDataSource([]);
70
+ const { unmount } = renderPicker(type, ds);
71
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
72
+ expect(ds.find.mock.calls[0][0]).toBe(object);
73
+ unmount();
74
+ }
75
+ });
76
+
77
+ it('shows the loaded records as options once the list is opened', async () => {
78
+ const ds = mockDataSource();
79
+ renderPicker('user', ds);
80
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
81
+
82
+ fireEvent.click(screen.getByRole('combobox'));
83
+ expect(await screen.findByText('Ada Auditor')).toBeInTheDocument();
84
+ expect(screen.getByText('Bob Builder')).toBeInTheDocument();
85
+ });
86
+ });
87
+
88
+ describe('RecipientPickerField — keeping the stored recipient', () => {
89
+ function renderControlled(initialType: string, value: string, onChange: any) {
90
+ const ds = mockDataSource();
91
+ const view = render(
92
+ <RecipientPickerField
93
+ value={value}
94
+ onChange={onChange}
95
+ field={{ name: 'recipient_id' } as any}
96
+ dataSource={ds}
97
+ dependentValues={{ recipient_type: initialType }}
98
+ />,
99
+ );
100
+ const rerenderWith = (type: string) =>
101
+ view.rerender(
102
+ <RecipientPickerField
103
+ value={value}
104
+ onChange={onChange}
105
+ field={{ name: 'recipient_id' } as any}
106
+ dataSource={ds}
107
+ dependentValues={{ recipient_type: type }}
108
+ />,
109
+ );
110
+ return { ...view, rerenderWith };
111
+ }
112
+
113
+ it('keeps the saved recipient when the type arrives late (edit form hydrating)', async () => {
114
+ // The edit dialog mounts the widget before the record lands, so
115
+ // recipient_type is '' on the first render and 'user' on the next. That is
116
+ // hydration, not the admin changing the type — clearing there wiped the
117
+ // saved recipient and the blank got persisted on save.
118
+ const onChange = vi.fn();
119
+ const { rerenderWith } = renderControlled('', 'usr_1', onChange);
120
+ rerenderWith('user');
121
+ await waitFor(() => expect(onChange).not.toHaveBeenCalled());
122
+ });
123
+
124
+ it('clears the recipient when the admin actually switches type', async () => {
125
+ const onChange = vi.fn();
126
+ const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
127
+ rerenderWith('team');
128
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(''));
129
+ });
130
+
131
+ it('does not clear when the type is unset back to empty', async () => {
132
+ const onChange = vi.fn();
133
+ const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
134
+ rerenderWith('');
135
+ await waitFor(() => expect(onChange).not.toHaveBeenCalled());
136
+ });
137
+ });
138
+
139
+ describe('RecipientPickerField — localizable copy', () => {
140
+ it('takes the placeholder from a per-type key rather than the raw enum value', async () => {
141
+ // With no I18nProvider the widget falls back to the English defaults, so
142
+ // the assertion is on the *wording*: 'Select a business unit' can only come
143
+ // from the keyed string — interpolating the enum produced
144
+ // 'Select a business unit' only by accident and 'Select a unit and
145
+ // subordinates' for the next type over.
146
+ const ds = mockDataSource([]);
147
+ renderPicker('unit_and_subordinates', ds);
148
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
149
+ expect(await screen.findByText('Select a business unit')).toBeInTheDocument();
150
+ expect(screen.queryByText(/unit and subordinates/i)).not.toBeInTheDocument();
151
+ });
152
+
153
+ it('prompts for a recipient type before one is chosen', () => {
154
+ renderPicker('', mockDataSource([]));
155
+ expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument();
156
+ });
157
+ });