@object-ui/fields 15.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 +219 -0
- package/README.md +45 -7
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1798 -1721
- 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/CapabilityMultiSelectField.test.tsx +26 -1
- package/src/widgets/CapabilityMultiSelectField.tsx +35 -10
- 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/useFieldTranslation.ts +13 -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": "
|
|
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": "
|
|
34
|
-
"@object-ui/core": "
|
|
35
|
-
"@object-ui/i18n": "
|
|
36
|
-
"@object-ui/providers": "
|
|
37
|
-
"@object-ui/react": "
|
|
38
|
-
"@object-ui/types": "
|
|
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', () => {
|
|
@@ -106,7 +106,11 @@ describe('CapabilityMultiSelectField', () => {
|
|
|
106
106
|
dataSource={mockDataSource()}
|
|
107
107
|
/>,
|
|
108
108
|
);
|
|
109
|
-
|
|
109
|
+
// Chip labels now resolve through the i18n bundle (objectui#2600 B5), so an
|
|
110
|
+
// async translation-settle can re-render the chips between findBy and the
|
|
111
|
+
// click — re-query at click time so we act on the live node, not a detached one.
|
|
112
|
+
await screen.findByRole('button', { name: 'Studio Access' });
|
|
113
|
+
fireEvent.click(screen.getByRole('button', { name: 'Studio Access' }));
|
|
110
114
|
const emitted = onChange.mock.calls[0][0];
|
|
111
115
|
expect(typeof emitted).toBe('string');
|
|
112
116
|
expect(JSON.parse(emitted)).toEqual(['manage_users']);
|
|
@@ -140,4 +144,25 @@ describe('CapabilityMultiSelectField', () => {
|
|
|
140
144
|
expect(await screen.findByText('Studio Access')).toBeInTheDocument();
|
|
141
145
|
expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
|
|
142
146
|
});
|
|
147
|
+
|
|
148
|
+
// objectui#2600 B5 — curated platform caps get a localized label; package/
|
|
149
|
+
// admin-authored caps keep whatever label the registry served.
|
|
150
|
+
it('localizes curated capability labels but preserves registry labels for others', async () => {
|
|
151
|
+
render(
|
|
152
|
+
<CapabilityMultiSelectField
|
|
153
|
+
value={'[]'}
|
|
154
|
+
onChange={vi.fn()}
|
|
155
|
+
field={{ name: 'system_permissions' } as any}
|
|
156
|
+
dataSource={mockDataSource([
|
|
157
|
+
// Registry sends a shorter label; the curated client map wins.
|
|
158
|
+
{ name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
|
|
159
|
+
// Not a curated platform capability — its registry label is kept.
|
|
160
|
+
{ name: 'export_data', label: 'Export Data', description: 'Export', scope: 'org', active: true },
|
|
161
|
+
])}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
expect(await screen.findByRole('button', { name: 'Manage Organization Users' })).toBeInTheDocument();
|
|
165
|
+
expect(screen.queryByRole('button', { name: 'Manage Org Users' })).not.toBeInTheDocument();
|
|
166
|
+
expect(screen.getByRole('button', { name: 'Export Data' })).toBeInTheDocument();
|
|
167
|
+
});
|
|
143
168
|
});
|
|
@@ -3,6 +3,7 @@ import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
5
5
|
import { FieldWidgetProps } from './types';
|
|
6
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* CapabilityMultiSelectField — structured picker for a permission set's
|
|
@@ -54,13 +55,30 @@ export function parseCapabilityNames(value: unknown): string[] {
|
|
|
54
55
|
return [];
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Scope → group header order (platform powers first). The visible header is
|
|
60
|
+
* localized via `capability.group.<scope>` (objectui#2600 B5) — the capability
|
|
61
|
+
* *labels themselves* still come from the sys_capability registry, whose
|
|
62
|
+
* per-locale localization is tracked separately (framework).
|
|
63
|
+
*/
|
|
58
64
|
const SCOPE_ORDER = ['platform', 'org', 'other'] as const;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* objectui#2600 B5 — the curated platform capabilities are a FIXED, known set
|
|
68
|
+
* whose labels the sys_capability registry serves in English. Localize just
|
|
69
|
+
* these client-side via `capability.label.<name>` (dots → underscores);
|
|
70
|
+
* package- and admin-authored capabilities keep their authored registry label.
|
|
71
|
+
* (Mirrors @objectstack/spec/security `PLATFORM_CAPABILITIES`.)
|
|
72
|
+
*/
|
|
73
|
+
const CURATED_CAPABILITY_LABELS = new Set([
|
|
74
|
+
'manage_users',
|
|
75
|
+
'manage_org_users',
|
|
76
|
+
'manage_metadata',
|
|
77
|
+
'manage_platform_settings',
|
|
78
|
+
'setup_access',
|
|
79
|
+
'setup_write',
|
|
80
|
+
'studio_access',
|
|
81
|
+
]);
|
|
64
82
|
|
|
65
83
|
export function CapabilityMultiSelectField({
|
|
66
84
|
value,
|
|
@@ -69,6 +87,7 @@ export function CapabilityMultiSelectField({
|
|
|
69
87
|
className,
|
|
70
88
|
...props
|
|
71
89
|
}: FieldWidgetProps<string | string[]>) {
|
|
90
|
+
const { t } = useFieldTranslation();
|
|
72
91
|
const ctx = React.useContext(SchemaRendererContext);
|
|
73
92
|
const dataSource: DataSource | null =
|
|
74
93
|
(props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
@@ -119,7 +138,13 @@ export function CapabilityMultiSelectField({
|
|
|
119
138
|
return map;
|
|
120
139
|
}, [caps, selected]);
|
|
121
140
|
|
|
122
|
-
|
|
141
|
+
// Curated platform caps get a localized label (objectui#2600 B5); everything
|
|
142
|
+
// else keeps the registry-served label.
|
|
143
|
+
const labelFor = (name: string) => {
|
|
144
|
+
const norm = name.replace(/\./g, '_');
|
|
145
|
+
if (CURATED_CAPABILITY_LABELS.has(norm)) return t(`capability.label.${norm}`);
|
|
146
|
+
return byName.get(name)?.label || name;
|
|
147
|
+
};
|
|
123
148
|
|
|
124
149
|
// Group options by scope for the editable grid. Computed BEFORE the readonly
|
|
125
150
|
// early-return so the hook order stays stable regardless of `readonly`.
|
|
@@ -135,12 +160,12 @@ export function CapabilityMultiSelectField({
|
|
|
135
160
|
}
|
|
136
161
|
return SCOPE_ORDER.filter((s) => buckets.has(s)).map((s) => ({
|
|
137
162
|
scope: s,
|
|
138
|
-
label:
|
|
163
|
+
label: t(`capability.group.${s}`),
|
|
139
164
|
items: (buckets.get(s) ?? []).sort((a, b) =>
|
|
140
165
|
(a.label || a.name).localeCompare(b.label || b.name),
|
|
141
166
|
),
|
|
142
167
|
}));
|
|
143
|
-
}, [byName]);
|
|
168
|
+
}, [byName, t]);
|
|
144
169
|
|
|
145
170
|
if (readonly) {
|
|
146
171
|
if (selected.length === 0) return <EmptyValue />;
|
|
@@ -190,7 +215,7 @@ export function CapabilityMultiSelectField({
|
|
|
190
215
|
: 'border-input bg-background text-foreground hover:bg-accent',
|
|
191
216
|
)}
|
|
192
217
|
>
|
|
193
|
-
{cap.
|
|
218
|
+
{labelFor(cap.name)}
|
|
194
219
|
</button>
|
|
195
220
|
);
|
|
196
221
|
})}
|
|
@@ -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
|
+
});
|