@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.
- package/CHANGELOG.md +432 -0
- 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 +14 -11
- package/dist/index.js +2428 -2100
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- 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/RecipientPickerField.test.d.ts +0 -0
- 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/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +94 -133
- package/src/widgets/CheckboxesField.tsx +8 -5
- package/src/widgets/FileField.tsx +88 -57
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +71 -33
- 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.tsx +6 -3
- 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.tsx +6 -3
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/TextAreaField.tsx +4 -1
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- 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
|
-
|
|
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
|
);
|
|
@@ -98,12 +98,15 @@ export function MultiSelectField({
|
|
|
98
98
|
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
|
|
99
99
|
>
|
|
100
100
|
{options.map((opt) => {
|
|
101
|
-
|
|
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={
|
|
106
|
-
onClick={() => toggle(
|
|
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 ? '
|
|
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));
|
|
@@ -85,10 +85,13 @@ export function RadioField({
|
|
|
85
85
|
className={className}
|
|
86
86
|
>
|
|
87
87
|
{options.map((opt) => {
|
|
88
|
-
|
|
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={
|
|
91
|
-
<RadioGroupItem 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
|
+
});
|