@object-ui/fields 16.0.0 → 16.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 +198 -0
- package/README.md +45 -7
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1754 -1694
- 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/ImageField.d.ts +1 -1
- 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/SelectField.d.ts +13 -9
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +9 -9
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +47 -6
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +70 -7
- package/src/widgets/FileField.tsx +9 -1
- package/src/widgets/ImageField.tsx +45 -42
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +72 -8
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +64 -7
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +4 -2
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
File without changes
|
|
@@ -4,5 +4,12 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* CheckboxesField - select zero or more values from a fixed option set, rendered
|
|
5
5
|
* as a list of checkboxes. The stored value is a string[]. Used for the
|
|
6
6
|
* `checkboxes` field type.
|
|
7
|
+
*
|
|
8
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
9
|
+
* gating as `MultiSelectField` (ADR-0058 / #2715), resolved through the shared
|
|
10
|
+
* {@link useCascadingOptions} hook: the offered boxes narrow against the live
|
|
11
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
12
|
+
* first" hint while a dependency is empty, and selections no longer offered
|
|
13
|
+
* (parent changed / predicate flipped) are pruned from the array.
|
|
7
14
|
*/
|
|
8
|
-
export declare function CheckboxesField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
15
|
+
export declare function CheckboxesField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
@@ -5,7 +5,7 @@ import { FieldWidgetProps } from './types';
|
|
|
5
5
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
6
6
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
7
7
|
*/
|
|
8
|
-
export declare function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
8
|
+
export declare function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
9
9
|
/**
|
|
10
10
|
* FileCell — compact upload control for a line-item grid cell (objectui#2360).
|
|
11
11
|
*
|
|
@@ -4,4 +4,4 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* ImageField - Image upload widget with preview thumbnails
|
|
5
5
|
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
6
6
|
*/
|
|
7
|
-
export declare function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
7
|
+
export declare function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
File without changes
|
|
@@ -4,6 +4,14 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* MultiSelectField - select zero or more values from a fixed option set.
|
|
5
5
|
*
|
|
6
6
|
* Renders the configured options as toggleable chips. The stored value is a
|
|
7
|
-
* string[] (the selected option values). Used for the `multiselect` field type
|
|
7
|
+
* string[] (the selected option values). Used for the `multiselect` field type
|
|
8
|
+
* and for a `select` field declared `multiple: true`.
|
|
9
|
+
*
|
|
10
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
11
|
+
* gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
|
|
12
|
+
* shared {@link useCascadingOptions} hook: the offered chips narrow against the
|
|
13
|
+
* live form record + `current_user`, the control is gated behind a "select the
|
|
14
|
+
* parent first" hint while a dependency is empty, and selections no longer
|
|
15
|
+
* offered (parent changed / predicate flipped) are dropped from the array.
|
|
8
16
|
*/
|
|
9
|
-
export declare function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
17
|
+
export declare function MultiSelectField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
File without changes
|
|
@@ -4,5 +4,12 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* RadioField - choose exactly one value from a fixed option set, rendered as a
|
|
5
5
|
* radio group. The stored value is the selected option value (string). Used for
|
|
6
6
|
* the `radio` field type.
|
|
7
|
+
*
|
|
8
|
+
* Like the single `SelectField`, options support per-option `visibleWhen`
|
|
9
|
+
* cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
|
|
10
|
+
* {@link useCascadingOptions} hook: the offered radios narrow against the live
|
|
11
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
12
|
+
* first" hint while a dependency is empty, and a value no longer offered (parent
|
|
13
|
+
* changed / predicate flipped) is cleared.
|
|
7
14
|
*/
|
|
8
|
-
export declare function RadioField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
15
|
+
export declare function RadioField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
import { FieldWidgetProps } from './types';
|
|
3
3
|
/**
|
|
4
|
-
* SelectField -
|
|
4
|
+
* SelectField - dropdown selection widget.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* A field declared `multiple: true` selects zero-or-more values (spec:
|
|
7
|
+
* `multiple` is valid on `select`), so it renders the multi-value chip picker
|
|
8
|
+
* — the same widget the `multiselect` type uses. Delegating here (rather than
|
|
9
|
+
* only at a type-resolution layer) means every surface that renders the
|
|
10
|
+
* `select` widget — the object form, the inline grid editor, and
|
|
11
|
+
* `ActionParamDialog` — inherits multi-select identically, with no drift
|
|
12
|
+
* between them. Single-value selects keep the cascading dropdown below.
|
|
13
|
+
*
|
|
14
|
+
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
|
|
15
|
+
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
16
|
+
* in lockstep.
|
|
13
17
|
*/
|
|
14
|
-
export declare function SelectField(
|
|
18
|
+
export declare function SelectField(props: FieldWidgetProps<any>): React.JSX.Element;
|
package/dist/widgets/types.d.ts
CHANGED
|
@@ -7,5 +7,11 @@ export type FieldWidgetProps<T = any> = {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
className?: string;
|
|
9
9
|
errorMessage?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Upload widgets (`file`/`image`) fire this when their in-progress state
|
|
12
|
+
* flips, so a host can block submit until a presigned upload settles. Other
|
|
13
|
+
* widgets ignore it.
|
|
14
|
+
*/
|
|
15
|
+
onUploadingChange?: (uploading: boolean) => void;
|
|
10
16
|
[key: string]: any;
|
|
11
17
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CascadingOptions, OptionLike, DependsOnInput } from '@object-ui/core';
|
|
2
|
+
/**
|
|
3
|
+
* Shared per-option cascading / role-gating resolution for the option widgets
|
|
4
|
+
* (`SelectField` single, `MultiSelectField`, `RadioField`) — the client half of
|
|
5
|
+
* ADR-0058 (#2284). Each option may carry a `visibleWhen` CEL predicate; the
|
|
6
|
+
* offered set narrows against the live form record (`record.country == 'cn'`) +
|
|
7
|
+
* the global predicate scope (`'admin' in current_user.positions`). A field
|
|
8
|
+
* declares which sibling fields drive its list via `dependsOn`; while any is
|
|
9
|
+
* empty the list is *gated* — callers surface a "select the parent first" hint
|
|
10
|
+
* rather than an unfiltered set, mirroring the dependent-lookup UX.
|
|
11
|
+
*
|
|
12
|
+
* This is the React wrapper that sources `record` (the live form values) and the
|
|
13
|
+
* predicate scope from context; the actual resolution is the pure
|
|
14
|
+
* {@link resolveCascadingOptions} in `@object-ui/core`, shared with the form
|
|
15
|
+
* renderer so gating/filtering can never drift between them (#2715).
|
|
16
|
+
*/
|
|
17
|
+
export type CascadingOptionsResult<T extends OptionLike> = CascadingOptions<T>;
|
|
18
|
+
export declare function useCascadingOptions<T extends OptionLike>(rawOptions: readonly T[], dependsOn: DependsOnInput, dependentValues: Record<string, unknown> | undefined): CascadingOptionsResult<T>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notify a parent whenever an upload widget's in-progress state flips. Used by
|
|
3
|
+
* hosts that must block a submit until the presigned upload resolves — e.g.
|
|
4
|
+
* `ActionParamDialog` disables Confirm so a `file`/`image` param can't be
|
|
5
|
+
* submitted mid-upload (the value only becomes the fileId once the upload
|
|
6
|
+
* settles). Fires with the latest callback and only when `uploading` actually
|
|
7
|
+
* changes, so an inline arrow prop doesn't thrash the parent every render.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useUploadingSignal(uploading: boolean, onUploadingChange?: (uploading: boolean) => void): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.1.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
28
|
+
"lucide-react": "^1.25.0",
|
|
29
29
|
"react-markdown": "^10.1.0",
|
|
30
30
|
"rehype-sanitize": "^6.0.0",
|
|
31
31
|
"remark-gfm": "^4.0.1",
|
|
32
32
|
"tailwind-merge": "^3.6.0",
|
|
33
|
-
"@object-ui/components": "16.
|
|
34
|
-
"@object-ui/core": "16.
|
|
35
|
-
"@object-ui/i18n": "16.
|
|
36
|
-
"@object-ui/providers": "16.
|
|
37
|
-
"@object-ui/react": "16.
|
|
38
|
-
"@object-ui/types": "16.
|
|
33
|
+
"@object-ui/components": "16.1.0",
|
|
34
|
+
"@object-ui/core": "16.1.0",
|
|
35
|
+
"@object-ui/i18n": "16.1.0",
|
|
36
|
+
"@object-ui/providers": "16.1.0",
|
|
37
|
+
"@object-ui/react": "16.1.0",
|
|
38
|
+
"@object-ui/types": "16.1.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@types/react-dom": "19.2.3",
|
|
47
47
|
"@vitejs/plugin-react": "^6.0.3",
|
|
48
48
|
"typescript": "^6.0.3",
|
|
49
|
-
"vite": "^8.1.
|
|
49
|
+
"vite": "^8.1.5",
|
|
50
50
|
"vite-plugin-dts": "^5.0.3"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
|
@@ -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';
|
|
@@ -1907,6 +1906,10 @@ export function mapFieldTypeToFormType(fieldType: string): string {
|
|
|
1907
1906
|
// Auto-generated/computed fields (typically read-only)
|
|
1908
1907
|
formula: 'field:formula',
|
|
1909
1908
|
summary: 'field:summary',
|
|
1909
|
+
// `@objectstack/spec` spells this `autonumber`; the widget map key is
|
|
1910
|
+
// `auto_number`. Map both spellings so a spec-typed field/param doesn't
|
|
1911
|
+
// fall through to the plain text input.
|
|
1912
|
+
autonumber: 'field:auto_number',
|
|
1910
1913
|
auto_number: 'field:auto_number',
|
|
1911
1914
|
};
|
|
1912
1915
|
|
|
@@ -2222,6 +2225,44 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
|
|
|
2222
2225
|
*/
|
|
2223
2226
|
export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
|
|
2224
2227
|
|
|
2228
|
+
/**
|
|
2229
|
+
* Resolve an arbitrary field-type spelling to the widget key the form uses.
|
|
2230
|
+
* A key already present in the widget map resolves to itself; spec aliases
|
|
2231
|
+
* (`toggle`, `json`, `repeater`, `secret`, …) resolve through
|
|
2232
|
+
* {@link mapFieldTypeToFormType}; anything unknown falls back to `text` —
|
|
2233
|
+
* the same fallback the form renderer applies.
|
|
2234
|
+
*
|
|
2235
|
+
* Consumers that render field widgets outside the form (e.g. the app-shell
|
|
2236
|
+
* `ActionParamDialog`) use this + {@link getLazyFieldWidget} so their type
|
|
2237
|
+
* support can never drift behind the form surface (ADR-0059).
|
|
2238
|
+
*/
|
|
2239
|
+
export function resolveFormWidgetType(fieldType: string): string {
|
|
2240
|
+
if (fieldWidgetMap[fieldType]) return fieldType;
|
|
2241
|
+
const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
|
|
2242
|
+
return fieldWidgetMap[mapped] ? mapped : 'text';
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** Cache so each widget type creates one lazy component (and one chunk request). */
|
|
2246
|
+
const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Lazily-loaded form field widget for a field type. Shares the exact loaders
|
|
2250
|
+
* of {@link fieldWidgetMap} (the same components `registerField` registers for
|
|
2251
|
+
* forms), wrapped in `React.lazy` and cached per type — so a consumer can
|
|
2252
|
+
* render any form-supported field type without eagerly bundling every widget.
|
|
2253
|
+
* Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
|
|
2254
|
+
* widget via {@link resolveFormWidgetType}.
|
|
2255
|
+
*/
|
|
2256
|
+
export function getLazyFieldWidget(fieldType: string): React.ComponentType<any> {
|
|
2257
|
+
const key = resolveFormWidgetType(fieldType);
|
|
2258
|
+
let Widget = lazyFieldWidgets.get(key);
|
|
2259
|
+
if (!Widget) {
|
|
2260
|
+
Widget = React.lazy(fieldWidgetMap[key]);
|
|
2261
|
+
lazyFieldWidgets.set(key, Widget);
|
|
2262
|
+
}
|
|
2263
|
+
return Widget;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2225
2266
|
/**
|
|
2226
2267
|
* Register a specific field type lazily
|
|
2227
2268
|
* @param fieldType - The field type to register (e.g., 'text', 'number')
|
|
@@ -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
|
+
});
|
|
@@ -1,39 +1,101 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
1
|
+
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
|
|
3
|
+
import type { OptionLike } from '@object-ui/core';
|
|
3
4
|
import { FieldWidgetProps } from './types';
|
|
5
|
+
import { useCascadingOptions } from './useCascadingOptions';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
type Option = OptionLike;
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* CheckboxesField - select zero or more values from a fixed option set, rendered
|
|
9
11
|
* as a list of checkboxes. The stored value is a string[]. Used for the
|
|
10
12
|
* `checkboxes` field type.
|
|
13
|
+
*
|
|
14
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
15
|
+
* gating as `MultiSelectField` (ADR-0058 / #2715), resolved through the shared
|
|
16
|
+
* {@link useCascadingOptions} hook: the offered boxes narrow against the live
|
|
17
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
18
|
+
* first" hint while a dependency is empty, and selections no longer offered
|
|
19
|
+
* (parent changed / predicate flipped) are pruned from the array.
|
|
11
20
|
*/
|
|
12
|
-
export function CheckboxesField({
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
export function CheckboxesField({
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
field,
|
|
25
|
+
readonly,
|
|
26
|
+
className,
|
|
27
|
+
schema,
|
|
28
|
+
dependentValues,
|
|
29
|
+
dependsOn: dependsOnProp,
|
|
30
|
+
emptyHint: _emptyHint,
|
|
31
|
+
dataSource: _dataSource,
|
|
32
|
+
...props
|
|
33
|
+
}: FieldWidgetProps<string[]>) {
|
|
34
|
+
const config = (field || schema) as any;
|
|
35
|
+
const rawOptions: Option[] = config?.options || [];
|
|
15
36
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
16
37
|
const groupId = useId();
|
|
38
|
+
const fieldName = (props as any).name || config?.name || (props as any).id || '';
|
|
39
|
+
|
|
40
|
+
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
41
|
+
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
42
|
+
rawOptions,
|
|
43
|
+
dependsOn,
|
|
44
|
+
dependentValues,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Cascade clear: prune selected values the offered set no longer includes
|
|
48
|
+
// (parent changed / predicate flipped), keeping the ones still valid — the
|
|
49
|
+
// per-element clear the multi-value case needs (cf. the scalar select/radio).
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (readonly) return;
|
|
52
|
+
if (selected.length === 0) return;
|
|
53
|
+
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
54
|
+
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
}, [options, gated]);
|
|
17
57
|
|
|
18
58
|
if (readonly) {
|
|
19
59
|
if (selected.length === 0) return <EmptyValue />;
|
|
20
60
|
return (
|
|
21
61
|
<div className="flex flex-wrap gap-1">
|
|
22
62
|
{selected.map((v) => {
|
|
23
|
-
|
|
63
|
+
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
64
|
+
// still renders its label rather than a bare id.
|
|
65
|
+
const opt = rawOptions.find((o) => o.value === v);
|
|
24
66
|
return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
|
|
25
67
|
})}
|
|
26
68
|
</div>
|
|
27
69
|
);
|
|
28
70
|
}
|
|
29
71
|
|
|
72
|
+
// No offered options is unfillable — surface a legible state instead of an
|
|
73
|
+
// empty checkbox list: a dependency-gated list prompts for its controlling
|
|
74
|
+
// field; an unconfigured / fully-filtered list says so. Mirrors the select.
|
|
75
|
+
if (options.length === 0) {
|
|
76
|
+
const hint = gated
|
|
77
|
+
? `Select ${dependsOnFields.join(' / ')} first`
|
|
78
|
+
: 'No options available';
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
data-testid={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
|
|
82
|
+
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
83
|
+
>
|
|
84
|
+
{hint}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
30
89
|
const toggle = (v: string, checked: boolean) => {
|
|
31
90
|
const next = checked ? [...selected.filter((x) => x !== v), v] : selected.filter((x) => x !== v);
|
|
32
91
|
onChange(next);
|
|
33
92
|
};
|
|
34
93
|
|
|
35
94
|
return (
|
|
36
|
-
<div
|
|
95
|
+
<div
|
|
96
|
+
className={className}
|
|
97
|
+
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
|
|
98
|
+
>
|
|
37
99
|
{options.map((opt) => {
|
|
38
100
|
const id = `${groupId}-${opt.value}`;
|
|
39
101
|
return (
|
|
@@ -43,6 +105,7 @@ export function CheckboxesField({ value, onChange, field, readonly, className, .
|
|
|
43
105
|
checked={selected.includes(opt.value)}
|
|
44
106
|
onCheckedChange={(checked) => toggle(opt.value, !!checked)}
|
|
45
107
|
disabled={(props as any).disabled}
|
|
108
|
+
data-testid={`checkboxes-option-${opt.value}`}
|
|
46
109
|
/>
|
|
47
110
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
48
111
|
</div>
|
|
@@ -3,6 +3,7 @@ import { Button, EmptyValue } from '@object-ui/components';
|
|
|
3
3
|
import { useUpload } from '@object-ui/providers';
|
|
4
4
|
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
|
|
5
5
|
import { FieldWidgetProps } from './types';
|
|
6
|
+
import { useUploadingSignal } from './useUploadingSignal';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Shared upload pipeline for the file widgets: validates size, uploads through
|
|
@@ -49,6 +50,12 @@ function useFileUploads(opts: {
|
|
|
49
50
|
setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
|
|
50
51
|
});
|
|
51
52
|
return {
|
|
53
|
+
// `file_id` is the storage id the backend keys attachments by
|
|
54
|
+
// (the adapter returns it as `meta.fileId`); surfacing it here
|
|
55
|
+
// lets callers that need the id — e.g. an action param POSTing
|
|
56
|
+
// `attachments: string[]` — recover it without a second lookup.
|
|
57
|
+
// Extra key; the record file-field value shape is unchanged.
|
|
58
|
+
file_id: (result.meta as { fileId?: string } | undefined)?.fileId,
|
|
52
59
|
name: result.name,
|
|
53
60
|
original_name: file.name,
|
|
54
61
|
size: result.size,
|
|
@@ -84,7 +91,7 @@ function useFileUploads(opts: {
|
|
|
84
91
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
85
92
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
86
93
|
*/
|
|
87
|
-
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
94
|
+
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
|
|
88
95
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
89
96
|
const cameraRef = useRef<HTMLInputElement>(null);
|
|
90
97
|
const fileField = (field || (props as any).schema) as any;
|
|
@@ -111,6 +118,7 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
111
118
|
const { processFiles, errors, uploading, uploadProgress } = useFileUploads({
|
|
112
119
|
files, multiple, maxSize, onChange,
|
|
113
120
|
});
|
|
121
|
+
useUploadingSignal(uploading, onUploadingChange);
|
|
114
122
|
|
|
115
123
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
116
124
|
e.preventDefault();
|