@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,11 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { FieldWidgetProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Parse the stored value into the selected capability-name list. The canonical
|
|
5
|
+
* storage is a JSON-string array; we also tolerate an already-parsed array (in
|
|
6
|
+
* case a caller hands us the parsed value) and a bare comma-separated string
|
|
7
|
+
* (legacy/hand-authored) so no selection is silently dropped.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseCapabilityNames(value: unknown): string[];
|
|
10
|
+
export declare function CapabilityMultiSelectField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string | string[]>): React.JSX.Element;
|
|
11
|
+
export default CapabilityMultiSelectField;
|
|
File without changes
|
|
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.2.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.2.0",
|
|
34
|
+
"@object-ui/core": "13.2.0",
|
|
35
|
+
"@object-ui/i18n": "13.2.0",
|
|
36
|
+
"@object-ui/providers": "13.2.0",
|
|
37
|
+
"@object-ui/react": "13.2.0",
|
|
38
|
+
"@object-ui/types": "13.2.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 {
|
|
@@ -179,6 +179,7 @@ import { CurrencyField } from './widgets/CurrencyField';
|
|
|
179
179
|
import { TextAreaField } from './widgets/TextAreaField';
|
|
180
180
|
import { RichTextField } from './widgets/RichTextField';
|
|
181
181
|
import { LookupField } from './widgets/LookupField';
|
|
182
|
+
import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
|
|
182
183
|
import { DateTimeField } from './widgets/DateTimeField';
|
|
183
184
|
import { TimeField } from './widgets/TimeField';
|
|
184
185
|
import { PercentField } from './widgets/PercentField';
|
|
@@ -1450,7 +1451,13 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1450
1451
|
} else {
|
|
1451
1452
|
text = String(value);
|
|
1452
1453
|
}
|
|
1453
|
-
|
|
1454
|
+
// inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
|
|
1455
|
+
// actually clamps to the cell width. On a bare inline <span> truncate never
|
|
1456
|
+
// clips — there is no width box — and its `white-space:nowrap` also defeats
|
|
1457
|
+
// the parent cell's `break-words`, so a long name-keyed map / address JSON
|
|
1458
|
+
// spills into the neighbouring column (objectui#2578). The title keeps the
|
|
1459
|
+
// full value on hover.
|
|
1460
|
+
return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
|
|
1454
1461
|
}
|
|
1455
1462
|
|
|
1456
1463
|
/**
|
|
@@ -1760,25 +1767,36 @@ export function formatFileSize(bytes: number): string {
|
|
|
1760
1767
|
export function buildValidationRules(field: any): any {
|
|
1761
1768
|
const rules: any = {};
|
|
1762
1769
|
|
|
1763
|
-
// Required validation
|
|
1770
|
+
// Required validation. Emit a bare `true` for the auto-generated case rather
|
|
1771
|
+
// than baking an English message here: the form renderer localizes it via
|
|
1772
|
+
// `t('validation.required', { field })`. A field-authored `required_message`
|
|
1773
|
+
// still wins and is passed through verbatim.
|
|
1764
1774
|
if (field.required) {
|
|
1765
|
-
rules.required = typeof field.required_message === 'string'
|
|
1766
|
-
? field.required_message
|
|
1767
|
-
:
|
|
1775
|
+
rules.required = typeof field.required_message === 'string'
|
|
1776
|
+
? field.required_message
|
|
1777
|
+
: true;
|
|
1768
1778
|
}
|
|
1769
1779
|
|
|
1780
|
+
// Standard validation messages are localized by the form renderer, which has
|
|
1781
|
+
// an i18n `t` in scope. We therefore emit `message: undefined` for the
|
|
1782
|
+
// auto-generated case and tag each rule with a `messageKey` (+ any interp
|
|
1783
|
+
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1784
|
+
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1785
|
+
|
|
1770
1786
|
// Length validation for text fields
|
|
1771
1787
|
if (field.min_length) {
|
|
1772
1788
|
rules.minLength = {
|
|
1773
1789
|
value: field.min_length,
|
|
1774
|
-
message: field.min_length_message
|
|
1790
|
+
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1791
|
+
messageKey: 'validation.minLength',
|
|
1775
1792
|
};
|
|
1776
1793
|
}
|
|
1777
1794
|
|
|
1778
1795
|
if (field.max_length) {
|
|
1779
1796
|
rules.maxLength = {
|
|
1780
1797
|
value: field.max_length,
|
|
1781
|
-
message: field.max_length_message
|
|
1798
|
+
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1799
|
+
messageKey: 'validation.maxLength',
|
|
1782
1800
|
};
|
|
1783
1801
|
}
|
|
1784
1802
|
|
|
@@ -1786,14 +1804,16 @@ export function buildValidationRules(field: any): any {
|
|
|
1786
1804
|
if (field.min !== undefined) {
|
|
1787
1805
|
rules.min = {
|
|
1788
1806
|
value: field.min,
|
|
1789
|
-
message: field.min_message
|
|
1807
|
+
message: typeof field.min_message === 'string' ? field.min_message : undefined,
|
|
1808
|
+
messageKey: 'validation.min',
|
|
1790
1809
|
};
|
|
1791
1810
|
}
|
|
1792
1811
|
|
|
1793
1812
|
if (field.max !== undefined) {
|
|
1794
1813
|
rules.max = {
|
|
1795
1814
|
value: field.max,
|
|
1796
|
-
message: field.max_message
|
|
1815
|
+
message: typeof field.max_message === 'string' ? field.max_message : undefined,
|
|
1816
|
+
messageKey: 'validation.max',
|
|
1797
1817
|
};
|
|
1798
1818
|
}
|
|
1799
1819
|
|
|
@@ -1801,7 +1821,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1801
1821
|
if (field.pattern) {
|
|
1802
1822
|
rules.pattern = {
|
|
1803
1823
|
value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
|
|
1804
|
-
message: field.pattern_message
|
|
1824
|
+
message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
|
|
1825
|
+
messageKey: 'validation.pattern',
|
|
1805
1826
|
};
|
|
1806
1827
|
}
|
|
1807
1828
|
|
|
@@ -1809,7 +1830,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1809
1830
|
if (field.type === 'email') {
|
|
1810
1831
|
rules.pattern = {
|
|
1811
1832
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
1812
|
-
message:
|
|
1833
|
+
message: undefined,
|
|
1834
|
+
messageKey: 'validation.email',
|
|
1813
1835
|
};
|
|
1814
1836
|
}
|
|
1815
1837
|
|
|
@@ -1817,7 +1839,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1817
1839
|
if (field.type === 'url') {
|
|
1818
1840
|
rules.pattern = {
|
|
1819
1841
|
value: /^https?:\/\/.+/,
|
|
1820
|
-
message:
|
|
1842
|
+
message: undefined,
|
|
1843
|
+
messageKey: 'validation.url',
|
|
1821
1844
|
};
|
|
1822
1845
|
}
|
|
1823
1846
|
|
|
@@ -2130,6 +2153,12 @@ export function registerFields() {
|
|
|
2130
2153
|
// display widget remains the default for { type: 'html', content }.
|
|
2131
2154
|
ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2132
2155
|
ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2156
|
+
// Capability multi-select (ADR-0056 P2) — reached only via the field
|
|
2157
|
+
// `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
|
|
2158
|
+
// field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
|
|
2159
|
+
// widget (value/onChange from RHF), so the wrapper's extra label + local value
|
|
2160
|
+
// buffer would only duplicate the form's FormLabel and desync the value.
|
|
2161
|
+
ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
|
|
2133
2162
|
// master_detail = child-side FK lookup (single value, typically required). See
|
|
2134
2163
|
// fieldWidgetMap above for rationale.
|
|
2135
2164
|
ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
@@ -2190,6 +2219,7 @@ export * from './widgets/PasswordField';
|
|
|
2190
2219
|
export * from './widgets/TextAreaField';
|
|
2191
2220
|
export * from './widgets/RichTextField';
|
|
2192
2221
|
export * from './widgets/LookupField';
|
|
2222
|
+
export * from './widgets/CapabilityMultiSelectField';
|
|
2193
2223
|
export * from './widgets/RecordPickerDialog';
|
|
2194
2224
|
export * from './widgets/FileField';
|
|
2195
2225
|
export * from './widgets/ImageField';
|
|
@@ -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
|
+
* CapabilityMultiSelectField (ADR-0056 P2, epic #2398).
|
|
11
|
+
*
|
|
12
|
+
* Proves the two properties P2 depends on:
|
|
13
|
+
* 1. the stored value parses tolerantly into a capability-name list, and
|
|
14
|
+
* 2. every edit emits a JSON-**string** array of names — byte-equivalent to
|
|
15
|
+
* the `sys_permission_set.system_permissions` textarea storage, so the
|
|
16
|
+
* round-trip through the picker never changes the on-disk shape.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
21
|
+
import '@testing-library/jest-dom';
|
|
22
|
+
import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField';
|
|
23
|
+
|
|
24
|
+
describe('parseCapabilityNames', () => {
|
|
25
|
+
it('parses the canonical JSON-string array', () => {
|
|
26
|
+
expect(parseCapabilityNames('["setup.access","studio.access"]')).toEqual([
|
|
27
|
+
'setup.access',
|
|
28
|
+
'studio.access',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats empty / null / undefined as no selection', () => {
|
|
33
|
+
expect(parseCapabilityNames('')).toEqual([]);
|
|
34
|
+
expect(parseCapabilityNames(' ')).toEqual([]);
|
|
35
|
+
expect(parseCapabilityNames(null)).toEqual([]);
|
|
36
|
+
expect(parseCapabilityNames(undefined)).toEqual([]);
|
|
37
|
+
expect(parseCapabilityNames('[]')).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('tolerates an already-parsed array', () => {
|
|
41
|
+
expect(parseCapabilityNames(['a', 'b'])).toEqual(['a', 'b']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('falls back to comma-splitting a non-JSON legacy string', () => {
|
|
45
|
+
expect(parseCapabilityNames('setup.access, studio.access')).toEqual([
|
|
46
|
+
'setup.access',
|
|
47
|
+
'studio.access',
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const CAPS = [
|
|
53
|
+
{ name: 'manage_users', label: 'Manage Users', description: 'Manage users', scope: 'platform', active: true },
|
|
54
|
+
{ name: 'studio.access', label: 'Studio Access', description: 'Enter Studio', scope: 'platform', active: true },
|
|
55
|
+
{ name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function mockDataSource(rows = CAPS) {
|
|
59
|
+
return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('CapabilityMultiSelectField', () => {
|
|
63
|
+
it('renders capabilities grouped by scope with human labels', async () => {
|
|
64
|
+
render(
|
|
65
|
+
<CapabilityMultiSelectField
|
|
66
|
+
value={'["studio.access"]'}
|
|
67
|
+
onChange={vi.fn()}
|
|
68
|
+
field={{ name: 'system_permissions' } as any}
|
|
69
|
+
dataSource={mockDataSource()}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
// group headers
|
|
73
|
+
expect(await screen.findByText('Platform')).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText('Organization')).toBeInTheDocument();
|
|
75
|
+
// labels, not raw names
|
|
76
|
+
expect(screen.getByRole('button', { name: 'Studio Access' })).toHaveAttribute('aria-pressed', 'true');
|
|
77
|
+
expect(screen.getByRole('button', { name: 'Manage Users' })).toHaveAttribute('aria-pressed', 'false');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('emits a JSON-string array when a capability is toggled on', async () => {
|
|
81
|
+
const onChange = vi.fn();
|
|
82
|
+
render(
|
|
83
|
+
<CapabilityMultiSelectField
|
|
84
|
+
value={'["studio.access"]'}
|
|
85
|
+
onChange={onChange}
|
|
86
|
+
field={{ name: 'system_permissions' } as any}
|
|
87
|
+
dataSource={mockDataSource()}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' }));
|
|
91
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
92
|
+
const emitted = onChange.mock.calls[0][0];
|
|
93
|
+
// The emitted value must be a JSON *string* (not a raw array), byte-equal
|
|
94
|
+
// to the textarea storage contract.
|
|
95
|
+
expect(typeof emitted).toBe('string');
|
|
96
|
+
expect(JSON.parse(emitted)).toEqual(['studio.access', 'manage_users']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => {
|
|
100
|
+
const onChange = vi.fn();
|
|
101
|
+
render(
|
|
102
|
+
<CapabilityMultiSelectField
|
|
103
|
+
value={'["studio.access","manage_users"]'}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
field={{ name: 'system_permissions' } as any}
|
|
106
|
+
dataSource={mockDataSource()}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Studio Access' }));
|
|
110
|
+
const emitted = onChange.mock.calls[0][0];
|
|
111
|
+
expect(typeof emitted).toBe('string');
|
|
112
|
+
expect(JSON.parse(emitted)).toEqual(['manage_users']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('preserves an unknown/legacy selected name not in the registry', async () => {
|
|
116
|
+
render(
|
|
117
|
+
<CapabilityMultiSelectField
|
|
118
|
+
value={'["studio.access","legacy.custom"]'}
|
|
119
|
+
onChange={vi.fn()}
|
|
120
|
+
field={{ name: 'system_permissions' } as any}
|
|
121
|
+
dataSource={mockDataSource()}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
// The unknown name still renders as a (selected) chip so it is not dropped.
|
|
125
|
+
const legacy = await screen.findByRole('button', { name: 'legacy.custom' });
|
|
126
|
+
expect(legacy).toHaveAttribute('aria-pressed', 'true');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('renders selected labels as read-only badges when readonly', async () => {
|
|
130
|
+
render(
|
|
131
|
+
<CapabilityMultiSelectField
|
|
132
|
+
value={'["studio.access"]'}
|
|
133
|
+
onChange={vi.fn()}
|
|
134
|
+
field={{ name: 'system_permissions' } as any}
|
|
135
|
+
dataSource={mockDataSource()}
|
|
136
|
+
readonly
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
// No toggle buttons in readonly mode.
|
|
140
|
+
expect(await screen.findByText('Studio Access')).toBeInTheDocument();
|
|
141
|
+
expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
+
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
+
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
5
|
+
import { FieldWidgetProps } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CapabilityMultiSelectField — structured picker for a permission set's
|
|
9
|
+
* `system_permissions` (ADR-0056 / epic #2398, phase P2).
|
|
10
|
+
*
|
|
11
|
+
* `sys_permission_set.system_permissions` is framework-declared as a
|
|
12
|
+
* `Field.textarea` that stores a **JSON-serialized array of capability names**
|
|
13
|
+
* (e.g. `["setup.access","studio.access"]`; `security-plugin.ts` writes
|
|
14
|
+
* `JSON.stringify(...)` and reads `parseJson(...)`). Editing that as raw JSON is
|
|
15
|
+
* the anti-pattern ADR-0056 removes. This widget renders the same value as a
|
|
16
|
+
* multi-select over the live `sys_capability` registry — grouped by scope,
|
|
17
|
+
* labelled, with the capability description on hover — while round-tripping the
|
|
18
|
+
* stored value **byte-for-byte** as a JSON string of names.
|
|
19
|
+
*
|
|
20
|
+
* Reached via the field `widget: 'capability-multiselect'` hint (stamped onto
|
|
21
|
+
* the object metadata in MetadataProvider), registered in the field registry as
|
|
22
|
+
* `field:capability-multiselect`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface Capability {
|
|
26
|
+
name: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
scope?: string;
|
|
30
|
+
active?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the stored value into the selected capability-name list. The canonical
|
|
35
|
+
* storage is a JSON-string array; we also tolerate an already-parsed array (in
|
|
36
|
+
* case a caller hands us the parsed value) and a bare comma-separated string
|
|
37
|
+
* (legacy/hand-authored) so no selection is silently dropped.
|
|
38
|
+
*/
|
|
39
|
+
export function parseCapabilityNames(value: unknown): string[] {
|
|
40
|
+
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
const s = value.trim();
|
|
43
|
+
if (!s) return [];
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(s);
|
|
46
|
+
if (Array.isArray(parsed)) return parsed.map((v) => String(v)).filter(Boolean);
|
|
47
|
+
// A JSON scalar (unexpected) — treat as a single name.
|
|
48
|
+
return [String(parsed)].filter(Boolean);
|
|
49
|
+
} catch {
|
|
50
|
+
// Not JSON — fall back to comma-splitting so legacy values survive.
|
|
51
|
+
return s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Scope → group header. Order is intentional (platform powers first). */
|
|
58
|
+
const SCOPE_ORDER = ['platform', 'org', 'other'] as const;
|
|
59
|
+
const SCOPE_LABEL: Record<string, string> = {
|
|
60
|
+
platform: 'Platform',
|
|
61
|
+
org: 'Organization',
|
|
62
|
+
other: 'Other',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function CapabilityMultiSelectField({
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
readonly,
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: FieldWidgetProps<string | string[]>) {
|
|
72
|
+
const ctx = React.useContext(SchemaRendererContext);
|
|
73
|
+
const dataSource: DataSource | null =
|
|
74
|
+
(props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
75
|
+
const disabled = (props as any).disabled as boolean | undefined;
|
|
76
|
+
|
|
77
|
+
const [caps, setCaps] = React.useState<Capability[] | null>(null);
|
|
78
|
+
|
|
79
|
+
const selected = React.useMemo(() => parseCapabilityNames(value), [value]);
|
|
80
|
+
|
|
81
|
+
// Load the live capability registry (active only). Mirrors LookupField's
|
|
82
|
+
// context-dataSource access — createFieldRenderer does not forward the
|
|
83
|
+
// dataSource prop, so the SchemaRenderer context is the reliable source.
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!dataSource || typeof (dataSource as any).find !== 'function') return;
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const res = await dataSource.find('sys_capability', {
|
|
90
|
+
$filter: { active: true },
|
|
91
|
+
$top: 500,
|
|
92
|
+
} as QueryParams);
|
|
93
|
+
const rows: Capability[] =
|
|
94
|
+
(res as any)?.data ?? (res as any)?.records ?? (Array.isArray(res) ? res : []);
|
|
95
|
+
if (!cancelled) setCaps(Array.isArray(rows) ? rows : []);
|
|
96
|
+
} catch {
|
|
97
|
+
if (!cancelled) setCaps([]);
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
};
|
|
103
|
+
}, [dataSource]);
|
|
104
|
+
|
|
105
|
+
// Emit as a JSON string so the value round-trips byte-equivalent to the
|
|
106
|
+
// `system_permissions` textarea's `JSON.stringify(string[])` storage.
|
|
107
|
+
const emit = React.useCallback(
|
|
108
|
+
(names: string[]) => onChange(JSON.stringify(names) as any),
|
|
109
|
+
[onChange],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Union of the fetched registry and any *selected* names not present in it
|
|
113
|
+
// (unknown / legacy / package-owned-inactive), so no current grant is hidden
|
|
114
|
+
// or dropped. Unknown names land in the "other" group.
|
|
115
|
+
const byName = React.useMemo(() => {
|
|
116
|
+
const map = new Map<string, Capability>();
|
|
117
|
+
for (const c of caps ?? []) if (c?.name) map.set(c.name, c);
|
|
118
|
+
for (const n of selected) if (!map.has(n)) map.set(n, { name: n, label: n, scope: 'other' });
|
|
119
|
+
return map;
|
|
120
|
+
}, [caps, selected]);
|
|
121
|
+
|
|
122
|
+
const labelFor = (name: string) => byName.get(name)?.label || name;
|
|
123
|
+
|
|
124
|
+
// Group options by scope for the editable grid. Computed BEFORE the readonly
|
|
125
|
+
// early-return so the hook order stays stable regardless of `readonly`.
|
|
126
|
+
const groups = React.useMemo(() => {
|
|
127
|
+
const buckets = new Map<string, Capability[]>();
|
|
128
|
+
for (const c of byName.values()) {
|
|
129
|
+
const key = (SCOPE_ORDER as readonly string[]).includes(c.scope as string)
|
|
130
|
+
? (c.scope as string)
|
|
131
|
+
: 'other';
|
|
132
|
+
const list = buckets.get(key) ?? [];
|
|
133
|
+
list.push(c);
|
|
134
|
+
buckets.set(key, list);
|
|
135
|
+
}
|
|
136
|
+
return SCOPE_ORDER.filter((s) => buckets.has(s)).map((s) => ({
|
|
137
|
+
scope: s,
|
|
138
|
+
label: SCOPE_LABEL[s] ?? s,
|
|
139
|
+
items: (buckets.get(s) ?? []).sort((a, b) =>
|
|
140
|
+
(a.label || a.name).localeCompare(b.label || b.name),
|
|
141
|
+
),
|
|
142
|
+
}));
|
|
143
|
+
}, [byName]);
|
|
144
|
+
|
|
145
|
+
if (readonly) {
|
|
146
|
+
if (selected.length === 0) return <EmptyValue />;
|
|
147
|
+
return (
|
|
148
|
+
<div className="flex flex-wrap gap-1">
|
|
149
|
+
{selected.map((name) => (
|
|
150
|
+
<Badge key={name} variant="outline" title={byName.get(name)?.description}>
|
|
151
|
+
{labelFor(name)}
|
|
152
|
+
</Badge>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const toggle = (name: string) => {
|
|
159
|
+
const next = selected.includes(name)
|
|
160
|
+
? selected.filter((x) => x !== name)
|
|
161
|
+
: [...selected, name];
|
|
162
|
+
emit(next);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
167
|
+
{caps === null && groups.length === 0 && (
|
|
168
|
+
<span className="text-sm text-muted-foreground">Loading capabilities…</span>
|
|
169
|
+
)}
|
|
170
|
+
{groups.map((group) => (
|
|
171
|
+
<div key={group.scope} className="flex flex-col gap-1.5">
|
|
172
|
+
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
173
|
+
{group.label}
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex flex-wrap gap-1.5">
|
|
176
|
+
{group.items.map((cap) => {
|
|
177
|
+
const active = selected.includes(cap.name);
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
key={cap.name}
|
|
182
|
+
onClick={() => toggle(cap.name)}
|
|
183
|
+
disabled={disabled}
|
|
184
|
+
aria-pressed={active}
|
|
185
|
+
title={cap.description || undefined}
|
|
186
|
+
className={cn(
|
|
187
|
+
'rounded-full border px-3 py-1 text-sm transition-colors disabled:opacity-50',
|
|
188
|
+
active
|
|
189
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
190
|
+
: 'border-input bg-background text-foreground hover:bg-accent',
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{cap.label || cap.name}
|
|
194
|
+
</button>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default CapabilityMultiSelectField;
|
|
@@ -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
|
);
|