@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +45 -7
  3. package/dist/index.cjs +5 -5
  4. package/dist/index.d.ts +21 -0
  5. package/dist/index.js +1798 -1721
  6. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  7. package/dist/widgets/CheckboxesField.d.ts +8 -1
  8. package/dist/widgets/FileField.d.ts +1 -1
  9. package/dist/widgets/ImageField.d.ts +1 -1
  10. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  11. package/dist/widgets/MultiSelectField.d.ts +10 -2
  12. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  13. package/dist/widgets/RadioField.d.ts +8 -1
  14. package/dist/widgets/SelectField.d.ts +13 -9
  15. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  16. package/dist/widgets/types.d.ts +6 -0
  17. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  18. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  19. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  20. package/package.json +9 -9
  21. package/src/field-type-coverage.test.ts +3 -1
  22. package/src/index.tsx +47 -6
  23. package/src/standard-widgets.test.tsx +44 -0
  24. package/src/widgets/CapabilityMultiSelectField.test.tsx +26 -1
  25. package/src/widgets/CapabilityMultiSelectField.tsx +35 -10
  26. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  27. package/src/widgets/CheckboxesField.tsx +70 -7
  28. package/src/widgets/FileField.tsx +9 -1
  29. package/src/widgets/ImageField.tsx +45 -42
  30. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  31. package/src/widgets/MultiSelectField.tsx +72 -8
  32. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  33. package/src/widgets/RadioField.tsx +64 -7
  34. package/src/widgets/SelectField.tsx +33 -31
  35. package/src/widgets/TextAreaField.tsx +4 -2
  36. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  37. package/src/widgets/deriveLookupColumns.ts +5 -15
  38. package/src/widgets/types.ts +6 -0
  39. package/src/widgets/useCascadingOptions.ts +46 -0
  40. package/src/widgets/useFieldTranslation.ts +13 -0
  41. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  42. 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;
@@ -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 - Dropdown selection widget with configurable options.
4
+ * SelectField - dropdown selection widget.
5
5
  *
6
- * Supports cascading / role-gated options (#2284): each option may carry a
7
- * `visibleWhen` CEL predicate, evaluated against the live form record +
8
- * `current_user`, so the offered set narrows as a controlling field changes
9
- * (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
10
- * field declares which sibling fields drive its list via `dependsOn`; while any
11
- * of those is empty the control is gated with a "select the parent first" hint,
12
- * mirroring the dependent-lookup UX.
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({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
18
+ export declare function SelectField(props: FieldWidgetProps<any>): React.JSX.Element;
@@ -10,6 +10,7 @@ interface ObjectSchemaLike {
10
10
  type?: string;
11
11
  label?: string;
12
12
  hidden?: boolean;
13
+ system?: boolean;
13
14
  }>;
14
15
  /**
15
16
  * ADR-0085 semantic role: the object's most important fields. The single
@@ -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": "15.0.0",
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.24.0",
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": "15.0.0",
34
- "@object-ui/core": "15.0.0",
35
- "@object-ui/i18n": "15.0.0",
36
- "@object-ui/providers": "15.0.0",
37
- "@object-ui/react": "15.0.0",
38
- "@object-ui/types": "15.0.0"
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.4",
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
- 'formula', 'summary', 'auto_number',
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
- try {
281
- const { t } = useObjectTranslation();
282
- return t as (key: string, params?: Record<string, unknown>) => string;
283
- } catch {
284
- return undefined;
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
- fireEvent.click(await screen.findByRole('button', { name: 'Studio Access' }));
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
- /** Scope → group header. Order is intentional (platform powers first). */
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
- const SCOPE_LABEL: Record<string, string> = {
60
- platform: 'Platform',
61
- org: 'Organization',
62
- other: 'Other',
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
- const labelFor = (name: string) => byName.get(name)?.label || name;
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: SCOPE_LABEL[s] ?? s,
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.label || cap.name}
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
+ });