@object-ui/fields 13.1.0 → 14.0.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 +97 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1776 -1194
- package/dist/widgets/CapabilityMultiSelectField.d.ts +11 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/FilterConditionField.d.ts +4 -0
- package/dist/widgets/ObjectRefField.d.ts +4 -0
- package/dist/widgets/RecipientPickerField.d.ts +4 -0
- package/package.json +9 -9
- package/src/FieldEditWidget.tsx +3 -0
- package/src/index.tsx +26 -0
- package/src/widgets/CapabilityMultiSelectField.test.tsx +143 -0
- package/src/widgets/CapabilityMultiSelectField.tsx +204 -0
- package/src/widgets/FilterConditionField.tsx +343 -0
- package/src/widgets/ObjectRefField.tsx +106 -0
- package/src/widgets/RecipientPickerField.tsx +150 -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
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { FieldWidgetProps } from './types';
|
|
3
|
+
export declare function FilterConditionField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string | object>): React.JSX.Element;
|
|
4
|
+
export default FilterConditionField;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
28
|
+
"lucide-react": "^1.24.0",
|
|
29
29
|
"react-markdown": "^10.1.0",
|
|
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": "14.0.0",
|
|
34
|
+
"@object-ui/core": "14.0.0",
|
|
35
|
+
"@object-ui/i18n": "14.0.0",
|
|
36
|
+
"@object-ui/providers": "14.0.0",
|
|
37
|
+
"@object-ui/react": "14.0.0",
|
|
38
|
+
"@object-ui/types": "14.0.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.4",
|
|
50
50
|
"vite-plugin-dts": "^5.0.3"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
package/src/FieldEditWidget.tsx
CHANGED
|
@@ -106,6 +106,9 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
|
|
|
106
106
|
// Containers / non-authorable — a sub-form / sub-grid / embedding vector
|
|
107
107
|
// doesn't belong in a single cell.
|
|
108
108
|
'object', 'grid', 'vector',
|
|
109
|
+
// Widget-hint-only pickers — authored in the record form (they depend on
|
|
110
|
+
// sibling fields / a loaded object catalog), not inline in a grid cell.
|
|
111
|
+
'object-ref', 'filter-condition', 'recipient-picker',
|
|
109
112
|
]);
|
|
110
113
|
|
|
111
114
|
/** Field types whose value is chosen in one discrete gesture (no free typing). */
|
package/src/index.tsx
CHANGED
|
@@ -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';
|
|
@@ -2021,6 +2022,16 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
|
|
|
2021
2022
|
'geolocation': () => import('./widgets/GeolocationField').then(m => ({ default: m.GeolocationField })),
|
|
2022
2023
|
'signature': () => import('./widgets/SignatureField').then(m => ({ default: m.SignatureField })),
|
|
2023
2024
|
'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
|
|
2025
|
+
|
|
2026
|
+
// Widget-hint-only pickers (reached via a field `widget:` override, never a
|
|
2027
|
+
// bare field `type`). They render a *picker* over machine data an admin would
|
|
2028
|
+
// otherwise have to type — used by sys_sharing_rule (ADR-0056 P2 pattern):
|
|
2029
|
+
// object-ref → choose a registered object by name
|
|
2030
|
+
// filter-condition → visual criteria builder scoped to the chosen object
|
|
2031
|
+
// recipient-picker → record picker whose target follows a sibling type
|
|
2032
|
+
'object-ref': () => import('./widgets/ObjectRefField').then(m => ({ default: m.ObjectRefField })),
|
|
2033
|
+
'filter-condition': () => import('./widgets/FilterConditionField').then(m => ({ default: m.FilterConditionField })),
|
|
2034
|
+
'recipient-picker': () => import('./widgets/RecipientPickerField').then(m => ({ default: m.RecipientPickerField })),
|
|
2024
2035
|
};
|
|
2025
2036
|
|
|
2026
2037
|
/**
|
|
@@ -2070,6 +2081,11 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
|
|
|
2070
2081
|
// same "bare-name fallback overwritten" warning at every boot regardless.
|
|
2071
2082
|
'time',
|
|
2072
2083
|
'address',
|
|
2084
|
+
// Widget-hint-only pickers — resolved solely via `field:<widget>`, so the
|
|
2085
|
+
// bare-key fallback is never wanted.
|
|
2086
|
+
'object-ref',
|
|
2087
|
+
'filter-condition',
|
|
2088
|
+
'recipient-picker',
|
|
2073
2089
|
]);
|
|
2074
2090
|
|
|
2075
2091
|
export function registerField(fieldType: string): void {
|
|
@@ -2152,6 +2168,12 @@ export function registerFields() {
|
|
|
2152
2168
|
// display widget remains the default for { type: 'html', content }.
|
|
2153
2169
|
ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2154
2170
|
ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2171
|
+
// Capability multi-select (ADR-0056 P2) — reached only via the field
|
|
2172
|
+
// `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
|
|
2173
|
+
// field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
|
|
2174
|
+
// widget (value/onChange from RHF), so the wrapper's extra label + local value
|
|
2175
|
+
// buffer would only duplicate the form's FormLabel and desync the value.
|
|
2176
|
+
ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
|
|
2155
2177
|
// master_detail = child-side FK lookup (single value, typically required). See
|
|
2156
2178
|
// fieldWidgetMap above for rationale.
|
|
2157
2179
|
ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
@@ -2212,6 +2234,10 @@ export * from './widgets/PasswordField';
|
|
|
2212
2234
|
export * from './widgets/TextAreaField';
|
|
2213
2235
|
export * from './widgets/RichTextField';
|
|
2214
2236
|
export * from './widgets/LookupField';
|
|
2237
|
+
export * from './widgets/CapabilityMultiSelectField';
|
|
2238
|
+
export * from './widgets/ObjectRefField';
|
|
2239
|
+
export * from './widgets/FilterConditionField';
|
|
2240
|
+
export * from './widgets/RecipientPickerField';
|
|
2215
2241
|
export * from './widgets/RecordPickerDialog';
|
|
2216
2242
|
export * from './widgets/FileField';
|
|
2217
2243
|
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;
|