@object-ui/fields 16.1.0 → 17.2.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 +626 -0
- package/dist/FieldEditWidget.d.ts +9 -2
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -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 +2426 -2100
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +34 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RichTextField.d.ts +2 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TextAreaField.d.ts +2 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- 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 +99 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +119 -3
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +50 -8
- package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
- package/src/__tests__/widget-props-contract.test.tsx +129 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +97 -134
- package/src/widgets/AddressField.tsx +2 -2
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +2 -2
- package/src/widgets/BooleanField.tsx +2 -2
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CheckboxesField.tsx +12 -9
- package/src/widgets/CodeField.tsx +2 -2
- package/src/widgets/ColorField.tsx +2 -2
- package/src/widgets/CurrencyField.tsx +2 -2
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +2 -2
- package/src/widgets/FileField.tsx +90 -59
- package/src/widgets/FilterConditionField.tsx +112 -12
- package/src/widgets/FormulaField.tsx +2 -2
- package/src/widgets/GeolocationField.tsx +2 -2
- package/src/widgets/GridField.tsx +3 -3
- package/src/widgets/ImageField.tsx +73 -35
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +116 -19
- package/src/widgets/MasterDetailField.tsx +2 -2
- package/src/widgets/MultiSelectField.tsx +10 -7
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/ObjectRefField.tsx +13 -8
- package/src/widgets/PasswordField.tsx +2 -2
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/PercentField.tsx +2 -2
- package/src/widgets/PhoneField.tsx +2 -2
- package/src/widgets/QRCodeField.tsx +2 -2
- package/src/widgets/RadioField.tsx +10 -7
- package/src/widgets/RatingField.tsx +2 -2
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +40 -20
- package/src/widgets/RichTextField.tsx +2 -2
- package/src/widgets/SelectField.tsx +5 -5
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +2 -2
- package/src/widgets/SummaryField.tsx +2 -2
- package/src/widgets/TagsField.tsx +3 -3
- package/src/widgets/TextAreaField.tsx +6 -3
- package/src/widgets/TextField.tsx +2 -2
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +2 -2
- package/src/widgets/UserField.tsx +2 -2
- package/src/widgets/VectorField.tsx +2 -2
- 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 +246 -0
- package/src/widgets/types.ts +129 -6
- 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
|
+
});
|
|
@@ -10,7 +10,7 @@ import { cn,
|
|
|
10
10
|
PopoverTrigger,
|
|
11
11
|
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
12
12
|
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
|
-
import {
|
|
13
|
+
import { FieldWidgetComponentProps } from './types';
|
|
14
14
|
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
15
15
|
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
16
16
|
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
@@ -191,7 +191,7 @@ function mapFieldTypeToFilterType(
|
|
|
191
191
|
* from the referenced object using `DataSource.find()`.
|
|
192
192
|
* Falls back to static `options` when no DataSource is available.
|
|
193
193
|
*/
|
|
194
|
-
export function LookupField({ value, onChange, field, readonly, ...props }:
|
|
194
|
+
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
|
|
195
195
|
const [isOpen, setIsOpen] = useState(false);
|
|
196
196
|
const { t } = useFieldTranslation();
|
|
197
197
|
const listboxId = React.useId();
|
|
@@ -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);
|
|
@@ -274,13 +280,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
274
280
|
}, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
|
|
275
281
|
|
|
276
282
|
// Resolve dependent field values from explicit prop or SchemaRendererContext.data
|
|
277
|
-
const dependentValuesProp =
|
|
283
|
+
const dependentValuesProp = props.dependentValues;
|
|
278
284
|
|
|
279
285
|
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
280
286
|
const ctx = useContext(SchemaRendererContext);
|
|
281
287
|
const contextDataSource = ctx?.dataSource ?? null;
|
|
282
288
|
const dataSource: DataSource | null =
|
|
283
|
-
(props as
|
|
289
|
+
(props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
|
|
284
290
|
|
|
285
291
|
/** Resolve dependent values from the explicit prop (preferred), the form-data
|
|
286
292
|
* context provided by @object-ui/react, or finally `ctx.data` (record scope). */
|
|
@@ -363,7 +369,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
363
369
|
|
|
364
370
|
// Optional create-new callback
|
|
365
371
|
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
366
|
-
|
|
372
|
+
props.onCreateNew ?? lookupField?.onCreateNew;
|
|
367
373
|
|
|
368
374
|
// State for the full Record Picker dialog (Level 2)
|
|
369
375
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
@@ -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) : [];
|
|
@@ -584,7 +657,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
584
657
|
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
585
658
|
// unit_price/description when the item is chosen. When provided (single
|
|
586
659
|
// select), it drives the update and the host owns the resulting value change.
|
|
587
|
-
const onSelectRecord =
|
|
660
|
+
const onSelectRecord = props.onSelectRecord;
|
|
588
661
|
|
|
589
662
|
const handleSelect = useCallback(
|
|
590
663
|
(option: LookupOption) => {
|
|
@@ -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
|
|
|
@@ -822,7 +906,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
822
906
|
// Compact mode (e.g. inside a line-item grid cell): show the selected value
|
|
823
907
|
// INSIDE a borderless trigger on a single line — no chip stacked above a
|
|
824
908
|
// separate "Select…" button (which double-stacks and wastes the row height).
|
|
825
|
-
const compact = !!
|
|
909
|
+
const compact = !!props.compact;
|
|
826
910
|
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
827
911
|
|
|
828
912
|
// Shared field trigger — the anchor for either the inline PeoplePicker
|
|
@@ -836,21 +920,34 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
836
920
|
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
837
921
|
)}
|
|
838
922
|
type="button"
|
|
839
|
-
disabled={dependenciesMissing ||
|
|
840
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((
|
|
923
|
+
disabled={dependenciesMissing || props.disabled}
|
|
924
|
+
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
|
|
841
925
|
title={dependenciesMissing
|
|
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
|
);
|
|
@@ -1117,7 +1214,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1117
1214
|
type="button"
|
|
1118
1215
|
// Gated exactly like the main trigger (#2215) — pre-fix this button
|
|
1119
1216
|
// opened the full unscoped table while the dependency was missing.
|
|
1120
|
-
disabled={dependenciesMissing ||
|
|
1217
|
+
disabled={dependenciesMissing || props.disabled}
|
|
1121
1218
|
onClick={() => setIsPickerOpen(true)}
|
|
1122
1219
|
aria-label={t('lookup.browseAll')}
|
|
1123
1220
|
title={dependenciesMissing
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Button, Badge, cn } from '@object-ui/components';
|
|
3
3
|
import { Plus, X, ExternalLink } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Master-Detail relationship data structure
|
|
@@ -23,7 +23,7 @@ export function MasterDetailField({
|
|
|
23
23
|
readonly,
|
|
24
24
|
className,
|
|
25
25
|
...props
|
|
26
|
-
}:
|
|
26
|
+
}: FieldWidgetComponentProps<MasterDetailValue[]>) {
|
|
27
27
|
const items = value || [];
|
|
28
28
|
const config = field || (props as any).schema;
|
|
29
29
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect } from 'react';
|
|
2
2
|
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
6
|
|
|
7
7
|
interface Option extends OptionLike { color?: string }
|
|
@@ -32,11 +32,11 @@ export function MultiSelectField({
|
|
|
32
32
|
emptyHint: _emptyHint,
|
|
33
33
|
dataSource: _dataSource,
|
|
34
34
|
...props
|
|
35
|
-
}:
|
|
35
|
+
}: FieldWidgetComponentProps<string[]>) {
|
|
36
36
|
const config = (field || schema) as any;
|
|
37
37
|
const rawOptions: Option[] = config?.options || [];
|
|
38
38
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
39
|
-
const fieldName =
|
|
39
|
+
const fieldName = props.name || config?.name || props.id || '';
|
|
40
40
|
|
|
41
41
|
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
42
42
|
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
@@ -98,13 +98,16 @@ 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(
|
|
107
|
-
disabled={
|
|
108
|
+
key={value}
|
|
109
|
+
onClick={() => toggle(value)}
|
|
110
|
+
disabled={props.disabled}
|
|
108
111
|
aria-pressed={active}
|
|
109
112
|
data-testid={`multiselect-option-${opt.value}`}
|
|
110
113
|
className={cn(
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* NumberField - Numeric input with optional decimal precision
|
|
8
8
|
* Supports min/max/step constraints and configurable decimal precision
|
|
9
9
|
*/
|
|
10
|
-
export function NumberField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
11
11
|
if (readonly) {
|
|
12
12
|
return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
|
|
13
13
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* ObjectField - JSON object editor
|
|
7
7
|
* Allows editing structured JSON data
|
|
8
8
|
*/
|
|
9
|
-
export function ObjectField({ value, onChange, field, readonly, ...props }:
|
|
9
|
+
export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
10
|
const config = field || (props as any).schema;
|
|
11
11
|
|
|
12
12
|
// Initialize string state based on value
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ObjectRefField — object-name picker for form fields that store the machine
|
|
@@ -31,10 +32,11 @@ export function ObjectRefField({
|
|
|
31
32
|
readonly,
|
|
32
33
|
className,
|
|
33
34
|
...props
|
|
34
|
-
}:
|
|
35
|
+
}: FieldWidgetComponentProps<string>) {
|
|
35
36
|
const ctx = React.useContext(SchemaRendererContext);
|
|
36
|
-
const
|
|
37
|
-
const
|
|
37
|
+
const { t } = useFieldTranslation();
|
|
38
|
+
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
39
|
+
const disabled = props.disabled;
|
|
38
40
|
|
|
39
41
|
const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
|
|
40
42
|
|
|
@@ -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
|
}
|
|
@@ -2,13 +2,13 @@ import React, { useState } from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { Button } from '@object-ui/components';
|
|
4
4
|
import { Eye, EyeOff } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import { FieldWidgetComponentProps } from './types';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* PasswordField - Secure password input with visibility toggle
|
|
9
9
|
* Includes show/hide toggle button and masks value in readonly mode
|
|
10
10
|
*/
|
|
11
|
-
export function PasswordField({ value, onChange, field, readonly, className, ...props }:
|
|
11
|
+
export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
12
|
const [showPassword, setShowPassword] = useState(false);
|
|
13
13
|
const config = field || (props as any).schema;
|
|
14
14
|
|
|
@@ -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));
|