@object-ui/fields 11.5.0 → 13.2.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 +150 -0
- package/README.md +11 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1637 -1479
- package/dist/widgets/CapabilityMultiSelectField.d.ts +11 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +10 -3
- package/dist/widgets/openNativePicker.d.ts +11 -0
- package/package.json +8 -8
- package/src/index.tsx +43 -13
- package/src/widgets/CapabilityMultiSelectField.test.tsx +143 -0
- package/src/widgets/CapabilityMultiSelectField.tsx +204 -0
- package/src/widgets/DateField.tsx +5 -0
- package/src/widgets/DateTimeField.tsx +5 -0
- package/src/widgets/SelectField.cascade.test.tsx +138 -0
- package/src/widgets/SelectField.tsx +74 -14
- package/src/widgets/TimeField.tsx +5 -0
- package/src/widgets/openNativePicker.ts +18 -0
|
@@ -0,0 +1,138 @@
|
|
|
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 `select` options (#2284).
|
|
11
|
+
*
|
|
12
|
+
* Exercises the observable, non-Radix behaviour of {@link SelectField}: the
|
|
13
|
+
* dependency gate (a "select the parent first" empty-state) and the cascade
|
|
14
|
+
* clear (a value dropped when the offered set no longer includes it). The
|
|
15
|
+
* per-option filtering itself is unit-tested in `@object-ui/core`
|
|
16
|
+
* (`optionRules.test.ts`); here we prove the widget wires `dependentValues` +
|
|
17
|
+
* predicate scope into that resolver.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import { render, screen } from '@testing-library/react';
|
|
22
|
+
import '@testing-library/jest-dom';
|
|
23
|
+
import { PredicateScopeProvider } from '@object-ui/react';
|
|
24
|
+
import { SelectField } from './SelectField';
|
|
25
|
+
|
|
26
|
+
const provinceField = {
|
|
27
|
+
name: 'province',
|
|
28
|
+
type: 'select',
|
|
29
|
+
dependsOn: 'country',
|
|
30
|
+
options: [
|
|
31
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
32
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
33
|
+
],
|
|
34
|
+
} as any;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('SelectField — dependency gating (#2284)', () => {
|
|
41
|
+
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
42
|
+
render(
|
|
43
|
+
<SelectField
|
|
44
|
+
value={undefined}
|
|
45
|
+
onChange={vi.fn()}
|
|
46
|
+
field={provinceField}
|
|
47
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
const gate = screen.getByTestId('select-empty-province');
|
|
51
|
+
expect(gate).toHaveTextContent(/select country first/i);
|
|
52
|
+
// Gated → no live combobox is offered.
|
|
53
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('unlocks the combobox once the controlling field is set', () => {
|
|
57
|
+
render(
|
|
58
|
+
<SelectField
|
|
59
|
+
value={undefined}
|
|
60
|
+
onChange={vi.fn()}
|
|
61
|
+
field={provinceField}
|
|
62
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.queryByTestId('select-empty-province')).not.toBeInTheDocument();
|
|
66
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('SelectField — cascade clear (#2284)', () => {
|
|
71
|
+
it('clears a value the parent change no longer offers', () => {
|
|
72
|
+
const onChange = vi.fn();
|
|
73
|
+
render(
|
|
74
|
+
<SelectField
|
|
75
|
+
value={'ca'}
|
|
76
|
+
onChange={onChange}
|
|
77
|
+
field={provinceField}
|
|
78
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
|
|
82
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps a value that is still offered', () => {
|
|
86
|
+
const onChange = vi.fn();
|
|
87
|
+
render(
|
|
88
|
+
<SelectField
|
|
89
|
+
value={'zj'}
|
|
90
|
+
onChange={onChange}
|
|
91
|
+
field={provinceField}
|
|
92
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('SelectField — role / context gating (#2284)', () => {
|
|
100
|
+
const tierField = {
|
|
101
|
+
name: 'tier',
|
|
102
|
+
type: 'select',
|
|
103
|
+
options: [
|
|
104
|
+
{ label: 'Standard', value: 'standard' },
|
|
105
|
+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
|
|
106
|
+
],
|
|
107
|
+
} as any;
|
|
108
|
+
|
|
109
|
+
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
110
|
+
const onChange = vi.fn();
|
|
111
|
+
render(
|
|
112
|
+
<PredicateScopeProvider scope={{ current_user: { roles: ['sales'] } }}>
|
|
113
|
+
<SelectField
|
|
114
|
+
value={'admin_only'}
|
|
115
|
+
onChange={onChange}
|
|
116
|
+
field={tierField}
|
|
117
|
+
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
118
|
+
/>
|
|
119
|
+
</PredicateScopeProvider>,
|
|
120
|
+
);
|
|
121
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps an admin-only value for an admin', () => {
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
render(
|
|
127
|
+
<PredicateScopeProvider scope={{ current_user: { roles: ['admin'] } }}>
|
|
128
|
+
<SelectField
|
|
129
|
+
value={'admin_only'}
|
|
130
|
+
onChange={onChange}
|
|
131
|
+
field={tierField}
|
|
132
|
+
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
133
|
+
/>
|
|
134
|
+
</PredicateScopeProvider>,
|
|
135
|
+
);
|
|
136
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Select,
|
|
4
4
|
SelectContent,
|
|
@@ -7,17 +7,42 @@ import {
|
|
|
7
7
|
SelectValue,
|
|
8
8
|
EmptyValue,
|
|
9
9
|
} from '@object-ui/components';
|
|
10
|
+
import {
|
|
11
|
+
resolveVisibleOptions,
|
|
12
|
+
isOptionGroupGated,
|
|
13
|
+
resolveDependsOnFields,
|
|
14
|
+
isValueStillOffered,
|
|
15
|
+
} from '@object-ui/core';
|
|
16
|
+
import { SchemaRendererContext, usePredicateScope } from '@object-ui/react';
|
|
10
17
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
11
18
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
12
19
|
import { FieldWidgetProps } from './types';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
|
-
* SelectField - Dropdown selection widget with configurable options
|
|
16
|
-
*
|
|
22
|
+
* SelectField - Dropdown selection widget with configurable options.
|
|
23
|
+
*
|
|
24
|
+
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
25
|
+
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
26
|
+
* `current_user`, so the offered set narrows as a controlling field changes
|
|
27
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
|
|
28
|
+
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
29
|
+
* of those is empty the control is gated with a "select the parent first" hint,
|
|
30
|
+
* mirroring the dependent-lookup UX.
|
|
17
31
|
*/
|
|
18
|
-
export function SelectField({
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
export function SelectField({
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
field,
|
|
36
|
+
readonly,
|
|
37
|
+
schema,
|
|
38
|
+
dependentValues,
|
|
39
|
+
dependsOn: dependsOnProp,
|
|
40
|
+
emptyHint: _emptyHint,
|
|
41
|
+
dataSource: _dataSource,
|
|
42
|
+
...props
|
|
43
|
+
}: FieldWidgetProps<string>) {
|
|
44
|
+
const config = (field || schema) as SelectFieldMetadata;
|
|
45
|
+
const rawOptions = config?.options || [];
|
|
21
46
|
const { t } = useFieldTranslation();
|
|
22
47
|
// Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
|
|
23
48
|
// driven by synthetic DOM events, so e2e must target the trigger/options by a
|
|
@@ -25,32 +50,67 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
25
50
|
// react-hook-form field name spread in by the form renderer (FormField).
|
|
26
51
|
const fieldName = (props as any).name || (config as any)?.name || props.id || '';
|
|
27
52
|
|
|
53
|
+
// Live form values for cascading options — injected by the form renderer as
|
|
54
|
+
// `dependentValues` (same channel dependent lookups use), falling back to the
|
|
55
|
+
// record on SchemaRendererContext. `current_user` etc. come from the global
|
|
56
|
+
// predicate scope so role/context predicates resolve too.
|
|
57
|
+
const ctx = useContext(SchemaRendererContext) as any;
|
|
58
|
+
const record = useMemo<Record<string, unknown>>(() => {
|
|
59
|
+
return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
|
|
60
|
+
}, [dependentValues, ctx?.formValues, ctx?.data]);
|
|
61
|
+
const predicateScope = usePredicateScope();
|
|
62
|
+
|
|
63
|
+
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
64
|
+
const dependsOnFields = useMemo(() => resolveDependsOnFields(dependsOn), [dependsOn]);
|
|
65
|
+
const gated = useMemo(
|
|
66
|
+
() => dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record),
|
|
67
|
+
[dependsOnFields, dependsOn, record],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Effective (offered) options after per-option `visibleWhen` filtering. Empty
|
|
71
|
+
// while gated so we never present an unfiltered set before the parent is set.
|
|
72
|
+
const options = useMemo(
|
|
73
|
+
() => (gated ? [] : resolveVisibleOptions(rawOptions, record, predicateScope)),
|
|
74
|
+
[gated, rawOptions, record, predicateScope],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Cascade clear: once the offered set no longer includes the current value
|
|
78
|
+
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (readonly) return;
|
|
81
|
+
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
82
|
+
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [options, gated]);
|
|
85
|
+
|
|
28
86
|
if (readonly) {
|
|
29
|
-
const option =
|
|
87
|
+
const option = rawOptions.find((o) => o.value === value);
|
|
30
88
|
const display = option?.label || value;
|
|
31
89
|
return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
|
|
32
90
|
}
|
|
33
91
|
|
|
34
92
|
// A select with no options is unfillable — a silently-empty Radix dropdown
|
|
35
|
-
// reads as "broken widget" and hides the real cause
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// (see plugin form `renderFieldComponent`'s `case 'select'`).
|
|
93
|
+
// reads as "broken widget" and hides the real cause. Surface a legible state:
|
|
94
|
+
// a dependency-gated list prompts for its controlling field; an unconfigured
|
|
95
|
+
// list says so. Mirrors the inline form renderer's behaviour.
|
|
39
96
|
if (options.length === 0) {
|
|
97
|
+
const hint = gated
|
|
98
|
+
? `Select ${dependsOnFields.join(' / ')} first`
|
|
99
|
+
: 'No options available';
|
|
40
100
|
return (
|
|
41
101
|
<div
|
|
42
102
|
data-testid={fieldName ? `select-empty-${fieldName}` : undefined}
|
|
43
103
|
className="flex h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
44
104
|
>
|
|
45
|
-
|
|
105
|
+
{hint}
|
|
46
106
|
</div>
|
|
47
107
|
);
|
|
48
108
|
}
|
|
49
109
|
|
|
50
110
|
return (
|
|
51
|
-
<Select
|
|
111
|
+
<Select
|
|
52
112
|
{...props}
|
|
53
|
-
value={value}
|
|
113
|
+
value={value}
|
|
54
114
|
onValueChange={onChange}
|
|
55
115
|
disabled={readonly || props.disabled}
|
|
56
116
|
>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
|
+
import { openNativePicker } from './openNativePicker';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* TimeField - Time picker input widget
|
|
@@ -20,6 +21,10 @@ export function TimeField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
20
21
|
type="time"
|
|
21
22
|
value={value || ''}
|
|
22
23
|
onChange={(e) => onChange(e.target.value)}
|
|
24
|
+
onClick={(e) => {
|
|
25
|
+
openNativePicker(e.currentTarget);
|
|
26
|
+
domProps.onClick?.(e);
|
|
27
|
+
}}
|
|
23
28
|
disabled={readonly || domProps.disabled}
|
|
24
29
|
/>
|
|
25
30
|
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open the browser's native picker (date / datetime-local / time) for an input.
|
|
3
|
+
*
|
|
4
|
+
* Native date/time inputs only open their picker when the user clicks the small
|
|
5
|
+
* calendar/clock icon. Calling `showPicker()` on click makes the whole field
|
|
6
|
+
* behave like other widgets — clicking anywhere in the box opens the picker.
|
|
7
|
+
*
|
|
8
|
+
* `showPicker()` can throw (unsupported browser, not user-activated, or the input
|
|
9
|
+
* is disabled/hidden), so failures are swallowed and the native fallback stands.
|
|
10
|
+
*/
|
|
11
|
+
export function openNativePicker(input: HTMLInputElement): void {
|
|
12
|
+
if (input.disabled || input.readOnly) return;
|
|
13
|
+
try {
|
|
14
|
+
(input as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
|
|
15
|
+
} catch {
|
|
16
|
+
// Ignore — browsers throw when the picker can't be shown; the icon still works.
|
|
17
|
+
}
|
|
18
|
+
}
|