@object-ui/fields 13.2.0 → 14.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 +208 -0
- package/README.md +11 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +23 -8
- package/dist/index.js +2027 -1364
- package/dist/widgets/FileField.d.ts +22 -0
- package/dist/widgets/FilterConditionField.d.ts +4 -0
- package/dist/widgets/GridField.d.ts +6 -2
- package/dist/widgets/ObjectRefField.d.ts +4 -0
- package/dist/widgets/RecipientPickerField.d.ts +4 -0
- package/dist/widgets/SelectField.d.ts +1 -1
- package/package.json +9 -9
- package/src/FieldEditWidget.tsx +3 -0
- package/src/__tests__/DateCellRenderer.test.tsx +27 -4
- package/src/__tests__/lookupCellDisplayField.test.tsx +107 -0
- package/src/__tests__/lookupCellDisplayName.test.tsx +119 -0
- package/src/index.tsx +237 -39
- package/src/standard-widgets.test.tsx +35 -0
- package/src/widgets/CurrencyField.tsx +5 -0
- package/src/widgets/FileField.tsx +170 -28
- package/src/widgets/FilterConditionField.tsx +343 -0
- package/src/widgets/GridField.test.tsx +84 -1
- package/src/widgets/GridField.tsx +32 -2
- package/src/widgets/LookupField.tsx +12 -2
- package/src/widgets/NumberField.tsx +14 -2
- package/src/widgets/ObjectRefField.tsx +106 -0
- package/src/widgets/PeoplePicker.test.tsx +6 -0
- package/src/widgets/PeoplePicker.tsx +11 -2
- package/src/widgets/RecipientPickerField.tsx +150 -0
- package/src/widgets/SelectField.cascade.test.tsx +3 -3
- package/src/widgets/SelectField.tsx +1 -1
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
|
|
19
19
|
import { resolveFieldRuleState } from '@object-ui/core';
|
|
20
20
|
import { LookupField } from './LookupField';
|
|
21
|
+
import { FileCell } from './FileField';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* GridField / LineItemsField — editable child-grid ("line items") widget.
|
|
@@ -30,7 +31,7 @@ import { LookupField } from './LookupField';
|
|
|
30
31
|
*
|
|
31
32
|
* Column config (a subset of `GridColumnDefinition`):
|
|
32
33
|
* { field, label?, type?, options?, width?, required?, prefix?, step? }
|
|
33
|
-
* type ∈ 'text' | 'number' | 'currency' | 'date' | 'select'
|
|
34
|
+
* type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
|
|
34
35
|
*
|
|
35
36
|
* Field-level config (from `GridFieldMetadata`):
|
|
36
37
|
* columns, min_rows, max_rows, allow_add, allow_delete, total_field
|
|
@@ -39,7 +40,7 @@ import { LookupField } from './LookupField';
|
|
|
39
40
|
export interface GridColumn {
|
|
40
41
|
field: string;
|
|
41
42
|
label?: string;
|
|
42
|
-
type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup';
|
|
43
|
+
type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
|
|
43
44
|
options?: Array<{ label: string; value: string }>;
|
|
44
45
|
width?: number;
|
|
45
46
|
required?: boolean;
|
|
@@ -49,7 +50,11 @@ export interface GridColumn {
|
|
|
49
50
|
reference?: string;
|
|
50
51
|
displayField?: string;
|
|
51
52
|
idField?: string;
|
|
53
|
+
/** Multi-value column: multi-record lookup, or multi-file upload cell. */
|
|
52
54
|
multiple?: boolean;
|
|
55
|
+
/** For `type: 'file'` — accepted MIME types / extensions for the picker
|
|
56
|
+
* (e.g. `['image/*', '.pdf']`). Omit to accept anything. */
|
|
57
|
+
accept?: string[];
|
|
53
58
|
/**
|
|
54
59
|
* Hidden from the grid by default but revealable via the column chooser.
|
|
55
60
|
* Set by `deriveColumns` for fields beyond the default-visible budget — the
|
|
@@ -105,6 +110,7 @@ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
|
|
|
105
110
|
number: 104,
|
|
106
111
|
currency: 116,
|
|
107
112
|
date: 150,
|
|
113
|
+
file: 168,
|
|
108
114
|
};
|
|
109
115
|
const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
|
|
110
116
|
|
|
@@ -252,6 +258,13 @@ export function computeRow(columns: GridColumn[], row: Row): Row {
|
|
|
252
258
|
* currency/number → formatted, empty → em dash). Lookups render separately. */
|
|
253
259
|
function displayText(c: GridColumn, value: any): string {
|
|
254
260
|
if (value === null || value === undefined || value === '') return '—';
|
|
261
|
+
if (c.type === 'file') {
|
|
262
|
+
const files = Array.isArray(value) ? value : [value];
|
|
263
|
+
if (files.length === 0) return '—';
|
|
264
|
+
if (files.length > 1) return `${files.length} files`;
|
|
265
|
+
const f = files[0];
|
|
266
|
+
return typeof f === 'string' ? f : f?.name || f?.original_name || 'File';
|
|
267
|
+
}
|
|
255
268
|
if (c.type === 'select' && Array.isArray(c.options)) {
|
|
256
269
|
const opt = c.options.find((o) => String(o.value) === String(value));
|
|
257
270
|
return opt ? opt.label : String(value);
|
|
@@ -613,6 +626,8 @@ export function GridField({
|
|
|
613
626
|
readonly
|
|
614
627
|
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
|
|
615
628
|
/>
|
|
629
|
+
) : c.type === 'file' ? (
|
|
630
|
+
displayText(c, row[c.field])
|
|
616
631
|
) : row[c.field] != null && row[c.field] !== '' ? (
|
|
617
632
|
String(row[c.field])
|
|
618
633
|
) : (
|
|
@@ -700,6 +715,21 @@ export function GridField({
|
|
|
700
715
|
/>
|
|
701
716
|
);
|
|
702
717
|
}
|
|
718
|
+
// File / image column → a real upload control in the cell (objectui#2360),
|
|
719
|
+
// not a text input: chips for uploaded files + a compact picker button.
|
|
720
|
+
if (c.type === 'file') {
|
|
721
|
+
return (
|
|
722
|
+
<FileCell
|
|
723
|
+
value={val}
|
|
724
|
+
onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
|
|
725
|
+
multiple={c.multiple}
|
|
726
|
+
accept={Array.isArray(c.accept) && c.accept.length > 0 ? c.accept.join(',') : undefined}
|
|
727
|
+
disabled={locked}
|
|
728
|
+
aria-label={c.label || c.field}
|
|
729
|
+
data-cell={`${rowIdx}-${colIdx}`}
|
|
730
|
+
/>
|
|
731
|
+
);
|
|
732
|
+
}
|
|
703
733
|
if (c.type === 'select') {
|
|
704
734
|
return (
|
|
705
735
|
<Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
|
|
@@ -544,6 +544,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
544
544
|
[idField],
|
|
545
545
|
);
|
|
546
546
|
|
|
547
|
+
// Level-2 pickers (PeoplePicker / RecordPickerDialog) operate on bare ids —
|
|
548
|
+
// their seed queries and selected-row matching compare against `idField`
|
|
549
|
+
// values. Collapse any `$expand`-ed record objects (passed through
|
|
550
|
+
// uncollapsed by the inline editor, objectui#2572) before handing the value
|
|
551
|
+
// down, or the seed fetch would query for a bogus object-shaped id.
|
|
552
|
+
const pickerValue = useMemo(
|
|
553
|
+
() => (multiple ? (Array.isArray(value) ? value.map(normalizeId) : []) : normalizeId(value)),
|
|
554
|
+
[multiple, value, normalizeId],
|
|
555
|
+
);
|
|
556
|
+
|
|
547
557
|
// Resolve a raw field value into its display option. An expanded-reference
|
|
548
558
|
// object is mapped directly (mirroring the read cell's display-name path) so
|
|
549
559
|
// the inline editor shows the record's name instead of the placeholder; a bare
|
|
@@ -914,7 +924,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
914
924
|
subtitleFields={subtitleFields}
|
|
915
925
|
avatarField={avatarField}
|
|
916
926
|
pageSize={lookupPageSize}
|
|
917
|
-
value={
|
|
927
|
+
value={pickerValue}
|
|
918
928
|
onSelect={onChange}
|
|
919
929
|
onSelectRecords={handlePickerSelectRecords}
|
|
920
930
|
lookupFilters={lookupFilters}
|
|
@@ -1135,7 +1145,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1135
1145
|
titleFormat={refTitleFormat}
|
|
1136
1146
|
idField={idField}
|
|
1137
1147
|
pageSize={lookupPageSize}
|
|
1138
|
-
value={
|
|
1148
|
+
value={pickerValue}
|
|
1139
1149
|
onSelect={onChange}
|
|
1140
1150
|
onSelectRecords={handlePickerSelectRecords}
|
|
1141
1151
|
lookupFilters={lookupFilters}
|
|
@@ -14,8 +14,16 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
14
14
|
|
|
15
15
|
const numberField = (field || (props as any).schema) as NumberFieldMetadata;
|
|
16
16
|
// Step follows `scale` (decimal places), not `precision` (total digit count):
|
|
17
|
-
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
17
|
+
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
18
|
+
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
|
|
19
|
+
// An explicit `step` in the metadata wins over the derived one.
|
|
18
20
|
const scale = numberField?.scale;
|
|
21
|
+
const step =
|
|
22
|
+
typeof numberField?.step === 'number'
|
|
23
|
+
? numberField.step
|
|
24
|
+
: typeof scale === 'number'
|
|
25
|
+
? Math.pow(10, -scale)
|
|
26
|
+
: 'any';
|
|
19
27
|
|
|
20
28
|
// Filter out non-DOM props
|
|
21
29
|
const { inputType, ...domProps } = props as any;
|
|
@@ -31,7 +39,11 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
31
39
|
}}
|
|
32
40
|
placeholder={numberField?.placeholder}
|
|
33
41
|
disabled={readonly || domProps.disabled}
|
|
34
|
-
|
|
42
|
+
// Surface the field's declared range so the browser's spinner/keyboard
|
|
43
|
+
// affordances respect it (server-side validation still owns enforcement).
|
|
44
|
+
min={typeof numberField?.min === 'number' ? numberField.min : undefined}
|
|
45
|
+
max={typeof numberField?.max === 'number' ? numberField.max : undefined}
|
|
46
|
+
step={step}
|
|
35
47
|
/>
|
|
36
48
|
);
|
|
37
49
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
+
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
+
import type { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ObjectRefField — object-name picker for form fields that store the machine
|
|
8
|
+
* name of a registered object (e.g. `sys_sharing_rule.object_name`).
|
|
9
|
+
*
|
|
10
|
+
* Reached via the field `widget: 'object-ref'` hint (declared on the object
|
|
11
|
+
* metadata; resolves as `field:object-ref`). Renders a searchable dropdown of
|
|
12
|
+
* the platform's registered objects instead of a free-text input where the
|
|
13
|
+
* admin has to remember a machine name.
|
|
14
|
+
*
|
|
15
|
+
* Object catalog source, in order of preference:
|
|
16
|
+
* 1. `dataSource.getObjects()` — the metadata-registry list endpoint
|
|
17
|
+
* (`GET /api/v1/meta/object`), which merges code- and DB-defined objects.
|
|
18
|
+
* 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the
|
|
19
|
+
* same source the Setup app's object grid browses.
|
|
20
|
+
* The stored value is always the object's `name` string. If the catalog can't
|
|
21
|
+
* load, the current value is still shown, so the control degrades safely.
|
|
22
|
+
*/
|
|
23
|
+
interface ObjectHeader {
|
|
24
|
+
name: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ObjectRefField({
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
readonly,
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: FieldWidgetProps<string>) {
|
|
35
|
+
const ctx = React.useContext(SchemaRendererContext);
|
|
36
|
+
const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
37
|
+
const disabled = (props as any).disabled as boolean | undefined;
|
|
38
|
+
|
|
39
|
+
const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (!dataSource) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
(async () => {
|
|
45
|
+
try {
|
|
46
|
+
let rows: ObjectHeader[] = [];
|
|
47
|
+
if (typeof dataSource.getObjects === 'function') {
|
|
48
|
+
rows = (await dataSource.getObjects()) ?? [];
|
|
49
|
+
} else if (typeof dataSource.find === 'function') {
|
|
50
|
+
const res = await dataSource.find('sys_metadata', {
|
|
51
|
+
$filter: { type: 'object' },
|
|
52
|
+
$top: 1000,
|
|
53
|
+
});
|
|
54
|
+
const list: any[] =
|
|
55
|
+
res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
|
|
56
|
+
rows = list
|
|
57
|
+
.map((r) => ({
|
|
58
|
+
name: String(r?.name ?? ''),
|
|
59
|
+
label: r?.label != null ? String(r.label) : undefined,
|
|
60
|
+
}))
|
|
61
|
+
.filter((r) => r.name);
|
|
62
|
+
}
|
|
63
|
+
if (!cancelled) setObjects(rows);
|
|
64
|
+
} catch {
|
|
65
|
+
if (!cancelled) setObjects([]);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
};
|
|
71
|
+
}, [dataSource]);
|
|
72
|
+
|
|
73
|
+
const options = React.useMemo(() => {
|
|
74
|
+
const opts = (objects ?? []).map((o) => ({
|
|
75
|
+
value: o.name,
|
|
76
|
+
label: o.label ? `${o.label} (${o.name})` : o.name,
|
|
77
|
+
}));
|
|
78
|
+
// Keep the current value selectable even if the catalog didn't include it
|
|
79
|
+
// (unknown / not-yet-published object), so editing never drops it.
|
|
80
|
+
if (value && !opts.some((o) => o.value === value)) {
|
|
81
|
+
opts.unshift({ value, label: value });
|
|
82
|
+
}
|
|
83
|
+
return opts.sort((a, b) => a.label.localeCompare(b.label));
|
|
84
|
+
}, [objects, value]);
|
|
85
|
+
|
|
86
|
+
if (readonly) {
|
|
87
|
+
if (!value) return <EmptyValue />;
|
|
88
|
+
const label = options.find((o) => o.value === value)?.label ?? value;
|
|
89
|
+
return <span className={className}>{label}</span>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Combobox
|
|
94
|
+
options={options}
|
|
95
|
+
value={value ?? ''}
|
|
96
|
+
onValueChange={(v) => onChange(v as any)}
|
|
97
|
+
placeholder={objects === null ? 'Loading objects…' : 'Select an object'}
|
|
98
|
+
searchPlaceholder="Search objects…"
|
|
99
|
+
emptyText={objects === null ? 'Loading…' : 'No objects found'}
|
|
100
|
+
disabled={disabled}
|
|
101
|
+
className={cn(className)}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default ObjectRefField;
|
|
@@ -179,6 +179,12 @@ describe('PeoplePicker', () => {
|
|
|
179
179
|
await waitFor(() => expect(screen.getByText('Amy Lin')).toBeTruthy());
|
|
180
180
|
const search = screen.getByTestId('people-picker-search');
|
|
181
181
|
fireEvent.keyDown(search, { key: 'ArrowDown' });
|
|
182
|
+
// Wait for the cursor to actually land on the first row before committing —
|
|
183
|
+
// guards against a late background re-render swallowing the keypress (the
|
|
184
|
+
// old identity-keyed cursor reset made this flaky on slow CI).
|
|
185
|
+
await waitFor(() =>
|
|
186
|
+
expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).toBe('true'),
|
|
187
|
+
);
|
|
182
188
|
fireEvent.keyDown(search, { key: 'Enter' });
|
|
183
189
|
expect(onSelect).toHaveBeenCalledWith('u1');
|
|
184
190
|
});
|
|
@@ -311,10 +311,19 @@ export function PeoplePicker({
|
|
|
311
311
|
|
|
312
312
|
// --- keyboard cursor ---
|
|
313
313
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
314
|
-
// Reset the cursor when the query
|
|
314
|
+
// Reset the cursor when the query results are actually *replaced* — keyed on
|
|
315
|
+
// the record-id signature, not the array identity. A background refetch that
|
|
316
|
+
// returns the same records (StrictMode double-effect, refetch-on-focus)
|
|
317
|
+
// re-emits a new array; resetting on identity alone yanked the cursor to -1
|
|
318
|
+
// mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
|
|
319
|
+
const recordsSignature = useMemo(
|
|
320
|
+
() => query.records.map(r => String(getPersonId(r, idField))).join(''),
|
|
321
|
+
[query.records, idField],
|
|
322
|
+
);
|
|
315
323
|
useEffect(() => {
|
|
316
324
|
setActiveIndex(-1);
|
|
317
|
-
|
|
325
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
326
|
+
}, [query.search, recordsSignature]);
|
|
318
327
|
// Keep it in range if the list shrinks.
|
|
319
328
|
useEffect(() => {
|
|
320
329
|
setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
+
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
+
import type { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RecipientPickerField — dependent record picker for a polymorphic recipient
|
|
8
|
+
* reference (e.g. `sys_sharing_rule.recipient_id`), whose target object is
|
|
9
|
+
* decided by a sibling `recipient_type` select.
|
|
10
|
+
*
|
|
11
|
+
* Reached via the field `widget: 'recipient-picker'` hint (resolves as
|
|
12
|
+
* `field:recipient-picker`). Reads the live `recipient_type` from
|
|
13
|
+
* `dependentValues` (the form record), loads candidate records of the mapped
|
|
14
|
+
* object via `dataSource.find(...)`, and stores the value the sharing evaluator
|
|
15
|
+
* expects for that type:
|
|
16
|
+
*
|
|
17
|
+
* user → sys_user, store `id`
|
|
18
|
+
* team → sys_team, store `id`
|
|
19
|
+
* business_unit → sys_business_unit, store `id`
|
|
20
|
+
* unit_and_subordinates → sys_business_unit, store `id`
|
|
21
|
+
* position → sys_position, store `name` (matched against
|
|
22
|
+
* sys_user_position.position at evaluation time)
|
|
23
|
+
*
|
|
24
|
+
* When `recipient_type` changes after mount the stored id is reset (an id valid
|
|
25
|
+
* for one type is meaningless for another). Unknown types degrade to a plain
|
|
26
|
+
* text input so nothing breaks.
|
|
27
|
+
*/
|
|
28
|
+
interface RecipientMapping {
|
|
29
|
+
object: string;
|
|
30
|
+
/** Which record field to persist into recipient_id. */
|
|
31
|
+
storeField: 'id' | 'name';
|
|
32
|
+
/** Candidate display-label fields, in preference order. */
|
|
33
|
+
labelFields: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
|
|
37
|
+
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
|
|
38
|
+
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
|
|
39
|
+
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
40
|
+
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
41
|
+
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function RecipientPickerField({
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
readonly,
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: FieldWidgetProps<string>) {
|
|
51
|
+
const ctx = React.useContext(SchemaRendererContext);
|
|
52
|
+
const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
53
|
+
const disabled = (props as any).disabled as boolean | undefined;
|
|
54
|
+
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
55
|
+
const recipientType = String(dependentValues.recipient_type ?? '');
|
|
56
|
+
const mapping = TYPE_TO_OBJECT[recipientType];
|
|
57
|
+
|
|
58
|
+
const [records, setRecords] = React.useState<any[] | null>(null);
|
|
59
|
+
|
|
60
|
+
// Reset the stored recipient when the type changes AFTER mount (an id for a
|
|
61
|
+
// user is not a valid team/business-unit id). The ref starts null so the
|
|
62
|
+
// initial render of an existing rule never clears its value.
|
|
63
|
+
const prevType = React.useRef<string | null>(null);
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (prevType.current !== null && prevType.current !== recipientType && value) {
|
|
66
|
+
onChange('' as any);
|
|
67
|
+
}
|
|
68
|
+
prevType.current = recipientType;
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [recipientType]);
|
|
71
|
+
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
setRecords(null);
|
|
74
|
+
if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' });
|
|
79
|
+
const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
|
|
80
|
+
if (!cancelled) setRecords(Array.isArray(list) ? list : []);
|
|
81
|
+
} catch {
|
|
82
|
+
if (!cancelled) setRecords([]);
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true;
|
|
87
|
+
};
|
|
88
|
+
}, [dataSource, mapping?.object]);
|
|
89
|
+
|
|
90
|
+
const labelOf = (r: any): string => {
|
|
91
|
+
for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
|
|
92
|
+
return String(r?.id ?? '');
|
|
93
|
+
};
|
|
94
|
+
const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? '');
|
|
95
|
+
|
|
96
|
+
const options = React.useMemo(() => {
|
|
97
|
+
const opts = (records ?? [])
|
|
98
|
+
.map((r) => ({ value: valueOf(r), label: labelOf(r) }))
|
|
99
|
+
.filter((o) => o.value);
|
|
100
|
+
if (value && !opts.some((o) => o.value === value)) opts.unshift({ value, label: value });
|
|
101
|
+
return opts;
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
}, [records, value, mapping]);
|
|
104
|
+
|
|
105
|
+
if (!recipientType) {
|
|
106
|
+
return (
|
|
107
|
+
<p className={cn('text-sm text-muted-foreground', className)}>
|
|
108
|
+
Select a recipient type first.
|
|
109
|
+
</p>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!mapping) {
|
|
114
|
+
// Unknown / unsupported recipient type — keep a plain text input so the
|
|
115
|
+
// field is never un-editable.
|
|
116
|
+
return (
|
|
117
|
+
<input
|
|
118
|
+
className={cn(
|
|
119
|
+
'w-full rounded border bg-background px-2 py-1 text-sm',
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
value={value ?? ''}
|
|
123
|
+
disabled={disabled || readonly}
|
|
124
|
+
onChange={(e) => onChange(e.target.value as any)}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (readonly) {
|
|
130
|
+
if (!value) return <EmptyValue />;
|
|
131
|
+
return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Combobox
|
|
136
|
+
options={options}
|
|
137
|
+
value={value ?? ''}
|
|
138
|
+
onValueChange={(v) => onChange(v as any)}
|
|
139
|
+
placeholder={
|
|
140
|
+
records === null ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}`
|
|
141
|
+
}
|
|
142
|
+
searchPlaceholder="Search…"
|
|
143
|
+
emptyText={records === null ? 'Loading…' : 'No matches'}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
className={className}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default RecipientPickerField;
|
|
@@ -102,14 +102,14 @@ describe('SelectField — role / context gating (#2284)', () => {
|
|
|
102
102
|
type: 'select',
|
|
103
103
|
options: [
|
|
104
104
|
{ label: 'Standard', value: 'standard' },
|
|
105
|
-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.
|
|
105
|
+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
106
106
|
],
|
|
107
107
|
} as any;
|
|
108
108
|
|
|
109
109
|
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
110
110
|
const onChange = vi.fn();
|
|
111
111
|
render(
|
|
112
|
-
<PredicateScopeProvider scope={{ current_user: {
|
|
112
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
113
113
|
<SelectField
|
|
114
114
|
value={'admin_only'}
|
|
115
115
|
onChange={onChange}
|
|
@@ -124,7 +124,7 @@ describe('SelectField — role / context gating (#2284)', () => {
|
|
|
124
124
|
it('keeps an admin-only value for an admin', () => {
|
|
125
125
|
const onChange = vi.fn();
|
|
126
126
|
render(
|
|
127
|
-
<PredicateScopeProvider scope={{ current_user: {
|
|
127
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
128
128
|
<SelectField
|
|
129
129
|
value={'admin_only'}
|
|
130
130
|
onChange={onChange}
|
|
@@ -24,7 +24,7 @@ import { FieldWidgetProps } from './types';
|
|
|
24
24
|
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
25
25
|
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
26
26
|
* `current_user`, so the offered set narrows as a controlling field changes
|
|
27
|
-
* (`record.country == 'cn'`) or by role (`'admin' in current_user.
|
|
27
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
|
|
28
28
|
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
29
29
|
* of those is empty the control is gated with a "select the parent first" hint,
|
|
30
30
|
* mirroring the dependent-lookup UX.
|