@object-ui/fields 16.0.0 → 17.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +630 -0
- package/README.md +45 -7
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +35 -11
- package/dist/index.js +2241 -1853
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +13 -9
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +11 -10
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +137 -135
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +77 -11
- package/src/widgets/FileField.tsx +91 -52
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +106 -65
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +78 -11
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +69 -9
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +8 -3
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map field type to form component type
|
|
11
|
+
*
|
|
12
|
+
* @param fieldType - The ObjectQL field type identifier to convert
|
|
13
|
+
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
14
|
+
* @returns The normalized form field type string used in the form schema
|
|
15
|
+
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
16
|
+
*/
|
|
17
|
+
export function mapFieldTypeToFormType(fieldType: string): string {
|
|
18
|
+
const typeMap: Record<string, string> = {
|
|
19
|
+
// Text-based fields
|
|
20
|
+
text: 'field:text',
|
|
21
|
+
textarea: 'field:textarea',
|
|
22
|
+
markdown: 'field:markdown', // Markdown editor (fallback to textarea)
|
|
23
|
+
html: 'field:html', // Rich text editor (fallback to textarea)
|
|
24
|
+
richtext: 'field:richtext', // WYSIWYG rich-text editor
|
|
25
|
+
secret: 'field:password', // encrypted-at-rest value — mask input like a password
|
|
26
|
+
|
|
27
|
+
// Numeric fields
|
|
28
|
+
number: 'field:number',
|
|
29
|
+
currency: 'field:currency',
|
|
30
|
+
percent: 'field:percent',
|
|
31
|
+
slider: 'field:slider',
|
|
32
|
+
progress: 'field:slider', // bounded 0..100 progress — edit via slider
|
|
33
|
+
rating: 'field:rating',
|
|
34
|
+
|
|
35
|
+
// Date/Time fields
|
|
36
|
+
date: 'field:date',
|
|
37
|
+
datetime: 'field:datetime',
|
|
38
|
+
time: 'field:time',
|
|
39
|
+
|
|
40
|
+
// Boolean
|
|
41
|
+
boolean: 'field:boolean',
|
|
42
|
+
toggle: 'field:boolean', // toggle is a boolean rendered as a switch
|
|
43
|
+
|
|
44
|
+
// Selection fields
|
|
45
|
+
select: 'field:select',
|
|
46
|
+
multiselect: 'field:multiselect',
|
|
47
|
+
radio: 'field:radio',
|
|
48
|
+
checkboxes: 'field:checkboxes',
|
|
49
|
+
tags: 'field:tags',
|
|
50
|
+
lookup: 'field:lookup',
|
|
51
|
+
master_detail: 'field:master_detail',
|
|
52
|
+
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
|
|
53
|
+
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
|
|
54
|
+
// ownership). Both render via the UserField person-picker (delegates to the
|
|
55
|
+
// lookup picker). Without these they would fall through to `field:text`.
|
|
56
|
+
user: 'field:user',
|
|
57
|
+
owner: 'field:owner',
|
|
58
|
+
|
|
59
|
+
// Contact fields
|
|
60
|
+
email: 'field:email',
|
|
61
|
+
phone: 'field:phone',
|
|
62
|
+
url: 'field:url',
|
|
63
|
+
|
|
64
|
+
// File / media fields
|
|
65
|
+
file: 'field:file',
|
|
66
|
+
image: 'field:image',
|
|
67
|
+
avatar: 'field:avatar',
|
|
68
|
+
video: 'field:file', // uploads as a file
|
|
69
|
+
audio: 'field:file', // uploads as a file
|
|
70
|
+
signature: 'field:signature',
|
|
71
|
+
|
|
72
|
+
// Special / enhanced fields
|
|
73
|
+
password: 'field:password',
|
|
74
|
+
location: 'field:location', // Location/map field (fallback to input)
|
|
75
|
+
geolocation: 'field:geolocation',
|
|
76
|
+
address: 'field:address',
|
|
77
|
+
color: 'field:color',
|
|
78
|
+
code: 'field:code',
|
|
79
|
+
json: 'field:code', // JSON edited in the code editor
|
|
80
|
+
qrcode: 'field:qrcode',
|
|
81
|
+
vector: 'field:vector',
|
|
82
|
+
|
|
83
|
+
// Embedded structured values (stored as JSON on the row)
|
|
84
|
+
object: 'field:object',
|
|
85
|
+
composite: 'field:object', // embedded object
|
|
86
|
+
record: 'field:object', // name-keyed map
|
|
87
|
+
repeater: 'field:grid', // embedded array of rows
|
|
88
|
+
|
|
89
|
+
// Auto-generated/computed fields (typically read-only)
|
|
90
|
+
formula: 'field:formula',
|
|
91
|
+
summary: 'field:summary',
|
|
92
|
+
// `@objectstack/spec` spells this `autonumber`; the widget map key is
|
|
93
|
+
// `auto_number`. Map both spellings so a spec-typed field/param doesn't
|
|
94
|
+
// fall through to the plain text input.
|
|
95
|
+
autonumber: 'field:auto_number',
|
|
96
|
+
auto_number: 'field:auto_number',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return typeMap[fieldType] || 'field:text';
|
|
100
|
+
}
|
|
@@ -27,7 +27,9 @@ const FORM_WIDGET_TYPES = [
|
|
|
27
27
|
'file', 'image', 'avatar', 'video', 'audio', 'signature',
|
|
28
28
|
'location', 'geolocation', 'address', 'color', 'code', 'json', 'qrcode', 'vector',
|
|
29
29
|
'object', 'composite', 'record', 'repeater',
|
|
30
|
-
|
|
30
|
+
// `autonumber` is the spec `FieldType` spelling; `auto_number` is the widget
|
|
31
|
+
// map key. Both must resolve to the AutoNumber widget, not text.
|
|
32
|
+
'formula', 'summary', 'auto_number', 'autonumber',
|
|
31
33
|
];
|
|
32
34
|
|
|
33
35
|
/**
|
package/src/index.tsx
CHANGED
|
@@ -277,12 +277,11 @@ function useFieldLabel() {
|
|
|
277
277
|
* fallback in that case.
|
|
278
278
|
*/
|
|
279
279
|
function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) => string) | undefined {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
}
|
|
280
|
+
// useObjectTranslation is provider-safe (optional context read; react-i18next
|
|
281
|
+
// falls back to the global instance), so it never throws — no try/catch, which
|
|
282
|
+
// would wrap a hook call and violate rules-of-hooks. Mirrors useFieldLabel.
|
|
283
|
+
const { t } = useObjectTranslation();
|
|
284
|
+
return t as (key: string, params?: Record<string, unknown>) => string;
|
|
286
285
|
}
|
|
287
286
|
|
|
288
287
|
import { TextField } from './widgets/TextField';
|
|
@@ -304,6 +303,8 @@ import { PercentField } from './widgets/PercentField';
|
|
|
304
303
|
import { PasswordField } from './widgets/PasswordField';
|
|
305
304
|
import { FileField } from './widgets/FileField';
|
|
306
305
|
import { ImageField } from './widgets/ImageField';
|
|
306
|
+
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
307
|
+
import { readFileValues } from './widgets/file-value';
|
|
307
308
|
import { LocationField } from './widgets/LocationField';
|
|
308
309
|
import { FormulaField } from './widgets/FormulaField';
|
|
309
310
|
import { SummaryField } from './widgets/SummaryField';
|
|
@@ -507,7 +508,7 @@ function formatRelativeDays(diffDays: number, locale?: string): string {
|
|
|
507
508
|
|
|
508
509
|
/**
|
|
509
510
|
* Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
|
|
510
|
-
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/
|
|
511
|
+
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
|
|
511
512
|
*
|
|
512
513
|
* `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
|
|
513
514
|
* isn't overdue, only a past due/deadline-semantic field is. Non-due-like
|
|
@@ -1269,50 +1270,88 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
1269
1270
|
}
|
|
1270
1271
|
|
|
1271
1272
|
/**
|
|
1272
|
-
* Image field cell renderer (with thumbnails)
|
|
1273
|
+
* Image field cell renderer (with thumbnails + click-to-zoom).
|
|
1274
|
+
*
|
|
1275
|
+
* An image value may be a plain URL string, an object ({ url | src | href … }),
|
|
1276
|
+
* a bare `sys_file` id, or an array of any of those. Normalising through
|
|
1277
|
+
* `readFileValues` (which resolves a bare id to its stable download URL) means
|
|
1278
|
+
* a string-URL field, a CDN link, and an unexpanded reference all render a
|
|
1279
|
+
* thumbnail instead of a broken `<img src="">` placeholder.
|
|
1280
|
+
*
|
|
1281
|
+
* Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
|
|
1282
|
+
* click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
|
|
1283
|
+
* doesn't also trigger row navigation.
|
|
1273
1284
|
*/
|
|
1274
1285
|
export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1275
|
-
|
|
1286
|
+
const { t } = useObjectTranslation();
|
|
1287
|
+
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
|
1288
|
+
|
|
1289
|
+
const imgs = React.useMemo(
|
|
1290
|
+
() =>
|
|
1291
|
+
readFileValues(value, 'Image')
|
|
1292
|
+
.filter((v) => v.url)
|
|
1293
|
+
.map((v) => ({ url: v.url as string, name: v.name })),
|
|
1294
|
+
[value],
|
|
1295
|
+
);
|
|
1276
1296
|
|
|
1277
|
-
|
|
1278
|
-
// or an array of either. Normalize so a string-URL field (common — e.g. a
|
|
1279
|
-
// `cover` seeded with a CDN link) renders a thumbnail instead of a broken
|
|
1280
|
-
// <img src=""> placeholder.
|
|
1281
|
-
const urlOf = (v: any): string =>
|
|
1282
|
-
typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
|
|
1283
|
-
const nameOf = (v: any, fallback: string): string =>
|
|
1284
|
-
(v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
|
|
1297
|
+
if (!value || imgs.length === 0) return <EmptyValue />;
|
|
1285
1298
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1299
|
+
const imageAlt = (idx: number, name?: string) =>
|
|
1300
|
+
name || t('fields.image.imageAlt', { index: idx + 1 });
|
|
1301
|
+
const open = (idx: number) => (e: React.MouseEvent) => {
|
|
1302
|
+
e.stopPropagation();
|
|
1303
|
+
setLightboxIndex(idx);
|
|
1304
|
+
};
|
|
1305
|
+
const multiple = imgs.length > 1;
|
|
1306
|
+
|
|
1307
|
+
const lightbox = lightboxIndex !== null && (
|
|
1308
|
+
<ImageLightbox
|
|
1309
|
+
images={imgs}
|
|
1310
|
+
index={lightboxIndex}
|
|
1311
|
+
open
|
|
1312
|
+
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
1313
|
+
onIndexChange={setLightboxIndex}
|
|
1314
|
+
/>
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
if (multiple) {
|
|
1289
1318
|
return (
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1319
|
+
<>
|
|
1320
|
+
<div className="flex -space-x-2">
|
|
1321
|
+
{imgs.slice(0, 3).map((img, idx) => (
|
|
1322
|
+
<img
|
|
1323
|
+
key={idx}
|
|
1324
|
+
src={img.url}
|
|
1325
|
+
alt={imageAlt(idx, img.name)}
|
|
1326
|
+
onClick={open(idx)}
|
|
1327
|
+
className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
|
|
1328
|
+
/>
|
|
1329
|
+
))}
|
|
1330
|
+
{imgs.length > 3 && (
|
|
1331
|
+
<button
|
|
1332
|
+
type="button"
|
|
1333
|
+
onClick={open(3)}
|
|
1334
|
+
className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
|
|
1335
|
+
>
|
|
1336
|
+
+{imgs.length - 3}
|
|
1337
|
+
</button>
|
|
1338
|
+
)}
|
|
1339
|
+
</div>
|
|
1340
|
+
{lightbox}
|
|
1341
|
+
</>
|
|
1305
1342
|
);
|
|
1306
1343
|
}
|
|
1307
1344
|
|
|
1308
|
-
const url = urlOf(value);
|
|
1309
|
-
if (!url) return <EmptyValue />;
|
|
1310
1345
|
return (
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1346
|
+
<>
|
|
1347
|
+
<img
|
|
1348
|
+
src={imgs[0].url}
|
|
1349
|
+
alt={imageAlt(0, imgs[0].name)}
|
|
1350
|
+
onClick={open(0)}
|
|
1351
|
+
className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
|
|
1352
|
+
/>
|
|
1353
|
+
{lightbox}
|
|
1354
|
+
</>
|
|
1316
1355
|
);
|
|
1317
1356
|
}
|
|
1318
1357
|
|
|
@@ -1824,94 +1863,10 @@ setCellRendererResolver(getCellRenderer);
|
|
|
1824
1863
|
|
|
1825
1864
|
|
|
1826
1865
|
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
1832
|
-
* @returns The normalized form field type string used in the form schema
|
|
1833
|
-
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
1834
|
-
*/
|
|
1835
|
-
export function mapFieldTypeToFormType(fieldType: string): string {
|
|
1836
|
-
const typeMap: Record<string, string> = {
|
|
1837
|
-
// Text-based fields
|
|
1838
|
-
text: 'field:text',
|
|
1839
|
-
textarea: 'field:textarea',
|
|
1840
|
-
markdown: 'field:markdown', // Markdown editor (fallback to textarea)
|
|
1841
|
-
html: 'field:html', // Rich text editor (fallback to textarea)
|
|
1842
|
-
richtext: 'field:richtext', // WYSIWYG rich-text editor
|
|
1843
|
-
secret: 'field:password', // encrypted-at-rest value — mask input like a password
|
|
1844
|
-
|
|
1845
|
-
// Numeric fields
|
|
1846
|
-
number: 'field:number',
|
|
1847
|
-
currency: 'field:currency',
|
|
1848
|
-
percent: 'field:percent',
|
|
1849
|
-
slider: 'field:slider',
|
|
1850
|
-
progress: 'field:slider', // bounded 0..100 progress — edit via slider
|
|
1851
|
-
rating: 'field:rating',
|
|
1852
|
-
|
|
1853
|
-
// Date/Time fields
|
|
1854
|
-
date: 'field:date',
|
|
1855
|
-
datetime: 'field:datetime',
|
|
1856
|
-
time: 'field:time',
|
|
1857
|
-
|
|
1858
|
-
// Boolean
|
|
1859
|
-
boolean: 'field:boolean',
|
|
1860
|
-
toggle: 'field:boolean', // toggle is a boolean rendered as a switch
|
|
1861
|
-
|
|
1862
|
-
// Selection fields
|
|
1863
|
-
select: 'field:select',
|
|
1864
|
-
multiselect: 'field:multiselect',
|
|
1865
|
-
radio: 'field:radio',
|
|
1866
|
-
checkboxes: 'field:checkboxes',
|
|
1867
|
-
tags: 'field:tags',
|
|
1868
|
-
lookup: 'field:lookup',
|
|
1869
|
-
master_detail: 'field:master_detail',
|
|
1870
|
-
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
|
|
1871
|
-
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
|
|
1872
|
-
// ownership). Both render via the UserField person-picker (delegates to the
|
|
1873
|
-
// lookup picker). Without these they would fall through to `field:text`.
|
|
1874
|
-
user: 'field:user',
|
|
1875
|
-
owner: 'field:owner',
|
|
1876
|
-
|
|
1877
|
-
// Contact fields
|
|
1878
|
-
email: 'field:email',
|
|
1879
|
-
phone: 'field:phone',
|
|
1880
|
-
url: 'field:url',
|
|
1881
|
-
|
|
1882
|
-
// File / media fields
|
|
1883
|
-
file: 'field:file',
|
|
1884
|
-
image: 'field:image',
|
|
1885
|
-
avatar: 'field:avatar',
|
|
1886
|
-
video: 'field:file', // uploads as a file
|
|
1887
|
-
audio: 'field:file', // uploads as a file
|
|
1888
|
-
signature: 'field:signature',
|
|
1889
|
-
|
|
1890
|
-
// Special / enhanced fields
|
|
1891
|
-
password: 'field:password',
|
|
1892
|
-
location: 'field:location', // Location/map field (fallback to input)
|
|
1893
|
-
geolocation: 'field:geolocation',
|
|
1894
|
-
address: 'field:address',
|
|
1895
|
-
color: 'field:color',
|
|
1896
|
-
code: 'field:code',
|
|
1897
|
-
json: 'field:code', // JSON edited in the code editor
|
|
1898
|
-
qrcode: 'field:qrcode',
|
|
1899
|
-
vector: 'field:vector',
|
|
1900
|
-
|
|
1901
|
-
// Embedded structured values (stored as JSON on the row)
|
|
1902
|
-
object: 'field:object',
|
|
1903
|
-
composite: 'field:object', // embedded object
|
|
1904
|
-
record: 'field:object', // name-keyed map
|
|
1905
|
-
repeater: 'field:grid', // embedded array of rows
|
|
1906
|
-
|
|
1907
|
-
// Auto-generated/computed fields (typically read-only)
|
|
1908
|
-
formula: 'field:formula',
|
|
1909
|
-
summary: 'field:summary',
|
|
1910
|
-
auto_number: 'field:auto_number',
|
|
1911
|
-
};
|
|
1912
|
-
|
|
1913
|
-
return typeMap[fieldType] || 'field:text';
|
|
1914
|
-
}
|
|
1866
|
+
// `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
|
|
1867
|
+
// FieldEditWidget can resolve spec aliases without importing this barrel.
|
|
1868
|
+
export { mapFieldTypeToFormType } from './field-type-alias';
|
|
1869
|
+
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
1915
1870
|
|
|
1916
1871
|
/**
|
|
1917
1872
|
* Formats file size in bytes to human-readable string
|
|
@@ -1963,18 +1918,23 @@ export function buildValidationRules(field: any): any {
|
|
|
1963
1918
|
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1964
1919
|
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1965
1920
|
|
|
1966
|
-
// Length validation for text fields
|
|
1967
|
-
|
|
1921
|
+
// Length validation for text fields. The spec-canonical keys are camelCase
|
|
1922
|
+
// (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
|
|
1923
|
+
// record-validator enforces); the snake_case pair is the legacy objectui
|
|
1924
|
+
// spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
|
|
1925
|
+
const minLength = (field as any).minLength ?? field.min_length;
|
|
1926
|
+
if (minLength) {
|
|
1968
1927
|
rules.minLength = {
|
|
1969
|
-
value:
|
|
1928
|
+
value: minLength,
|
|
1970
1929
|
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1971
1930
|
messageKey: 'validation.minLength',
|
|
1972
1931
|
};
|
|
1973
1932
|
}
|
|
1974
1933
|
|
|
1975
|
-
|
|
1934
|
+
const maxLength = (field as any).maxLength ?? field.max_length;
|
|
1935
|
+
if (maxLength) {
|
|
1976
1936
|
rules.maxLength = {
|
|
1977
|
-
value:
|
|
1937
|
+
value: maxLength,
|
|
1978
1938
|
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1979
1939
|
messageKey: 'validation.maxLength',
|
|
1980
1940
|
};
|
|
@@ -2222,6 +2182,44 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
|
|
|
2222
2182
|
*/
|
|
2223
2183
|
export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
|
|
2224
2184
|
|
|
2185
|
+
/**
|
|
2186
|
+
* Resolve an arbitrary field-type spelling to the widget key the form uses.
|
|
2187
|
+
* A key already present in the widget map resolves to itself; spec aliases
|
|
2188
|
+
* (`toggle`, `json`, `repeater`, `secret`, …) resolve through
|
|
2189
|
+
* {@link mapFieldTypeToFormType}; anything unknown falls back to `text` —
|
|
2190
|
+
* the same fallback the form renderer applies.
|
|
2191
|
+
*
|
|
2192
|
+
* Consumers that render field widgets outside the form (e.g. the app-shell
|
|
2193
|
+
* `ActionParamDialog`) use this + {@link getLazyFieldWidget} so their type
|
|
2194
|
+
* support can never drift behind the form surface (ADR-0059).
|
|
2195
|
+
*/
|
|
2196
|
+
export function resolveFormWidgetType(fieldType: string): string {
|
|
2197
|
+
if (fieldWidgetMap[fieldType]) return fieldType;
|
|
2198
|
+
const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
|
|
2199
|
+
return fieldWidgetMap[mapped] ? mapped : 'text';
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** Cache so each widget type creates one lazy component (and one chunk request). */
|
|
2203
|
+
const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Lazily-loaded form field widget for a field type. Shares the exact loaders
|
|
2207
|
+
* of {@link fieldWidgetMap} (the same components `registerField` registers for
|
|
2208
|
+
* forms), wrapped in `React.lazy` and cached per type — so a consumer can
|
|
2209
|
+
* render any form-supported field type without eagerly bundling every widget.
|
|
2210
|
+
* Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
|
|
2211
|
+
* widget via {@link resolveFormWidgetType}.
|
|
2212
|
+
*/
|
|
2213
|
+
export function getLazyFieldWidget(fieldType: string): React.ComponentType<any> {
|
|
2214
|
+
const key = resolveFormWidgetType(fieldType);
|
|
2215
|
+
let Widget = lazyFieldWidgets.get(key);
|
|
2216
|
+
if (!Widget) {
|
|
2217
|
+
Widget = React.lazy(fieldWidgetMap[key]);
|
|
2218
|
+
lazyFieldWidgets.set(key, Widget);
|
|
2219
|
+
}
|
|
2220
|
+
return Widget;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2225
2223
|
/**
|
|
2226
2224
|
* Register a specific field type lazily
|
|
2227
2225
|
* @param fieldType - The field type to register (e.g., 'text', 'number')
|
|
@@ -2397,6 +2395,10 @@ export function registerFields() {
|
|
|
2397
2395
|
}
|
|
2398
2396
|
|
|
2399
2397
|
export * from './widgets/types';
|
|
2398
|
+
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
2399
|
+
// arbiter of reference vs expanded vs legacy-blob form, shared by the upload
|
|
2400
|
+
// widgets and by action-param serialization.
|
|
2401
|
+
export * from './widgets/file-value';
|
|
2400
2402
|
export * from './FieldEditWidget';
|
|
2401
2403
|
export * from './widgets/TextField';
|
|
2402
2404
|
export * from './widgets/NumberField';
|
|
@@ -213,6 +213,50 @@ describe('Standard Field Widgets', () => {
|
|
|
213
213
|
);
|
|
214
214
|
expect(container.textContent).toBe('Option A');
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
// A `select` declared `multiple` is a multi-value picker (spec allows
|
|
218
|
+
// `multiple` on select). SelectField delegates to the chip picker so the
|
|
219
|
+
// form, inline editor, and ActionParamDialog all get multi-select from the
|
|
220
|
+
// one `select` widget — no single-select fallback, no per-surface drift.
|
|
221
|
+
it('renders the multi-value chip picker when field.multiple is set', () => {
|
|
222
|
+
render(
|
|
223
|
+
<SelectField
|
|
224
|
+
value={['a']}
|
|
225
|
+
onChange={mockOnChange}
|
|
226
|
+
field={{ ...fieldMock, multiple: true } as any}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
// Chip picker uses toggle buttons (aria-pressed), not a Radix combobox.
|
|
230
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
231
|
+
const optionA = screen.getByRole('button', { name: 'Option A' });
|
|
232
|
+
expect(optionA).toHaveAttribute('aria-pressed', 'true');
|
|
233
|
+
expect(screen.getByRole('button', { name: 'Option B' })).toHaveAttribute('aria-pressed', 'false');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('multi-select toggles values as an array', () => {
|
|
237
|
+
render(
|
|
238
|
+
<SelectField
|
|
239
|
+
value={['a']}
|
|
240
|
+
onChange={mockOnChange}
|
|
241
|
+
field={{ ...fieldMock, multiple: true } as any}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
fireEvent.click(screen.getByRole('button', { name: 'Option B' }));
|
|
245
|
+
expect(mockOnChange).toHaveBeenCalledWith(['a', 'b']);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('multi-select renders selected values as badges in readonly mode', () => {
|
|
249
|
+
const { container } = render(
|
|
250
|
+
<SelectField
|
|
251
|
+
value={['a', 'b']}
|
|
252
|
+
onChange={mockOnChange}
|
|
253
|
+
field={{ ...fieldMock, multiple: true } as any}
|
|
254
|
+
readonly={true}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
expect(container.textContent).toContain('Option A');
|
|
258
|
+
expect(container.textContent).toContain('Option B');
|
|
259
|
+
});
|
|
216
260
|
});
|
|
217
261
|
|
|
218
262
|
describe('BooleanField', () => {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cascading / role-gated `checkboxes` options (#2715 follow-up) — parity with
|
|
11
|
+
* `MultiSelectField` (ADR-0058 / #2284).
|
|
12
|
+
*
|
|
13
|
+
* Exercises the observable widget behaviour: the dependency gate (a "select the
|
|
14
|
+
* parent first" empty-state) and the per-element cascade clear — a selection
|
|
15
|
+
* dropped from the array when the offered set no longer includes it, while
|
|
16
|
+
* still-valid selections are kept. The per-option filtering itself is unit-tested
|
|
17
|
+
* in `@object-ui/core` (`optionRules.test.ts`); here we prove the checkbox list
|
|
18
|
+
* wires `dependentValues` + predicate scope into that resolver via the shared
|
|
19
|
+
* `useCascadingOptions` hook.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { render, screen } from '@testing-library/react';
|
|
24
|
+
import '@testing-library/jest-dom';
|
|
25
|
+
import { PredicateScopeProvider } from '@object-ui/react';
|
|
26
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
27
|
+
|
|
28
|
+
const provinceField = {
|
|
29
|
+
name: 'provinces',
|
|
30
|
+
type: 'checkboxes',
|
|
31
|
+
dependsOn: 'country',
|
|
32
|
+
options: [
|
|
33
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
34
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
35
|
+
],
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('CheckboxesField — dependency gating (#2715)', () => {
|
|
43
|
+
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
44
|
+
render(
|
|
45
|
+
<CheckboxesField
|
|
46
|
+
value={[]}
|
|
47
|
+
onChange={vi.fn()}
|
|
48
|
+
field={provinceField}
|
|
49
|
+
{...({ name: 'provinces', dependentValues: {} } as any)}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
const gate = screen.getByTestId('checkboxes-empty-provinces');
|
|
53
|
+
expect(gate).toHaveTextContent(/select country first/i);
|
|
54
|
+
// Gated → no checkboxes offered.
|
|
55
|
+
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('unlocks the checkboxes once the controlling field is set', () => {
|
|
59
|
+
render(
|
|
60
|
+
<CheckboxesField
|
|
61
|
+
value={[]}
|
|
62
|
+
onChange={vi.fn()}
|
|
63
|
+
field={provinceField}
|
|
64
|
+
{...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
expect(screen.queryByTestId('checkboxes-empty-provinces')).not.toBeInTheDocument();
|
|
68
|
+
// Only the `cn` option is offered.
|
|
69
|
+
expect(screen.getByRole('checkbox', { name: 'Zhejiang' })).toBeInTheDocument();
|
|
70
|
+
expect(screen.queryByRole('checkbox', { name: 'California' })).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('CheckboxesField — cascade clear (#2715)', () => {
|
|
75
|
+
it('drops only the selections the parent change no longer offers', () => {
|
|
76
|
+
const onChange = vi.fn();
|
|
77
|
+
render(
|
|
78
|
+
<CheckboxesField
|
|
79
|
+
value={['zj', 'ca']}
|
|
80
|
+
onChange={onChange}
|
|
81
|
+
field={provinceField}
|
|
82
|
+
{...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
|
|
83
|
+
/>,
|
|
84
|
+
);
|
|
85
|
+
// 'ca' is a US province — under country=cn it is not offered, so it is
|
|
86
|
+
// pruned; the still-valid 'zj' is kept.
|
|
87
|
+
expect(onChange).toHaveBeenCalledWith(['zj']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps selections that are all still offered', () => {
|
|
91
|
+
const onChange = vi.fn();
|
|
92
|
+
render(
|
|
93
|
+
<CheckboxesField
|
|
94
|
+
value={['zj']}
|
|
95
|
+
onChange={onChange}
|
|
96
|
+
field={provinceField}
|
|
97
|
+
{...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('CheckboxesField — role / context gating (#2715)', () => {
|
|
105
|
+
const tierField = {
|
|
106
|
+
name: 'tiers',
|
|
107
|
+
type: 'checkboxes',
|
|
108
|
+
options: [
|
|
109
|
+
{ label: 'Standard', value: 'standard' },
|
|
110
|
+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
111
|
+
],
|
|
112
|
+
} as any;
|
|
113
|
+
|
|
114
|
+
it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
115
|
+
const onChange = vi.fn();
|
|
116
|
+
render(
|
|
117
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
118
|
+
<CheckboxesField
|
|
119
|
+
value={['standard', 'admin_only']}
|
|
120
|
+
onChange={onChange}
|
|
121
|
+
field={tierField}
|
|
122
|
+
{...({ name: 'tiers', dependentValues: {} } as any)}
|
|
123
|
+
/>
|
|
124
|
+
</PredicateScopeProvider>,
|
|
125
|
+
);
|
|
126
|
+
expect(onChange).toHaveBeenCalledWith(['standard']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('keeps an admin-only value for an admin', () => {
|
|
130
|
+
const onChange = vi.fn();
|
|
131
|
+
render(
|
|
132
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
133
|
+
<CheckboxesField
|
|
134
|
+
value={['standard', 'admin_only']}
|
|
135
|
+
onChange={onChange}
|
|
136
|
+
field={tierField}
|
|
137
|
+
{...({ name: 'tiers', dependentValues: {} } as any)}
|
|
138
|
+
/>
|
|
139
|
+
</PredicateScopeProvider>,
|
|
140
|
+
);
|
|
141
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|