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