@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.
- package/CHANGELOG.md +630 -0
- package/README.md +45 -7
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +35 -11
- package/dist/index.js +2241 -1853
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +13 -9
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +11 -10
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +137 -135
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +77 -11
- package/src/widgets/FileField.tsx +91 -52
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +106 -65
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +78 -11
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +69 -9
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +8 -3
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- 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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
|
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({
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
43
|
-
onClick={() => toggle(
|
|
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 ? '
|
|
98
|
-
searchPlaceholder=
|
|
99
|
-
emptyText={objects === null ? '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|