@object-ui/fields 11.5.0 → 13.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 +99 -0
- package/README.md +11 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1529 -1479
- 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 +35 -13
- 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
|
File without changes
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
import { FieldWidgetProps } from './types';
|
|
3
3
|
/**
|
|
4
|
-
* SelectField - Dropdown selection widget with configurable options
|
|
5
|
-
*
|
|
4
|
+
* SelectField - Dropdown selection widget with configurable options.
|
|
5
|
+
*
|
|
6
|
+
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
7
|
+
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
8
|
+
* `current_user`, so the offered set narrows as a controlling field changes
|
|
9
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
|
|
10
|
+
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
11
|
+
* of those is empty the control is gated with a "select the parent first" hint,
|
|
12
|
+
* mirroring the dependent-lookup UX.
|
|
6
13
|
*/
|
|
7
|
-
export declare function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
14
|
+
export declare function SelectField({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
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 declare function openNativePicker(input: HTMLInputElement): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.1.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
"rehype-sanitize": "^6.0.0",
|
|
31
31
|
"remark-gfm": "^4.0.1",
|
|
32
32
|
"tailwind-merge": "^3.6.0",
|
|
33
|
-
"@object-ui/components": "
|
|
34
|
-
"@object-ui/core": "
|
|
35
|
-
"@object-ui/i18n": "
|
|
36
|
-
"@object-ui/providers": "
|
|
37
|
-
"@object-ui/react": "
|
|
38
|
-
"@object-ui/types": "
|
|
33
|
+
"@object-ui/components": "13.1.0",
|
|
34
|
+
"@object-ui/core": "13.1.0",
|
|
35
|
+
"@object-ui/i18n": "13.1.0",
|
|
36
|
+
"@object-ui/providers": "13.1.0",
|
|
37
|
+
"@object-ui/react": "13.1.0",
|
|
38
|
+
"@object-ui/types": "13.1.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@types/react-dom": "19.2.3",
|
|
47
47
|
"@vitejs/plugin-react": "^6.0.3",
|
|
48
48
|
"typescript": "^6.0.3",
|
|
49
|
-
"vite": "^8.1.
|
|
49
|
+
"vite": "^8.1.3",
|
|
50
50
|
"vite-plugin-dts": "^5.0.3"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
package/src/index.tsx
CHANGED
|
@@ -27,7 +27,7 @@ const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
|
|
|
27
27
|
* Pick the most reasonable display name from an arbitrary record object.
|
|
28
28
|
* Tries common name-like keys in priority order, then falls back to undefined.
|
|
29
29
|
*/
|
|
30
|
-
function pickRecordDisplayName(
|
|
30
|
+
export function pickRecordDisplayName(
|
|
31
31
|
record: Record<string, unknown> | null | undefined,
|
|
32
32
|
preferredField?: string,
|
|
33
33
|
): string | undefined {
|
|
@@ -1450,7 +1450,13 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1450
1450
|
} else {
|
|
1451
1451
|
text = String(value);
|
|
1452
1452
|
}
|
|
1453
|
-
|
|
1453
|
+
// inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
|
|
1454
|
+
// actually clamps to the cell width. On a bare inline <span> truncate never
|
|
1455
|
+
// clips — there is no width box — and its `white-space:nowrap` also defeats
|
|
1456
|
+
// the parent cell's `break-words`, so a long name-keyed map / address JSON
|
|
1457
|
+
// spills into the neighbouring column (objectui#2578). The title keeps the
|
|
1458
|
+
// full value on hover.
|
|
1459
|
+
return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
|
|
1454
1460
|
}
|
|
1455
1461
|
|
|
1456
1462
|
/**
|
|
@@ -1760,25 +1766,36 @@ export function formatFileSize(bytes: number): string {
|
|
|
1760
1766
|
export function buildValidationRules(field: any): any {
|
|
1761
1767
|
const rules: any = {};
|
|
1762
1768
|
|
|
1763
|
-
// Required validation
|
|
1769
|
+
// Required validation. Emit a bare `true` for the auto-generated case rather
|
|
1770
|
+
// than baking an English message here: the form renderer localizes it via
|
|
1771
|
+
// `t('validation.required', { field })`. A field-authored `required_message`
|
|
1772
|
+
// still wins and is passed through verbatim.
|
|
1764
1773
|
if (field.required) {
|
|
1765
|
-
rules.required = typeof field.required_message === 'string'
|
|
1766
|
-
? field.required_message
|
|
1767
|
-
:
|
|
1774
|
+
rules.required = typeof field.required_message === 'string'
|
|
1775
|
+
? field.required_message
|
|
1776
|
+
: true;
|
|
1768
1777
|
}
|
|
1769
1778
|
|
|
1779
|
+
// Standard validation messages are localized by the form renderer, which has
|
|
1780
|
+
// an i18n `t` in scope. We therefore emit `message: undefined` for the
|
|
1781
|
+
// auto-generated case and tag each rule with a `messageKey` (+ any interp
|
|
1782
|
+
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1783
|
+
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1784
|
+
|
|
1770
1785
|
// Length validation for text fields
|
|
1771
1786
|
if (field.min_length) {
|
|
1772
1787
|
rules.minLength = {
|
|
1773
1788
|
value: field.min_length,
|
|
1774
|
-
message: field.min_length_message
|
|
1789
|
+
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1790
|
+
messageKey: 'validation.minLength',
|
|
1775
1791
|
};
|
|
1776
1792
|
}
|
|
1777
1793
|
|
|
1778
1794
|
if (field.max_length) {
|
|
1779
1795
|
rules.maxLength = {
|
|
1780
1796
|
value: field.max_length,
|
|
1781
|
-
message: field.max_length_message
|
|
1797
|
+
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1798
|
+
messageKey: 'validation.maxLength',
|
|
1782
1799
|
};
|
|
1783
1800
|
}
|
|
1784
1801
|
|
|
@@ -1786,14 +1803,16 @@ export function buildValidationRules(field: any): any {
|
|
|
1786
1803
|
if (field.min !== undefined) {
|
|
1787
1804
|
rules.min = {
|
|
1788
1805
|
value: field.min,
|
|
1789
|
-
message: field.min_message
|
|
1806
|
+
message: typeof field.min_message === 'string' ? field.min_message : undefined,
|
|
1807
|
+
messageKey: 'validation.min',
|
|
1790
1808
|
};
|
|
1791
1809
|
}
|
|
1792
1810
|
|
|
1793
1811
|
if (field.max !== undefined) {
|
|
1794
1812
|
rules.max = {
|
|
1795
1813
|
value: field.max,
|
|
1796
|
-
message: field.max_message
|
|
1814
|
+
message: typeof field.max_message === 'string' ? field.max_message : undefined,
|
|
1815
|
+
messageKey: 'validation.max',
|
|
1797
1816
|
};
|
|
1798
1817
|
}
|
|
1799
1818
|
|
|
@@ -1801,7 +1820,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1801
1820
|
if (field.pattern) {
|
|
1802
1821
|
rules.pattern = {
|
|
1803
1822
|
value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
|
|
1804
|
-
message: field.pattern_message
|
|
1823
|
+
message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
|
|
1824
|
+
messageKey: 'validation.pattern',
|
|
1805
1825
|
};
|
|
1806
1826
|
}
|
|
1807
1827
|
|
|
@@ -1809,7 +1829,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1809
1829
|
if (field.type === 'email') {
|
|
1810
1830
|
rules.pattern = {
|
|
1811
1831
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
1812
|
-
message:
|
|
1832
|
+
message: undefined,
|
|
1833
|
+
messageKey: 'validation.email',
|
|
1813
1834
|
};
|
|
1814
1835
|
}
|
|
1815
1836
|
|
|
@@ -1817,7 +1838,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1817
1838
|
if (field.type === 'url') {
|
|
1818
1839
|
rules.pattern = {
|
|
1819
1840
|
value: /^https?:\/\/.+/,
|
|
1820
|
-
message:
|
|
1841
|
+
message: undefined,
|
|
1842
|
+
messageKey: 'validation.url',
|
|
1821
1843
|
};
|
|
1822
1844
|
}
|
|
1823
1845
|
|
|
@@ -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
|
* DateField - Date picker input widget
|
|
@@ -20,6 +21,10 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
20
21
|
type="date"
|
|
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
|
);
|
|
@@ -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
|
* DateTimeField - Combined date and time picker widget
|
|
@@ -26,6 +27,10 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
|
|
|
26
27
|
type="datetime-local"
|
|
27
28
|
value={value || ''}
|
|
28
29
|
onChange={(e) => onChange(e.target.value)}
|
|
30
|
+
onClick={(e) => {
|
|
31
|
+
openNativePicker(e.currentTarget);
|
|
32
|
+
domProps.onClick?.(e);
|
|
33
|
+
}}
|
|
29
34
|
disabled={readonly || domProps.disabled}
|
|
30
35
|
/>
|
|
31
36
|
);
|
|
@@ -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
|
+
}
|