@object-ui/fields 16.0.0 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +198 -0
- package/README.md +45 -7
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1754 -1694
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/SelectField.d.ts +13 -9
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +9 -9
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +47 -6
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +70 -7
- package/src/widgets/FileField.tsx +9 -1
- package/src/widgets/ImageField.tsx +45 -42
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +72 -8
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +64 -7
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +4 -2
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
@@ -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
|
|
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({
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
);
|