@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
@@ -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
- interface Option { label: string; value: string }
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({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
13
- const config = (field || (props as any).schema) as any;
14
- const options: Option[] = config?.options || [];
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
- const opt = options.find((o) => o.value === v);
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 className={className}>
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();
@@ -3,6 +3,7 @@ import { Button, EmptyValue } from '@object-ui/components';
3
3
  import { useUpload } from '@object-ui/providers';
4
4
  import { Upload, X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
5
5
  import { FieldWidgetProps } from './types';
6
+ import { useUploadingSignal } from './useUploadingSignal';
6
7
 
7
8
  // Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
8
9
  // ImageField bundle. Consumers that never crop pay zero cost.
@@ -14,7 +15,7 @@ const ImageCropperDialog = lazy(() =>
14
15
  * ImageField - Image upload widget with preview thumbnails
15
16
  * Supports single and multiple image uploads with drag-and-drop and preview display
16
17
  */
17
- export function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
18
+ export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
18
19
  const inputRef = useRef<HTMLInputElement>(null);
19
20
  const imageField = (field || (props as any).schema) as any;
20
21
  const multiple = imageField?.multiple || false;
@@ -26,11 +27,52 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
26
27
  const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
27
28
  const { upload } = useUpload();
28
29
  const [uploading, setUploading] = useState(false);
30
+ useUploadingSignal(uploading, onUploadingChange);
31
+
32
+ // Derived value + memoized handlers must run before the readonly early return
33
+ // so hook order stays stable across renders.
34
+ const images = value ? (Array.isArray(value) ? value : [value]) : [];
35
+
36
+ const handleCropConfirm = useCallback(
37
+ async (blob: Blob, name: string) => {
38
+ if (!cropTarget) return;
39
+ setUploading(true);
40
+ try {
41
+ const result = await upload(blob);
42
+ const next = {
43
+ name: result.name || name,
44
+ original_name: name,
45
+ size: result.size,
46
+ mime_type: result.mimeType,
47
+ url: result.url,
48
+ };
49
+ if (multiple) {
50
+ const updated = [...images];
51
+ updated[cropTarget.index] = next;
52
+ onChange(updated);
53
+ } else {
54
+ onChange(next);
55
+ }
56
+ } finally {
57
+ setUploading(false);
58
+ setCropTarget(null);
59
+ }
60
+ },
61
+ [cropTarget, images, multiple, onChange, upload],
62
+ );
63
+
64
+ const openCropper = useCallback(
65
+ (index: number) => {
66
+ const img = images[index];
67
+ if (!img?.url) return;
68
+ setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
69
+ },
70
+ [images],
71
+ );
29
72
 
30
73
  if (readonly) {
31
74
  if (!value) return <EmptyValue />;
32
-
33
- const images = Array.isArray(value) ? value : [value];
75
+
34
76
  return (
35
77
  <div className="flex flex-wrap gap-2">
36
78
  {images.map((img: any, idx: number) => (
@@ -45,8 +87,6 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
45
87
  );
46
88
  }
47
89
 
48
- const images = value ? (Array.isArray(value) ? value : [value]) : [];
49
-
50
90
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
51
91
  const selectedFiles = Array.from(e.target.files || []);
52
92
  if (selectedFiles.length === 0) return;
@@ -87,43 +127,6 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
87
127
  }
88
128
  };
89
129
 
90
- const handleCropConfirm = useCallback(
91
- async (blob: Blob, name: string) => {
92
- if (!cropTarget) return;
93
- setUploading(true);
94
- try {
95
- const result = await upload(blob);
96
- const next = {
97
- name: result.name || name,
98
- original_name: name,
99
- size: result.size,
100
- mime_type: result.mimeType,
101
- url: result.url,
102
- };
103
- if (multiple) {
104
- const updated = [...images];
105
- updated[cropTarget.index] = next;
106
- onChange(updated);
107
- } else {
108
- onChange(next);
109
- }
110
- } finally {
111
- setUploading(false);
112
- setCropTarget(null);
113
- }
114
- },
115
- [cropTarget, images, multiple, onChange, upload],
116
- );
117
-
118
- const openCropper = useCallback(
119
- (index: number) => {
120
- const img = images[index];
121
- if (!img?.url) return;
122
- setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
123
- },
124
- [images],
125
- );
126
-
127
130
  return (
128
131
  <div className={props.className}>
129
132
  <input
@@ -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 `multiselect` options (#2715) — parity with the single
11
+ * `SelectField` (ADR-0058 / #2284).
12
+ *
13
+ * Exercises the observable widget behaviour: the dependency gate (a "select the
14
+ * parent first" empty-state), and the cascade clear — but here per-element: a
15
+ * selection dropped from the array when the offered set no longer includes it,
16
+ * while still-valid selections are kept. The per-option filtering itself is
17
+ * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
18
+ * chip picker wires `dependentValues` + predicate scope into that resolver via
19
+ * the shared `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 { MultiSelectField } from './MultiSelectField';
27
+
28
+ const provinceField = {
29
+ name: 'province',
30
+ type: 'multiselect',
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('MultiSelectField — dependency gating (#2715)', () => {
43
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
44
+ render(
45
+ <MultiSelectField
46
+ value={[]}
47
+ onChange={vi.fn()}
48
+ field={provinceField}
49
+ {...({ name: 'province', dependentValues: {} } as any)}
50
+ />,
51
+ );
52
+ const gate = screen.getByTestId('multiselect-empty-province');
53
+ expect(gate).toHaveTextContent(/select country first/i);
54
+ // Gated → no chips offered.
55
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
56
+ });
57
+
58
+ it('unlocks the chips once the controlling field is set', () => {
59
+ render(
60
+ <MultiSelectField
61
+ value={[]}
62
+ onChange={vi.fn()}
63
+ field={provinceField}
64
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
65
+ />,
66
+ );
67
+ expect(screen.queryByTestId('multiselect-empty-province')).not.toBeInTheDocument();
68
+ // Only the `cn` option is offered.
69
+ expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
70
+ expect(screen.queryByTestId('multiselect-option-ca')).not.toBeInTheDocument();
71
+ });
72
+ });
73
+
74
+ describe('MultiSelectField — cascade clear (#2715)', () => {
75
+ it('drops only the selections the parent change no longer offers', () => {
76
+ const onChange = vi.fn();
77
+ render(
78
+ <MultiSelectField
79
+ value={['zj', 'ca']}
80
+ onChange={onChange}
81
+ field={provinceField}
82
+ {...({ name: 'province', 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
+ <MultiSelectField
94
+ value={['zj']}
95
+ onChange={onChange}
96
+ field={provinceField}
97
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
98
+ />,
99
+ );
100
+ expect(onChange).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
104
+ describe('MultiSelectField — role / context gating (#2715)', () => {
105
+ const tierField = {
106
+ name: 'tiers',
107
+ type: 'multiselect',
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
+ <MultiSelectField
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
+ <MultiSelectField
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,102 @@
1
- import React from 'react';
1
+ import React, { useEffect } from 'react';
2
2
  import { Badge, EmptyValue, cn } 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
- interface Option { label: string; value: string; color?: string }
7
+ interface Option extends OptionLike { color?: string }
6
8
 
7
9
  /**
8
10
  * MultiSelectField - select zero or more values from a fixed option set.
9
11
  *
10
12
  * Renders the configured options as toggleable chips. The stored value is a
11
- * string[] (the selected option values). Used for the `multiselect` field type.
13
+ * string[] (the selected option values). Used for the `multiselect` field type
14
+ * and for a `select` field declared `multiple: true`.
15
+ *
16
+ * Options support the same per-option `visibleWhen` cascading + `dependsOn`
17
+ * gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
18
+ * shared {@link useCascadingOptions} hook: the offered chips narrow against the
19
+ * live form record + `current_user`, the control is gated behind a "select the
20
+ * parent first" hint while a dependency is empty, and selections no longer
21
+ * offered (parent changed / predicate flipped) are dropped from the array.
12
22
  */
13
- export function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
14
- const config = (field || (props as any).schema) as any;
15
- const options: Option[] = config?.options || [];
23
+ export function MultiSelectField({
24
+ value,
25
+ onChange,
26
+ field,
27
+ readonly,
28
+ className,
29
+ schema,
30
+ dependentValues,
31
+ dependsOn: dependsOnProp,
32
+ emptyHint: _emptyHint,
33
+ dataSource: _dataSource,
34
+ ...props
35
+ }: FieldWidgetProps<string[]>) {
36
+ const config = (field || schema) as any;
37
+ const rawOptions: Option[] = config?.options || [];
16
38
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
+ const fieldName = (props as any).name || config?.name || (props as any).id || '';
40
+
41
+ const dependsOn = config?.dependsOn ?? dependsOnProp;
42
+ const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
43
+ rawOptions,
44
+ dependsOn,
45
+ dependentValues,
46
+ );
47
+
48
+ // Cascade clear: drop selected values the offered set no longer includes
49
+ // (parent changed / predicate flipped), keeping the ones still valid — unlike
50
+ // the scalar case we prune per-element rather than clearing the whole field.
51
+ useEffect(() => {
52
+ if (readonly) return;
53
+ if (selected.length === 0) return;
54
+ const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
55
+ if (stillOffered.length !== selected.length) onChange(stillOffered);
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [options, gated]);
17
58
 
18
59
  if (readonly) {
19
60
  if (selected.length === 0) return <EmptyValue />;
20
61
  return (
21
62
  <div className="flex flex-wrap gap-1">
22
63
  {selected.map((v) => {
23
- const opt = options.find((o) => o.value === v);
64
+ // Label from the raw set so a stored value hidden by `visibleWhen`
65
+ // still renders its label rather than a bare id.
66
+ const opt = rawOptions.find((o) => o.value === v);
24
67
  return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
25
68
  })}
26
69
  </div>
27
70
  );
28
71
  }
29
72
 
73
+ // No offered options is unfillable — surface a legible state instead of an
74
+ // empty chip row: a dependency-gated list prompts for its controlling field;
75
+ // an unconfigured / fully-filtered list says so. Mirrors the single select.
76
+ if (options.length === 0) {
77
+ const hint = gated
78
+ ? `Select ${dependsOnFields.join(' / ')} first`
79
+ : 'No options available';
80
+ return (
81
+ <div
82
+ data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
83
+ 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"
84
+ >
85
+ {hint}
86
+ </div>
87
+ );
88
+ }
89
+
30
90
  const toggle = (v: string) => {
31
91
  const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
32
92
  onChange(next);
33
93
  };
34
94
 
35
95
  return (
36
- <div className={cn('flex flex-wrap gap-1.5', className)}>
96
+ <div
97
+ className={cn('flex flex-wrap gap-1.5', className)}
98
+ data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
+ >
37
100
  {options.map((opt) => {
38
101
  const active = selected.includes(opt.value);
39
102
  return (
@@ -43,6 +106,7 @@ export function MultiSelectField({ value, onChange, field, readonly, className,
43
106
  onClick={() => toggle(opt.value)}
44
107
  disabled={(props as any).disabled}
45
108
  aria-pressed={active}
109
+ data-testid={`multiselect-option-${opt.value}`}
46
110
  className={cn(
47
111
  'rounded-full border px-3 py-1 text-sm transition-colors',
48
112
  active
@@ -0,0 +1,141 @@
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 `radio` options (#2715) — parity with the single
11
+ * `SelectField` (ADR-0058 / #2284).
12
+ *
13
+ * Exercises the observable widget behaviour: the dependency gate (a "select the
14
+ * parent first" empty-state) and the scalar cascade clear (a value dropped when
15
+ * the offered set no longer includes it). The per-option filtering itself is
16
+ * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
17
+ * radio group wires `dependentValues` + predicate scope into that resolver via
18
+ * the shared `useCascadingOptions` hook.
19
+ */
20
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
21
+ import React from 'react';
22
+ import { render, screen } from '@testing-library/react';
23
+ import '@testing-library/jest-dom';
24
+ import { PredicateScopeProvider } from '@object-ui/react';
25
+ import { RadioField } from './RadioField';
26
+
27
+ const provinceField = {
28
+ name: 'province',
29
+ type: 'radio',
30
+ dependsOn: 'country',
31
+ options: [
32
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
33
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
34
+ ],
35
+ } as any;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ describe('RadioField — dependency gating (#2715)', () => {
42
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
43
+ render(
44
+ <RadioField
45
+ value={undefined}
46
+ onChange={vi.fn()}
47
+ field={provinceField}
48
+ {...({ name: 'province', dependentValues: {} } as any)}
49
+ />,
50
+ );
51
+ const gate = screen.getByTestId('radio-empty-province');
52
+ expect(gate).toHaveTextContent(/select country first/i);
53
+ // Gated → no radios offered.
54
+ expect(screen.queryAllByRole('radio')).toHaveLength(0);
55
+ });
56
+
57
+ it('unlocks the radios once the controlling field is set', () => {
58
+ render(
59
+ <RadioField
60
+ value={undefined}
61
+ onChange={vi.fn()}
62
+ field={provinceField}
63
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
64
+ />,
65
+ );
66
+ expect(screen.queryByTestId('radio-empty-province')).not.toBeInTheDocument();
67
+ // Only the `cn` option is offered.
68
+ expect(screen.getByRole('radio', { name: 'Zhejiang' })).toBeInTheDocument();
69
+ expect(screen.queryByRole('radio', { name: 'California' })).not.toBeInTheDocument();
70
+ });
71
+ });
72
+
73
+ describe('RadioField — cascade clear (#2715)', () => {
74
+ it('clears a value the parent change no longer offers', () => {
75
+ const onChange = vi.fn();
76
+ render(
77
+ <RadioField
78
+ value={'ca'}
79
+ onChange={onChange}
80
+ field={provinceField}
81
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
82
+ />,
83
+ );
84
+ // 'ca' is a US province — under country=cn it is not offered, so it is dropped.
85
+ expect(onChange).toHaveBeenCalledWith(undefined);
86
+ });
87
+
88
+ it('keeps a value that is still offered', () => {
89
+ const onChange = vi.fn();
90
+ render(
91
+ <RadioField
92
+ value={'zj'}
93
+ onChange={onChange}
94
+ field={provinceField}
95
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
96
+ />,
97
+ );
98
+ expect(onChange).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe('RadioField — role / context gating (#2715)', () => {
103
+ const tierField = {
104
+ name: 'tier',
105
+ type: 'radio',
106
+ options: [
107
+ { label: 'Standard', value: 'standard' },
108
+ { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
109
+ ],
110
+ } as any;
111
+
112
+ it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
113
+ const onChange = vi.fn();
114
+ render(
115
+ <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
116
+ <RadioField
117
+ value={'admin_only'}
118
+ onChange={onChange}
119
+ field={tierField}
120
+ {...({ name: 'tier', dependentValues: {} } as any)}
121
+ />
122
+ </PredicateScopeProvider>,
123
+ );
124
+ expect(onChange).toHaveBeenCalledWith(undefined);
125
+ });
126
+
127
+ it('keeps an admin-only value for an admin', () => {
128
+ const onChange = vi.fn();
129
+ render(
130
+ <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
131
+ <RadioField
132
+ value={'admin_only'}
133
+ onChange={onChange}
134
+ field={tierField}
135
+ {...({ name: 'tier', dependentValues: {} } as any)}
136
+ />
137
+ </PredicateScopeProvider>,
138
+ );
139
+ expect(onChange).not.toHaveBeenCalled();
140
+ });
141
+ });