@object-ui/fields 6.2.3 → 7.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 +203 -0
- package/dist/complex-widgets.test.d.ts +1 -0
- package/dist/currency.d.ts +13 -0
- package/dist/datetime-widgets.test.d.ts +1 -0
- package/dist/field-type-coverage.test.d.ts +1 -0
- package/dist/index.cjs +4 -3
- package/dist/index.d.ts +300 -2
- package/dist/index.js +2460 -1274
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
- package/dist/widgets/CheckboxesField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
- package/dist/widgets/GridField.d.ts +123 -0
- package/dist/widgets/GridField.test.d.ts +1 -0
- package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
- package/dist/widgets/MarkdownContent.d.ts +9 -0
- package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
- package/dist/widgets/MultiSelectField.d.ts +9 -0
- package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
- package/dist/widgets/RadioField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
- package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
- package/dist/widgets/TagsField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
- package/dist/widgets/deriveLookupColumns.d.ts +36 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
- package/dist/widgets/recentLookups.d.ts +13 -0
- package/dist/widgets/recentLookups.test.d.ts +1 -0
- package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
- package/package.json +14 -11
- package/src/__tests__/registry-collision.test.ts +91 -0
- package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
- package/src/coerce-safe-value.test.ts +34 -0
- package/src/complex-widgets.test.tsx +58 -7
- package/src/currency.ts +14 -0
- package/src/field-type-coverage.test.ts +65 -0
- package/src/index.tsx +372 -57
- package/src/widgets/CheckboxesField.tsx +53 -0
- package/src/widgets/CurrencyField.tsx +5 -1
- package/src/widgets/GridField.test.tsx +309 -0
- package/src/widgets/GridField.tsx +907 -33
- package/src/widgets/LookupField.tsx +258 -107
- package/src/widgets/MarkdownContent.tsx +19 -0
- package/src/widgets/MultiSelectField.tsx +59 -0
- package/src/widgets/PercentField.tsx +19 -7
- package/src/widgets/RadioField.tsx +41 -0
- package/src/widgets/RecordPickerDialog.tsx +22 -18
- package/src/widgets/SelectField.tsx +23 -2
- package/src/widgets/TagsField.tsx +67 -0
- package/src/widgets/deriveLookupColumns.test.ts +74 -0
- package/src/widgets/deriveLookupColumns.ts +104 -0
- package/src/widgets/recentLookups.test.ts +45 -0
- package/src/widgets/recentLookups.ts +47 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
- package/dist/packages/fields/src/fields.stories.d.ts +0 -56
- package/dist/packages/fields/src/index.d.ts +0 -286
- package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
- package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
- package/src/fields-edge-cases.stories.tsx +0 -407
- package/src/fields.stories.tsx +0 -566
- package/src/record-picker-dialog.stories.tsx +0 -160
- /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { cn,
|
|
3
|
+
Button,
|
|
3
4
|
Input,
|
|
4
5
|
Badge,
|
|
5
6
|
Popover,
|
|
@@ -8,8 +9,10 @@ import { Button,
|
|
|
8
9
|
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
9
10
|
import { FieldWidgetProps } from './types';
|
|
10
11
|
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
11
|
-
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
12
|
+
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
12
13
|
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
14
|
+
import { deriveLookupColumns } from './deriveLookupColumns';
|
|
15
|
+
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
13
16
|
import { getCellRendererResolver } from './_cell-renderer-bridge';
|
|
14
17
|
import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
|
|
15
18
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
@@ -127,6 +130,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
127
130
|
const [isOpen, setIsOpen] = useState(false);
|
|
128
131
|
const [searchQuery, setSearchQuery] = useState('');
|
|
129
132
|
const { t } = useFieldTranslation();
|
|
133
|
+
const listboxId = React.useId();
|
|
130
134
|
|
|
131
135
|
// Dynamic data loading state
|
|
132
136
|
const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
|
|
@@ -158,16 +162,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
158
162
|
|
|
159
163
|
const staticOptions: LookupOption[] = fieldMeta?.options || [];
|
|
160
164
|
const multiple = fieldMeta?.multiple || false;
|
|
161
|
-
const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name';
|
|
162
|
-
const descriptionField: string | undefined = fieldMeta?.description_field;
|
|
165
|
+
const displayField = fieldMeta?.display_field || fieldMeta?.displayField || fieldMeta?.reference_field || 'name';
|
|
166
|
+
const descriptionField: string | undefined = fieldMeta?.description_field ?? fieldMeta?.descriptionField;
|
|
163
167
|
const idField = fieldMeta?.id_field || 'id';
|
|
164
168
|
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
165
169
|
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
170
|
+
// Opt-in inline quick-create: when set, an empty/zero-result picker offers to
|
|
171
|
+
// create a record from the typed text via dataSource.create.
|
|
172
|
+
const allowCreate: boolean = !!(fieldMeta?.allow_create ?? fieldMeta?.allowCreate);
|
|
166
173
|
|
|
167
174
|
// Enterprise Record Picker configuration
|
|
168
|
-
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns;
|
|
169
|
-
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size;
|
|
170
|
-
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters;
|
|
175
|
+
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
|
|
176
|
+
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
|
|
177
|
+
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
|
|
171
178
|
|
|
172
179
|
/**
|
|
173
180
|
* Dependent lookups — restrict candidates based on values of *other* fields
|
|
@@ -194,24 +201,6 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
194
201
|
|
|
195
202
|
// Resolve dependent field values from explicit prop or SchemaRendererContext.data
|
|
196
203
|
const dependentValuesProp = (props as any).dependentValues as Record<string, any> | undefined;
|
|
197
|
-
// Derive filter columns from lookup_columns that have type info
|
|
198
|
-
const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
199
|
-
if (!lookupColumns) return undefined;
|
|
200
|
-
const cols: RecordPickerFilterColumn[] = [];
|
|
201
|
-
for (const c of lookupColumns) {
|
|
202
|
-
if (typeof c === 'object' && c.type) {
|
|
203
|
-
const filterType = mapFieldTypeToFilterType(c.type);
|
|
204
|
-
if (filterType) {
|
|
205
|
-
cols.push({
|
|
206
|
-
field: c.field,
|
|
207
|
-
label: c.label,
|
|
208
|
-
type: filterType,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return cols.length > 0 ? cols : undefined;
|
|
214
|
-
}, [lookupColumns]);
|
|
215
204
|
|
|
216
205
|
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
217
206
|
const ctx = useContext(SchemaRendererContext);
|
|
@@ -262,6 +251,42 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
262
251
|
return null;
|
|
263
252
|
}, [refObjectSchema]);
|
|
264
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Picker columns. Honour explicit `lookup_columns` when authored; otherwise
|
|
256
|
+
* derive a multi-column, disambiguating set from the referenced object's
|
|
257
|
+
* schema so every lookup gets a useful picker with zero field-level config.
|
|
258
|
+
*/
|
|
259
|
+
const pickerColumns = useMemo<Array<string | LookupColumnDef> | undefined>(() => {
|
|
260
|
+
if (lookupColumns && lookupColumns.length > 0) return lookupColumns;
|
|
261
|
+
const derived = deriveLookupColumns(refObjectSchema, { displayField });
|
|
262
|
+
return derived.length > 0 ? derived : undefined;
|
|
263
|
+
}, [lookupColumns, refObjectSchema, displayField]);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Secondary line under each quick-select option. Honour explicit
|
|
267
|
+
* `description_field`; otherwise reuse the first derived non-display column so
|
|
268
|
+
* the inline popover also benefits from the richer schema.
|
|
269
|
+
*/
|
|
270
|
+
const effectiveDescriptionField = useMemo<string | undefined>(() => {
|
|
271
|
+
if (descriptionField) return descriptionField;
|
|
272
|
+
const extra = pickerColumns?.find((c) => (typeof c === 'string' ? c : c.field) !== displayField);
|
|
273
|
+
if (!extra) return undefined;
|
|
274
|
+
return typeof extra === 'string' ? extra : extra.field;
|
|
275
|
+
}, [descriptionField, pickerColumns, displayField]);
|
|
276
|
+
|
|
277
|
+
// Derive filter-bar columns from any typed picker columns.
|
|
278
|
+
const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
279
|
+
if (!pickerColumns) return undefined;
|
|
280
|
+
const cols: RecordPickerFilterColumn[] = [];
|
|
281
|
+
for (const c of pickerColumns) {
|
|
282
|
+
if (typeof c === 'object' && c.type) {
|
|
283
|
+
const filterType = mapFieldTypeToFilterType(c.type);
|
|
284
|
+
if (filterType) cols.push({ field: c.field, label: c.label, type: filterType });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return cols.length > 0 ? cols : undefined;
|
|
288
|
+
}, [pickerColumns]);
|
|
289
|
+
|
|
265
290
|
// Optional create-new callback
|
|
266
291
|
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
267
292
|
(props as any).onCreateNew ?? lookupField?.onCreateNew;
|
|
@@ -325,9 +350,15 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
325
350
|
}
|
|
326
351
|
}
|
|
327
352
|
|
|
353
|
+
// Apply base scoping filters so the quick-select popover matches the
|
|
354
|
+
// full picker dialog (lookupFilters were previously dialog-only).
|
|
355
|
+
if (lookupFilters && lookupFilters.length > 0) {
|
|
356
|
+
params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
|
|
357
|
+
}
|
|
358
|
+
|
|
328
359
|
const result = await dataSource.find(referenceTo, params);
|
|
329
360
|
const records: any[] = result?.data ?? result ?? [];
|
|
330
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField,
|
|
361
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
331
362
|
|
|
332
363
|
setFetchedOptions(mapped);
|
|
333
364
|
setTotalCount(result?.total ?? records.length);
|
|
@@ -339,7 +370,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
339
370
|
setLoading(false);
|
|
340
371
|
}
|
|
341
372
|
},
|
|
342
|
-
[dataSource, referenceTo, displayField, idField,
|
|
373
|
+
[dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
|
|
343
374
|
);
|
|
344
375
|
|
|
345
376
|
// Re-fetch when dependent values change while the picker is open. This keeps
|
|
@@ -424,14 +455,14 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
424
455
|
for (const id of unresolved) {
|
|
425
456
|
if (typeof (dataSource as any).findOne === 'function') {
|
|
426
457
|
const rec = await (dataSource as any).findOne(referenceTo, id);
|
|
427
|
-
if (rec) fetched.push(recordToOption(rec, displayField, idField,
|
|
458
|
+
if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
428
459
|
} else {
|
|
429
460
|
const res = await dataSource.find(referenceTo, {
|
|
430
461
|
$filter: { [idField]: id },
|
|
431
462
|
$top: 1,
|
|
432
463
|
} as QueryParams);
|
|
433
464
|
const rows = res?.data ?? res ?? [];
|
|
434
|
-
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField,
|
|
465
|
+
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
435
466
|
}
|
|
436
467
|
}
|
|
437
468
|
if (!cancelled && fetched.length) {
|
|
@@ -449,7 +480,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
449
480
|
cancelled = true;
|
|
450
481
|
};
|
|
451
482
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
452
|
-
}, [value, hasDataSource, referenceTo, displayField, idField,
|
|
483
|
+
}, [value, hasDataSource, referenceTo, displayField, idField, effectiveDescriptionField, multiple]);
|
|
453
484
|
|
|
454
485
|
// Get selected option(s) — check static, fetched, and picker-resolved options
|
|
455
486
|
const findOption = useCallback(
|
|
@@ -467,23 +498,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
467
498
|
? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
|
|
468
499
|
: value ? [findOption(value)].filter(Boolean) : [];
|
|
469
500
|
|
|
501
|
+
// Optional: receive the FULL selected record (not just its id) so a host can
|
|
502
|
+
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
503
|
+
// unit_price/description when the item is chosen. When provided (single
|
|
504
|
+
// select), it drives the update and the host owns the resulting value change.
|
|
505
|
+
const onSelectRecord = (props as any).onSelectRecord as ((record: LookupOption) => void) | undefined;
|
|
506
|
+
|
|
470
507
|
const handleSelect = useCallback(
|
|
471
508
|
(option: LookupOption) => {
|
|
472
509
|
if (multiple) {
|
|
473
510
|
const currentValues = Array.isArray(value) ? value : [];
|
|
474
511
|
const isSelected = currentValues.includes(option.value);
|
|
475
|
-
|
|
512
|
+
|
|
476
513
|
if (isSelected) {
|
|
477
514
|
onChange(currentValues.filter((v: any) => v !== option.value));
|
|
478
515
|
} else {
|
|
516
|
+
if (referenceTo) pushRecentLookupId(referenceTo, option.value);
|
|
479
517
|
onChange([...currentValues, option.value]);
|
|
480
518
|
}
|
|
481
519
|
} else {
|
|
482
|
-
|
|
520
|
+
if (referenceTo) pushRecentLookupId(referenceTo, option.value);
|
|
521
|
+
if (onSelectRecord) onSelectRecord(option);
|
|
522
|
+
else onChange(option.value);
|
|
483
523
|
setIsOpen(false);
|
|
484
524
|
}
|
|
485
525
|
},
|
|
486
|
-
[multiple, value, onChange],
|
|
526
|
+
[multiple, value, onChange, onSelectRecord, referenceTo],
|
|
487
527
|
);
|
|
488
528
|
|
|
489
529
|
const handleRemove = (optionValue: any) => {
|
|
@@ -499,10 +539,89 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
499
539
|
// findOption can resolve display labels after the dialog closes.
|
|
500
540
|
const handlePickerSelectRecords = useCallback(
|
|
501
541
|
(records: any[]) => {
|
|
502
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField,
|
|
542
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
543
|
+
if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
|
|
503
544
|
setPickerResolvedRecords(mapped);
|
|
504
545
|
},
|
|
505
|
-
[displayField, idField,
|
|
546
|
+
[displayField, idField, effectiveDescriptionField, refTitleFormat, referenceTo],
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// ── Recently-used, quick-create, combined option list ────────────────────
|
|
550
|
+
const [recentOptions, setRecentOptions] = useState<LookupOption[]>([]);
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
if (!isOpen || !hasDataSource || !dataSource || !referenceTo || searchQuery) return;
|
|
553
|
+
const ids = getRecentLookupIds(referenceTo);
|
|
554
|
+
if (!ids.length) { setRecentOptions([]); return; }
|
|
555
|
+
let cancelled = false;
|
|
556
|
+
(async () => {
|
|
557
|
+
try {
|
|
558
|
+
const recs: LookupOption[] = [];
|
|
559
|
+
for (const id of ids) {
|
|
560
|
+
const cached = findOption(id);
|
|
561
|
+
if (cached) { recs.push(cached); continue; }
|
|
562
|
+
if (typeof (dataSource as any).findOne === 'function') {
|
|
563
|
+
const r = await (dataSource as any).findOne(referenceTo, id);
|
|
564
|
+
if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!cancelled) setRecentOptions(recs);
|
|
568
|
+
} catch { if (!cancelled) setRecentOptions([]); }
|
|
569
|
+
})();
|
|
570
|
+
return () => { cancelled = true; };
|
|
571
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
572
|
+
}, [isOpen, hasDataSource, referenceTo, searchQuery]);
|
|
573
|
+
|
|
574
|
+
// Recently-used first (only before the user types), then live results — one
|
|
575
|
+
// de-duped list that drives BOTH rendering and arrow-key navigation.
|
|
576
|
+
const recentCount = (!searchQuery && hasDataSource) ? recentOptions.length : 0;
|
|
577
|
+
const visibleOptions = useMemo(() => {
|
|
578
|
+
if (searchQuery || !hasDataSource || recentOptions.length === 0) return filteredOptions;
|
|
579
|
+
const recentIds = new Set(recentOptions.map((o) => o.value));
|
|
580
|
+
return [...recentOptions, ...filteredOptions.filter((o) => !recentIds.has(o.value))];
|
|
581
|
+
}, [searchQuery, hasDataSource, recentOptions, filteredOptions]);
|
|
582
|
+
useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
|
|
583
|
+
|
|
584
|
+
const [creating, setCreating] = useState(false);
|
|
585
|
+
const canCreate = !!onCreateNew || (allowCreate && hasDataSource);
|
|
586
|
+
const handleCreateNew = useCallback(
|
|
587
|
+
async (q: string) => {
|
|
588
|
+
const label = (q || '').trim();
|
|
589
|
+
if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
|
|
590
|
+
if (!allowCreate || !dataSource || !referenceTo || !label) return;
|
|
591
|
+
setCreating(true);
|
|
592
|
+
setError(null);
|
|
593
|
+
try {
|
|
594
|
+
const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
|
|
595
|
+
const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat);
|
|
596
|
+
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
597
|
+
handleSelect(opt);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
600
|
+
} finally {
|
|
601
|
+
setCreating(false);
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
[onCreateNew, allowCreate, dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, handleSelect],
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// Compact one-line preview of an option's extra (non-display) columns —
|
|
608
|
+
// shown as a native tooltip so users can disambiguate without opening it.
|
|
609
|
+
const previewOf = useCallback(
|
|
610
|
+
(option: LookupOption): string | undefined => {
|
|
611
|
+
if (!pickerColumns || pickerColumns.length === 0) return undefined;
|
|
612
|
+
const parts: string[] = [];
|
|
613
|
+
for (const c of pickerColumns) {
|
|
614
|
+
const f = typeof c === 'string' ? c : c.field;
|
|
615
|
+
if (f === displayField) continue;
|
|
616
|
+
const v = (option as any)[f];
|
|
617
|
+
if (v === null || v === undefined || v === '') continue;
|
|
618
|
+
const lbl = typeof c === 'string' ? f : (c.label || f);
|
|
619
|
+
const text = typeof v === 'object' ? (v.name ?? v.label ?? JSON.stringify(v)) : v;
|
|
620
|
+
parts.push(`${lbl}: ${text}`);
|
|
621
|
+
}
|
|
622
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
623
|
+
},
|
|
624
|
+
[pickerColumns, displayField],
|
|
506
625
|
);
|
|
507
626
|
|
|
508
627
|
// Keyboard handler for the search input — arrow keys + Enter
|
|
@@ -511,19 +630,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
511
630
|
if (e.key === 'ArrowDown') {
|
|
512
631
|
e.preventDefault();
|
|
513
632
|
setActiveIndex(prev =>
|
|
514
|
-
prev <
|
|
633
|
+
prev < visibleOptions.length - 1 ? prev + 1 : prev,
|
|
515
634
|
);
|
|
516
635
|
} else if (e.key === 'ArrowUp') {
|
|
517
636
|
e.preventDefault();
|
|
518
637
|
setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
|
|
519
638
|
} else if (e.key === 'Enter') {
|
|
520
639
|
e.preventDefault();
|
|
521
|
-
if (activeIndex >= 0 && activeIndex <
|
|
522
|
-
handleSelect(
|
|
640
|
+
if (activeIndex >= 0 && activeIndex < visibleOptions.length) {
|
|
641
|
+
handleSelect(visibleOptions[activeIndex]);
|
|
523
642
|
}
|
|
524
643
|
}
|
|
525
644
|
},
|
|
526
|
-
[
|
|
645
|
+
[visibleOptions, activeIndex, handleSelect],
|
|
527
646
|
);
|
|
528
647
|
|
|
529
648
|
// Scroll active item into view
|
|
@@ -558,14 +677,20 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
558
677
|
);
|
|
559
678
|
}
|
|
560
679
|
|
|
680
|
+
// Compact mode (e.g. inside a line-item grid cell): show the selected value
|
|
681
|
+
// INSIDE a borderless trigger on a single line — no chip stacked above a
|
|
682
|
+
// separate "Select…" button (which double-stacks and wastes the row height).
|
|
683
|
+
const compact = !!(props as any).compact;
|
|
684
|
+
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
685
|
+
|
|
561
686
|
return (
|
|
562
|
-
<div className=
|
|
563
|
-
{/* Selected values display */}
|
|
564
|
-
{selectedOptions.length > 0 && (
|
|
687
|
+
<div className={compact ? '' : 'space-y-2'}>
|
|
688
|
+
{/* Selected values display (full mode only — compact shows it in-trigger) */}
|
|
689
|
+
{selectedOptions.length > 0 && !compact && (
|
|
565
690
|
<div className="flex flex-wrap gap-1">
|
|
566
691
|
{selectedOptions.map((opt, idx) => (
|
|
567
|
-
<Badge
|
|
568
|
-
key={idx}
|
|
692
|
+
<Badge
|
|
693
|
+
key={idx}
|
|
569
694
|
variant="outline"
|
|
570
695
|
className="gap-1"
|
|
571
696
|
>
|
|
@@ -574,7 +699,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
574
699
|
onClick={() => handleRemove(opt?.value)}
|
|
575
700
|
className="ml-1 hover:text-destructive"
|
|
576
701
|
type="button"
|
|
577
|
-
aria-label={
|
|
702
|
+
aria-label={t('lookup.remove', { label: opt?.label || opt?.[displayField] })}
|
|
578
703
|
>
|
|
579
704
|
<X className="size-3" />
|
|
580
705
|
</button>
|
|
@@ -587,23 +712,33 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
587
712
|
<div className="flex items-center gap-1.5">
|
|
588
713
|
<Popover open={isOpen} onOpenChange={(o) => !dependenciesMissing && setIsOpen(o)}>
|
|
589
714
|
<PopoverTrigger asChild>
|
|
590
|
-
<Button
|
|
591
|
-
variant="outline"
|
|
592
|
-
className=
|
|
715
|
+
<Button
|
|
716
|
+
variant="outline"
|
|
717
|
+
className={cn(
|
|
718
|
+
'min-w-0 flex-1 justify-start text-left font-normal',
|
|
719
|
+
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
720
|
+
)}
|
|
593
721
|
type="button"
|
|
722
|
+
aria-haspopup="listbox"
|
|
723
|
+
aria-expanded={isOpen}
|
|
724
|
+
aria-controls={listboxId}
|
|
594
725
|
disabled={dependenciesMissing || (props as any).disabled}
|
|
595
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' :
|
|
726
|
+
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
|
|
596
727
|
title={dependenciesMissing
|
|
597
|
-
?
|
|
728
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
598
729
|
: undefined}
|
|
599
730
|
>
|
|
600
|
-
<Search className=
|
|
731
|
+
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
|
|
732
|
+
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
601
733
|
{dependenciesMissing
|
|
602
|
-
?
|
|
603
|
-
: selectedOptions.length
|
|
604
|
-
?
|
|
605
|
-
:
|
|
734
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
735
|
+
: compact && !multiple && selectedOptions.length > 0
|
|
736
|
+
? singleSelectedLabel
|
|
737
|
+
: selectedOptions.length === 0
|
|
738
|
+
? lookupField?.placeholder || t('common.select')
|
|
739
|
+
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
|
|
606
740
|
}
|
|
741
|
+
</span>
|
|
607
742
|
</Button>
|
|
608
743
|
</PopoverTrigger>
|
|
609
744
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
@@ -617,6 +752,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
617
752
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
618
753
|
onKeyDown={handleSearchKeyDown}
|
|
619
754
|
className="w-full pl-9 h-8 text-sm"
|
|
755
|
+
role="combobox"
|
|
756
|
+
aria-autocomplete="list"
|
|
757
|
+
aria-controls={listboxId}
|
|
758
|
+
aria-expanded={isOpen}
|
|
759
|
+
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
|
|
620
760
|
/>
|
|
621
761
|
{loading && (
|
|
622
762
|
<Loader2
|
|
@@ -638,7 +778,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
638
778
|
onClick={() => fetchLookupData(searchQuery || undefined)}
|
|
639
779
|
type="button"
|
|
640
780
|
>
|
|
641
|
-
|
|
781
|
+
{t('lookup.retry')}
|
|
642
782
|
</Button>
|
|
643
783
|
</div>
|
|
644
784
|
)}
|
|
@@ -647,77 +787,90 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
647
787
|
{loading && filteredOptions.length === 0 && !error && (
|
|
648
788
|
<div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
|
|
649
789
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
650
|
-
<p className="text-sm text-muted-foreground">
|
|
790
|
+
<p className="text-sm text-muted-foreground">{t('lookup.loading')}</p>
|
|
651
791
|
</div>
|
|
652
792
|
)}
|
|
653
793
|
|
|
654
794
|
{/* Options list */}
|
|
655
795
|
{!error && !(loading && filteredOptions.length === 0) && (
|
|
656
|
-
<div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox">
|
|
657
|
-
{
|
|
796
|
+
<div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox" id={listboxId}>
|
|
797
|
+
{visibleOptions.length === 0 ? (
|
|
658
798
|
<div className="py-4 text-center">
|
|
659
799
|
<p className="text-sm text-muted-foreground">
|
|
660
|
-
|
|
800
|
+
{t('lookup.noOptions')}
|
|
661
801
|
</p>
|
|
662
802
|
{/* Quick-create entry */}
|
|
663
|
-
{
|
|
803
|
+
{canCreate && (
|
|
664
804
|
<Button
|
|
665
805
|
variant="ghost"
|
|
666
806
|
size="sm"
|
|
667
807
|
className="mt-2 gap-1"
|
|
668
808
|
type="button"
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
setIsOpen(false);
|
|
672
|
-
}}
|
|
809
|
+
disabled={creating}
|
|
810
|
+
onClick={() => handleCreateNew(searchQuery)}
|
|
673
811
|
>
|
|
674
|
-
<Plus className="size-4" />
|
|
675
|
-
|
|
812
|
+
{creating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
|
813
|
+
{searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
|
|
676
814
|
</Button>
|
|
677
815
|
)}
|
|
678
816
|
</div>
|
|
679
817
|
) : (
|
|
680
818
|
<>
|
|
681
|
-
{
|
|
819
|
+
{visibleOptions.map((option, idx) => {
|
|
682
820
|
const isSelected = multiple
|
|
683
821
|
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
684
822
|
: value === option.value;
|
|
685
823
|
const isActive = idx === activeIndex;
|
|
824
|
+
const showRecentHeader = recentCount > 0 && idx === 0;
|
|
825
|
+
const showResultsHeader = recentCount > 0 && idx === recentCount;
|
|
686
826
|
|
|
687
827
|
return (
|
|
688
|
-
<
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
onClick={() => handleSelect(option)}
|
|
694
|
-
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
|
|
695
|
-
isActive
|
|
696
|
-
? 'bg-accent text-accent-foreground'
|
|
697
|
-
: isSelected
|
|
698
|
-
? 'bg-accent/50 text-accent-foreground'
|
|
699
|
-
: ''
|
|
700
|
-
}`}
|
|
701
|
-
type="button"
|
|
702
|
-
>
|
|
703
|
-
<div className="min-w-0 flex-1">
|
|
704
|
-
<span className="block truncate">{option.label}</span>
|
|
705
|
-
{option.description && (
|
|
706
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
707
|
-
{option.description}
|
|
708
|
-
</span>
|
|
709
|
-
)}
|
|
710
|
-
</div>
|
|
711
|
-
{isSelected && (
|
|
712
|
-
<Badge variant="default" className="ml-2 shrink-0">Selected</Badge>
|
|
828
|
+
<React.Fragment key={option.value}>
|
|
829
|
+
{showRecentHeader && (
|
|
830
|
+
<div className="px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
831
|
+
{t('lookup.recentlyUsed')}
|
|
832
|
+
</div>
|
|
713
833
|
)}
|
|
714
|
-
|
|
834
|
+
{showResultsHeader && (
|
|
835
|
+
<div className="mt-1 border-t px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
836
|
+
{t('lookup.allResults')}
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
<button
|
|
840
|
+
id={`${listboxId}-opt-${idx}`}
|
|
841
|
+
data-lookup-index={idx}
|
|
842
|
+
role="option"
|
|
843
|
+
aria-selected={isSelected}
|
|
844
|
+
title={previewOf(option)}
|
|
845
|
+
onClick={() => handleSelect(option)}
|
|
846
|
+
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
|
|
847
|
+
isActive
|
|
848
|
+
? 'bg-accent text-accent-foreground'
|
|
849
|
+
: isSelected
|
|
850
|
+
? 'bg-accent/50 text-accent-foreground'
|
|
851
|
+
: ''
|
|
852
|
+
}`}
|
|
853
|
+
type="button"
|
|
854
|
+
>
|
|
855
|
+
<div className="min-w-0 flex-1">
|
|
856
|
+
<span className="block truncate">{option.label}</span>
|
|
857
|
+
{option.description && (
|
|
858
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
859
|
+
{option.description}
|
|
860
|
+
</span>
|
|
861
|
+
)}
|
|
862
|
+
</div>
|
|
863
|
+
{isSelected && (
|
|
864
|
+
<Badge variant="default" className="ml-2 shrink-0">{t('lookup.selectedBadge')}</Badge>
|
|
865
|
+
)}
|
|
866
|
+
</button>
|
|
867
|
+
</React.Fragment>
|
|
715
868
|
);
|
|
716
869
|
})}
|
|
717
870
|
{/* Show total count when fetched from DataSource */}
|
|
718
871
|
{hasDataSource && totalCount > filteredOptions.length && (
|
|
719
872
|
<p className="text-xs text-muted-foreground text-center py-2">
|
|
720
|
-
|
|
873
|
+
{t('lookup.showingResults', { shown: filteredOptions.length, total: totalCount })}
|
|
721
874
|
</p>
|
|
722
875
|
)}
|
|
723
876
|
{/* "Show All Results" button — opens the full Record Picker (Level 2) */}
|
|
@@ -732,21 +885,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
732
885
|
data-testid="show-all-results"
|
|
733
886
|
>
|
|
734
887
|
<TableProperties className="size-3.5" />
|
|
735
|
-
|
|
888
|
+
{t('lookup.showAllResults', { count: totalCount })}
|
|
736
889
|
</button>
|
|
737
890
|
)}
|
|
738
891
|
{/* Quick-create entry (below results) */}
|
|
739
|
-
{
|
|
892
|
+
{canCreate && (
|
|
740
893
|
<button
|
|
741
894
|
type="button"
|
|
742
895
|
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
setIsOpen(false);
|
|
746
|
-
}}
|
|
896
|
+
disabled={creating}
|
|
897
|
+
onClick={() => handleCreateNew(searchQuery)}
|
|
747
898
|
>
|
|
748
|
-
<Plus className="size-3.5" />
|
|
749
|
-
|
|
899
|
+
{creating ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
|
900
|
+
{searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
|
|
750
901
|
</button>
|
|
751
902
|
)}
|
|
752
903
|
</>
|
|
@@ -764,8 +915,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
764
915
|
className="shrink-0"
|
|
765
916
|
type="button"
|
|
766
917
|
onClick={() => setIsPickerOpen(true)}
|
|
767
|
-
aria-label=
|
|
768
|
-
title=
|
|
918
|
+
aria-label={t('lookup.browseAll')}
|
|
919
|
+
title={t('lookup.browseAll')}
|
|
769
920
|
data-testid="browse-all-records"
|
|
770
921
|
>
|
|
771
922
|
<TableProperties className="size-4" />
|
|
@@ -778,11 +929,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
778
929
|
<RecordPickerDialog
|
|
779
930
|
open={isPickerOpen}
|
|
780
931
|
onOpenChange={setIsPickerOpen}
|
|
781
|
-
title={lookupField?.label || '
|
|
932
|
+
title={lookupField?.label || t('common.select')}
|
|
782
933
|
multiple={multiple}
|
|
783
934
|
dataSource={dataSource}
|
|
784
935
|
objectName={referenceTo}
|
|
785
|
-
columns={
|
|
936
|
+
columns={pickerColumns}
|
|
786
937
|
displayField={displayField}
|
|
787
938
|
titleFormat={refTitleFormat}
|
|
788
939
|
idField={idField}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Heavy markdown renderer, split into its own module so it can be lazy-loaded
|
|
8
|
+
* by the cell/detail renderers (react-markdown pulls in ~100-200 KB). GitHub
|
|
9
|
+
* Flavored Markdown + sanitization (defense against XSS in stored content).
|
|
10
|
+
*/
|
|
11
|
+
export default function MarkdownContent({ value }: { value: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="prose prose-sm max-w-none dark:prose-invert break-words">
|
|
14
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
|
15
|
+
{value}
|
|
16
|
+
</ReactMarkdown>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|