@object-ui/fields 16.0.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 (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. package/src/widgets/useUploadingSignal.ts +20 -0
@@ -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
  );
@@ -0,0 +1,143 @@
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
+ * Cascading / role-gated `multiselect` options (#2715) — parity with the single
11
+ * `SelectField` (ADR-0058 / #2284).
12
+ *
13
+ * Exercises the observable widget behaviour: the dependency gate (a "select the
14
+ * parent first" empty-state), and the cascade clear — but here per-element: a
15
+ * selection dropped from the array when the offered set no longer includes it,
16
+ * while still-valid selections are kept. The per-option filtering itself is
17
+ * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
18
+ * chip picker wires `dependentValues` + predicate scope into that resolver via
19
+ * the shared `useCascadingOptions` hook.
20
+ */
21
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
22
+ import React from 'react';
23
+ import { render, screen } from '@testing-library/react';
24
+ import '@testing-library/jest-dom';
25
+ import { PredicateScopeProvider } from '@object-ui/react';
26
+ import { MultiSelectField } from './MultiSelectField';
27
+
28
+ const provinceField = {
29
+ name: 'province',
30
+ type: 'multiselect',
31
+ dependsOn: 'country',
32
+ options: [
33
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
34
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
35
+ ],
36
+ } as any;
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ describe('MultiSelectField — dependency gating (#2715)', () => {
43
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
44
+ render(
45
+ <MultiSelectField
46
+ value={[]}
47
+ onChange={vi.fn()}
48
+ field={provinceField}
49
+ {...({ name: 'province', dependentValues: {} } as any)}
50
+ />,
51
+ );
52
+ const gate = screen.getByTestId('multiselect-empty-province');
53
+ expect(gate).toHaveTextContent(/select country first/i);
54
+ // Gated → no chips offered.
55
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
56
+ });
57
+
58
+ it('unlocks the chips once the controlling field is set', () => {
59
+ render(
60
+ <MultiSelectField
61
+ value={[]}
62
+ onChange={vi.fn()}
63
+ field={provinceField}
64
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
65
+ />,
66
+ );
67
+ expect(screen.queryByTestId('multiselect-empty-province')).not.toBeInTheDocument();
68
+ // Only the `cn` option is offered.
69
+ expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
70
+ expect(screen.queryByTestId('multiselect-option-ca')).not.toBeInTheDocument();
71
+ });
72
+ });
73
+
74
+ describe('MultiSelectField — cascade clear (#2715)', () => {
75
+ it('drops only the selections the parent change no longer offers', () => {
76
+ const onChange = vi.fn();
77
+ render(
78
+ <MultiSelectField
79
+ value={['zj', 'ca']}
80
+ onChange={onChange}
81
+ field={provinceField}
82
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
83
+ />,
84
+ );
85
+ // 'ca' is a US province — under country=cn it is not offered, so it is
86
+ // pruned; the still-valid 'zj' is kept.
87
+ expect(onChange).toHaveBeenCalledWith(['zj']);
88
+ });
89
+
90
+ it('keeps selections that are all still offered', () => {
91
+ const onChange = vi.fn();
92
+ render(
93
+ <MultiSelectField
94
+ value={['zj']}
95
+ onChange={onChange}
96
+ field={provinceField}
97
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
98
+ />,
99
+ );
100
+ expect(onChange).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
104
+ describe('MultiSelectField — role / context gating (#2715)', () => {
105
+ const tierField = {
106
+ name: 'tiers',
107
+ type: 'multiselect',
108
+ options: [
109
+ { label: 'Standard', value: 'standard' },
110
+ { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
111
+ ],
112
+ } as any;
113
+
114
+ it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
115
+ const onChange = vi.fn();
116
+ render(
117
+ <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
118
+ <MultiSelectField
119
+ value={['standard', 'admin_only']}
120
+ onChange={onChange}
121
+ field={tierField}
122
+ {...({ name: 'tiers', dependentValues: {} } as any)}
123
+ />
124
+ </PredicateScopeProvider>,
125
+ );
126
+ expect(onChange).toHaveBeenCalledWith(['standard']);
127
+ });
128
+
129
+ it('keeps an admin-only value for an admin', () => {
130
+ const onChange = vi.fn();
131
+ render(
132
+ <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
133
+ <MultiSelectField
134
+ value={['standard', 'admin_only']}
135
+ onChange={onChange}
136
+ field={tierField}
137
+ {...({ name: 'tiers', dependentValues: {} } as any)}
138
+ />
139
+ </PredicateScopeProvider>,
140
+ );
141
+ expect(onChange).not.toHaveBeenCalled();
142
+ });
143
+ });
@@ -1,48 +1,115 @@
1
- import React from 'react';
1
+ import React, { useEffect } from 'react';
2
2
  import { Badge, EmptyValue, cn } from '@object-ui/components';
3
+ import type { OptionLike } from '@object-ui/core';
3
4
  import { FieldWidgetProps } from './types';
5
+ import { useCascadingOptions } from './useCascadingOptions';
4
6
 
5
- interface Option { label: string; value: string; color?: string }
7
+ interface Option extends OptionLike { color?: string }
6
8
 
7
9
  /**
8
10
  * MultiSelectField - select zero or more values from a fixed option set.
9
11
  *
10
12
  * Renders the configured options as toggleable chips. The stored value is a
11
- * string[] (the selected option values). Used for the `multiselect` field type.
13
+ * string[] (the selected option values). Used for the `multiselect` field type
14
+ * and for a `select` field declared `multiple: true`.
15
+ *
16
+ * Options support the same per-option `visibleWhen` cascading + `dependsOn`
17
+ * gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
18
+ * shared {@link useCascadingOptions} hook: the offered chips narrow against the
19
+ * live form record + `current_user`, the control is gated behind a "select the
20
+ * parent first" hint while a dependency is empty, and selections no longer
21
+ * offered (parent changed / predicate flipped) are dropped from the array.
12
22
  */
13
- export function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
14
- const config = (field || (props as any).schema) as any;
15
- const options: Option[] = config?.options || [];
23
+ export function MultiSelectField({
24
+ value,
25
+ onChange,
26
+ field,
27
+ readonly,
28
+ className,
29
+ schema,
30
+ dependentValues,
31
+ dependsOn: dependsOnProp,
32
+ emptyHint: _emptyHint,
33
+ dataSource: _dataSource,
34
+ ...props
35
+ }: FieldWidgetProps<string[]>) {
36
+ const config = (field || schema) as any;
37
+ const rawOptions: Option[] = config?.options || [];
16
38
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
+ const fieldName = (props as any).name || config?.name || (props as any).id || '';
40
+
41
+ const dependsOn = config?.dependsOn ?? dependsOnProp;
42
+ const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
43
+ rawOptions,
44
+ dependsOn,
45
+ dependentValues,
46
+ );
47
+
48
+ // Cascade clear: drop selected values the offered set no longer includes
49
+ // (parent changed / predicate flipped), keeping the ones still valid — unlike
50
+ // the scalar case we prune per-element rather than clearing the whole field.
51
+ useEffect(() => {
52
+ if (readonly) return;
53
+ if (selected.length === 0) return;
54
+ const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
55
+ if (stillOffered.length !== selected.length) onChange(stillOffered);
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [options, gated]);
17
58
 
18
59
  if (readonly) {
19
60
  if (selected.length === 0) return <EmptyValue />;
20
61
  return (
21
62
  <div className="flex flex-wrap gap-1">
22
63
  {selected.map((v) => {
23
- const opt = options.find((o) => o.value === v);
64
+ // Label from the raw set so a stored value hidden by `visibleWhen`
65
+ // still renders its label rather than a bare id.
66
+ const opt = rawOptions.find((o) => o.value === v);
24
67
  return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
25
68
  })}
26
69
  </div>
27
70
  );
28
71
  }
29
72
 
73
+ // No offered options is unfillable — surface a legible state instead of an
74
+ // empty chip row: a dependency-gated list prompts for its controlling field;
75
+ // an unconfigured / fully-filtered list says so. Mirrors the single select.
76
+ if (options.length === 0) {
77
+ const hint = gated
78
+ ? `Select ${dependsOnFields.join(' / ')} first`
79
+ : 'No options available';
80
+ return (
81
+ <div
82
+ data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
83
+ className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
84
+ >
85
+ {hint}
86
+ </div>
87
+ );
88
+ }
89
+
30
90
  const toggle = (v: string) => {
31
91
  const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
32
92
  onChange(next);
33
93
  };
34
94
 
35
95
  return (
36
- <div className={cn('flex flex-wrap gap-1.5', className)}>
96
+ <div
97
+ className={cn('flex flex-wrap gap-1.5', className)}
98
+ data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
+ >
37
100
  {options.map((opt) => {
38
- 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);
39
105
  return (
40
106
  <button
41
107
  type="button"
42
- key={opt.value}
43
- onClick={() => toggle(opt.value)}
108
+ key={value}
109
+ onClick={() => toggle(value)}
44
110
  disabled={(props as any).disabled}
45
111
  aria-pressed={active}
112
+ data-testid={`multiselect-option-${opt.value}`}
46
113
  className={cn(
47
114
  'rounded-full border px-3 py-1 text-sm transition-colors',
48
115
  active
@@ -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));
@@ -0,0 +1,141 @@
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
+ * Cascading / role-gated `radio` options (#2715) — parity with the single
11
+ * `SelectField` (ADR-0058 / #2284).
12
+ *
13
+ * Exercises the observable widget behaviour: the dependency gate (a "select the
14
+ * parent first" empty-state) and the scalar cascade clear (a value dropped when
15
+ * the offered set no longer includes it). The per-option filtering itself is
16
+ * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
17
+ * radio group wires `dependentValues` + predicate scope into that resolver via
18
+ * the shared `useCascadingOptions` hook.
19
+ */
20
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
21
+ import React from 'react';
22
+ import { render, screen } from '@testing-library/react';
23
+ import '@testing-library/jest-dom';
24
+ import { PredicateScopeProvider } from '@object-ui/react';
25
+ import { RadioField } from './RadioField';
26
+
27
+ const provinceField = {
28
+ name: 'province',
29
+ type: 'radio',
30
+ dependsOn: 'country',
31
+ options: [
32
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
33
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
34
+ ],
35
+ } as any;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ describe('RadioField — dependency gating (#2715)', () => {
42
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
43
+ render(
44
+ <RadioField
45
+ value={undefined}
46
+ onChange={vi.fn()}
47
+ field={provinceField}
48
+ {...({ name: 'province', dependentValues: {} } as any)}
49
+ />,
50
+ );
51
+ const gate = screen.getByTestId('radio-empty-province');
52
+ expect(gate).toHaveTextContent(/select country first/i);
53
+ // Gated → no radios offered.
54
+ expect(screen.queryAllByRole('radio')).toHaveLength(0);
55
+ });
56
+
57
+ it('unlocks the radios once the controlling field is set', () => {
58
+ render(
59
+ <RadioField
60
+ value={undefined}
61
+ onChange={vi.fn()}
62
+ field={provinceField}
63
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
64
+ />,
65
+ );
66
+ expect(screen.queryByTestId('radio-empty-province')).not.toBeInTheDocument();
67
+ // Only the `cn` option is offered.
68
+ expect(screen.getByRole('radio', { name: 'Zhejiang' })).toBeInTheDocument();
69
+ expect(screen.queryByRole('radio', { name: 'California' })).not.toBeInTheDocument();
70
+ });
71
+ });
72
+
73
+ describe('RadioField — cascade clear (#2715)', () => {
74
+ it('clears a value the parent change no longer offers', () => {
75
+ const onChange = vi.fn();
76
+ render(
77
+ <RadioField
78
+ value={'ca'}
79
+ onChange={onChange}
80
+ field={provinceField}
81
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
82
+ />,
83
+ );
84
+ // 'ca' is a US province — under country=cn it is not offered, so it is dropped.
85
+ expect(onChange).toHaveBeenCalledWith(undefined);
86
+ });
87
+
88
+ it('keeps a value that is still offered', () => {
89
+ const onChange = vi.fn();
90
+ render(
91
+ <RadioField
92
+ value={'zj'}
93
+ onChange={onChange}
94
+ field={provinceField}
95
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
96
+ />,
97
+ );
98
+ expect(onChange).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe('RadioField — role / context gating (#2715)', () => {
103
+ const tierField = {
104
+ name: 'tier',
105
+ type: 'radio',
106
+ options: [
107
+ { label: 'Standard', value: 'standard' },
108
+ { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
109
+ ],
110
+ } as any;
111
+
112
+ it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
113
+ const onChange = vi.fn();
114
+ render(
115
+ <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
116
+ <RadioField
117
+ value={'admin_only'}
118
+ onChange={onChange}
119
+ field={tierField}
120
+ {...({ name: 'tier', dependentValues: {} } as any)}
121
+ />
122
+ </PredicateScopeProvider>,
123
+ );
124
+ expect(onChange).toHaveBeenCalledWith(undefined);
125
+ });
126
+
127
+ it('keeps an admin-only value for an admin', () => {
128
+ const onChange = vi.fn();
129
+ render(
130
+ <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
131
+ <RadioField
132
+ value={'admin_only'}
133
+ onChange={onChange}
134
+ field={tierField}
135
+ {...({ name: 'tier', dependentValues: {} } as any)}
136
+ />
137
+ </PredicateScopeProvider>,
138
+ );
139
+ expect(onChange).not.toHaveBeenCalled();
140
+ });
141
+ });