@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +198 -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 +1754 -1694
  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/CheckboxesField.cascade.test.tsx +143 -0
  25. package/src/widgets/CheckboxesField.tsx +70 -7
  26. package/src/widgets/FileField.tsx +9 -1
  27. package/src/widgets/ImageField.tsx +45 -42
  28. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  29. package/src/widgets/MultiSelectField.tsx +72 -8
  30. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  31. package/src/widgets/RadioField.tsx +64 -7
  32. package/src/widgets/SelectField.tsx +33 -31
  33. package/src/widgets/TextAreaField.tsx +4 -2
  34. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  35. package/src/widgets/deriveLookupColumns.ts +5 -15
  36. package/src/widgets/types.ts +6 -0
  37. package/src/widgets/useCascadingOptions.ts +46 -0
  38. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  39. package/src/widgets/useUploadingSignal.ts +20 -0
@@ -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
+ });
@@ -1,25 +1,82 @@
1
- import React, { useId } from 'react';
1
+ import React, { useId, useEffect } from 'react';
2
2
  import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
+ import { isValueStillOffered, 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
  * RadioField - choose exactly one value from a fixed option set, rendered as a
9
11
  * radio group. The stored value is the selected option value (string). Used for
10
12
  * the `radio` field type.
13
+ *
14
+ * Like the single `SelectField`, options support per-option `visibleWhen`
15
+ * cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
16
+ * {@link useCascadingOptions} hook: the offered radios 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 a value no longer offered (parent
19
+ * changed / predicate flipped) is cleared.
11
20
  */
12
- export function RadioField({ 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 RadioField({
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 groupId = useId();
37
+ const fieldName = (props as any).name || config?.name || (props as any).id || '';
38
+
39
+ const dependsOn = config?.dependsOn ?? dependsOnProp;
40
+ const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
41
+ rawOptions,
42
+ dependsOn,
43
+ dependentValues,
44
+ );
45
+
46
+ // Cascade clear: once the offered set no longer includes the current value
47
+ // (parent changed / predicate flipped), drop it so no stale pair persists.
48
+ useEffect(() => {
49
+ if (readonly) return;
50
+ if (value === undefined || value === null || (value as unknown) === '') return;
51
+ if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [options, gated]);
16
54
 
17
55
  if (readonly) {
18
56
  if (value == null || value === '') return <EmptyValue />;
19
- const opt = options.find((o) => o.value === value);
57
+ // Label from the raw set so a stored value hidden by `visibleWhen` still
58
+ // renders its label rather than a bare id.
59
+ const opt = rawOptions.find((o) => o.value === value);
20
60
  return <span className="text-sm">{opt?.label || String(value)}</span>;
21
61
  }
22
62
 
63
+ // No offered options is unfillable — surface a legible state instead of an
64
+ // empty radio group: a dependency-gated list prompts for its controlling
65
+ // field; an unconfigured / fully-filtered list says so. Mirrors the select.
66
+ if (options.length === 0) {
67
+ const hint = gated
68
+ ? `Select ${dependsOnFields.join(' / ')} first`
69
+ : 'No options available';
70
+ return (
71
+ <div
72
+ data-testid={fieldName ? `radio-empty-${fieldName}` : undefined}
73
+ 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"
74
+ >
75
+ {hint}
76
+ </div>
77
+ );
78
+ }
79
+
23
80
  return (
24
81
  <RadioGroup
25
82
  value={value ?? ''}
@@ -31,7 +88,7 @@ export function RadioField({ value, onChange, field, readonly, className, ...pro
31
88
  const id = `${groupId}-${opt.value}`;
32
89
  return (
33
90
  <div key={opt.value} className="flex items-center space-x-2">
34
- <RadioGroupItem value={opt.value} id={id} />
91
+ <RadioGroupItem value={opt.value} id={id} data-testid={`radio-option-${opt.value}`} />
35
92
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
36
93
  </div>
37
94
  );