@object-ui/fields 16.0.0 → 17.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 +630 -0
- package/README.md +45 -7
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +35 -11
- package/dist/index.js +2241 -1853
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +13 -9
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +11 -10
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +137 -135
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +77 -11
- package/src/widgets/FileField.tsx +91 -52
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +106 -65
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +78 -11
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +69 -9
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +8 -3
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
@@ -1,25 +1,82 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
1
|
+
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
|
|
3
|
+
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
|
|
3
4
|
import { FieldWidgetProps } from './types';
|
|
5
|
+
import { useCascadingOptions } from './useCascadingOptions';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
type Option = OptionLike;
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* RadioField - choose exactly one value from a fixed option set, rendered as a
|
|
9
11
|
* radio group. The stored value is the selected option value (string). Used for
|
|
10
12
|
* the `radio` field type.
|
|
13
|
+
*
|
|
14
|
+
* Like the single `SelectField`, options support per-option `visibleWhen`
|
|
15
|
+
* cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
|
|
16
|
+
* {@link useCascadingOptions} hook: the offered radios narrow against the live
|
|
17
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
18
|
+
* first" hint while a dependency is empty, and a value no longer offered (parent
|
|
19
|
+
* changed / predicate flipped) is cleared.
|
|
11
20
|
*/
|
|
12
|
-
export function RadioField({
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
export function RadioField({
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
field,
|
|
25
|
+
readonly,
|
|
26
|
+
className,
|
|
27
|
+
schema,
|
|
28
|
+
dependentValues,
|
|
29
|
+
dependsOn: dependsOnProp,
|
|
30
|
+
emptyHint: _emptyHint,
|
|
31
|
+
dataSource: _dataSource,
|
|
32
|
+
...props
|
|
33
|
+
}: FieldWidgetProps<string>) {
|
|
34
|
+
const config = (field || schema) as any;
|
|
35
|
+
const rawOptions: Option[] = config?.options || [];
|
|
15
36
|
const groupId = useId();
|
|
37
|
+
const fieldName = (props as any).name || config?.name || (props as any).id || '';
|
|
38
|
+
|
|
39
|
+
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
40
|
+
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
41
|
+
rawOptions,
|
|
42
|
+
dependsOn,
|
|
43
|
+
dependentValues,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Cascade clear: once the offered set no longer includes the current value
|
|
47
|
+
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (readonly) return;
|
|
50
|
+
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
51
|
+
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
}, [options, gated]);
|
|
16
54
|
|
|
17
55
|
if (readonly) {
|
|
18
56
|
if (value == null || value === '') return <EmptyValue />;
|
|
19
|
-
|
|
57
|
+
// Label from the raw set so a stored value hidden by `visibleWhen` still
|
|
58
|
+
// renders its label rather than a bare id.
|
|
59
|
+
const opt = rawOptions.find((o) => o.value === value);
|
|
20
60
|
return <span className="text-sm">{opt?.label || String(value)}</span>;
|
|
21
61
|
}
|
|
22
62
|
|
|
63
|
+
// No offered options is unfillable — surface a legible state instead of an
|
|
64
|
+
// empty radio group: a dependency-gated list prompts for its controlling
|
|
65
|
+
// field; an unconfigured / fully-filtered list says so. Mirrors the select.
|
|
66
|
+
if (options.length === 0) {
|
|
67
|
+
const hint = gated
|
|
68
|
+
? `Select ${dependsOnFields.join(' / ')} first`
|
|
69
|
+
: 'No options available';
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
data-testid={fieldName ? `radio-empty-${fieldName}` : undefined}
|
|
73
|
+
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
74
|
+
>
|
|
75
|
+
{hint}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
23
80
|
return (
|
|
24
81
|
<RadioGroup
|
|
25
82
|
value={value ?? ''}
|
|
@@ -28,10 +85,13 @@ export function RadioField({ value, onChange, field, readonly, className, ...pro
|
|
|
28
85
|
className={className}
|
|
29
86
|
>
|
|
30
87
|
{options.map((opt) => {
|
|
31
|
-
|
|
88
|
+
// Radix speaks strings — stringify the (possibly numeric,
|
|
89
|
+
// #3090-widened) authored value at this boundary.
|
|
90
|
+
const value = String(opt.value);
|
|
91
|
+
const id = `${groupId}-${value}`;
|
|
32
92
|
return (
|
|
33
|
-
<div key={
|
|
34
|
-
<RadioGroupItem value={
|
|
93
|
+
<div key={value} className="flex items-center space-x-2">
|
|
94
|
+
<RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
|
|
35
95
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
36
96
|
</div>
|
|
37
97
|
);
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
* RecipientPickerField — the sharing-rule recipient picker (objectstack#3821).
|
|
11
|
+
*
|
|
12
|
+
* Two regressions are pinned here:
|
|
13
|
+
* 1. the candidate query must use the STRUCTURED `$orderby` form. It used to
|
|
14
|
+
* pass the clause string `'name asc'`, which ApiDataSource walked
|
|
15
|
+
* character by character into `0 n,1 a,2 m,…`; the server then sorted by
|
|
16
|
+
* columns that don't exist and every recipient list came back empty, so
|
|
17
|
+
* the control read "No matches" forever and no rule could be saved.
|
|
18
|
+
* 2. the placeholder must come from a per-type i18n key, not from
|
|
19
|
+
* interpolating the enum value into an English sentence.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
24
|
+
import '@testing-library/jest-dom';
|
|
25
|
+
import { RecipientPickerField } from './RecipientPickerField';
|
|
26
|
+
|
|
27
|
+
const USERS = [
|
|
28
|
+
{ id: 'usr_1', name: 'Ada Auditor' },
|
|
29
|
+
{ id: 'usr_2', name: 'Bob Builder' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function mockDataSource(rows: any[] = USERS) {
|
|
33
|
+
return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderPicker(recipientType: string, dataSource: any, value = '') {
|
|
37
|
+
return render(
|
|
38
|
+
<RecipientPickerField
|
|
39
|
+
value={value}
|
|
40
|
+
onChange={vi.fn()}
|
|
41
|
+
field={{ name: 'recipient_id' } as any}
|
|
42
|
+
dataSource={dataSource}
|
|
43
|
+
dependentValues={{ recipient_type: recipientType }}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('RecipientPickerField — candidate query', () => {
|
|
49
|
+
it('orders candidates with the structured $orderby form, never a clause string', async () => {
|
|
50
|
+
const ds = mockDataSource();
|
|
51
|
+
renderPicker('user', ds);
|
|
52
|
+
|
|
53
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
54
|
+
const [object, params] = ds.find.mock.calls[0];
|
|
55
|
+
expect(object).toBe('sys_user');
|
|
56
|
+
expect(params.$orderby).toEqual({ name: 'asc' });
|
|
57
|
+
// A bare string is what regressed; assert the shape explicitly.
|
|
58
|
+
expect(typeof params.$orderby).not.toBe('string');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('queries the object mapped to each recipient type', async () => {
|
|
62
|
+
for (const [type, object] of [
|
|
63
|
+
['user', 'sys_user'],
|
|
64
|
+
['team', 'sys_team'],
|
|
65
|
+
['business_unit', 'sys_business_unit'],
|
|
66
|
+
['unit_and_subordinates', 'sys_business_unit'],
|
|
67
|
+
['position', 'sys_position'],
|
|
68
|
+
] as const) {
|
|
69
|
+
const ds = mockDataSource([]);
|
|
70
|
+
const { unmount } = renderPicker(type, ds);
|
|
71
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
72
|
+
expect(ds.find.mock.calls[0][0]).toBe(object);
|
|
73
|
+
unmount();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows the loaded records as options once the list is opened', async () => {
|
|
78
|
+
const ds = mockDataSource();
|
|
79
|
+
renderPicker('user', ds);
|
|
80
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
81
|
+
|
|
82
|
+
fireEvent.click(screen.getByRole('combobox'));
|
|
83
|
+
expect(await screen.findByText('Ada Auditor')).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText('Bob Builder')).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('RecipientPickerField — keeping the stored recipient', () => {
|
|
89
|
+
function renderControlled(initialType: string, value: string, onChange: any) {
|
|
90
|
+
const ds = mockDataSource();
|
|
91
|
+
const view = render(
|
|
92
|
+
<RecipientPickerField
|
|
93
|
+
value={value}
|
|
94
|
+
onChange={onChange}
|
|
95
|
+
field={{ name: 'recipient_id' } as any}
|
|
96
|
+
dataSource={ds}
|
|
97
|
+
dependentValues={{ recipient_type: initialType }}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
const rerenderWith = (type: string) =>
|
|
101
|
+
view.rerender(
|
|
102
|
+
<RecipientPickerField
|
|
103
|
+
value={value}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
field={{ name: 'recipient_id' } as any}
|
|
106
|
+
dataSource={ds}
|
|
107
|
+
dependentValues={{ recipient_type: type }}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
return { ...view, rerenderWith };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('keeps the saved recipient when the type arrives late (edit form hydrating)', async () => {
|
|
114
|
+
// The edit dialog mounts the widget before the record lands, so
|
|
115
|
+
// recipient_type is '' on the first render and 'user' on the next. That is
|
|
116
|
+
// hydration, not the admin changing the type — clearing there wiped the
|
|
117
|
+
// saved recipient and the blank got persisted on save.
|
|
118
|
+
const onChange = vi.fn();
|
|
119
|
+
const { rerenderWith } = renderControlled('', 'usr_1', onChange);
|
|
120
|
+
rerenderWith('user');
|
|
121
|
+
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clears the recipient when the admin actually switches type', async () => {
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
|
|
127
|
+
rerenderWith('team');
|
|
128
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith(''));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not clear when the type is unset back to empty', async () => {
|
|
132
|
+
const onChange = vi.fn();
|
|
133
|
+
const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
|
|
134
|
+
rerenderWith('');
|
|
135
|
+
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('RecipientPickerField — localizable copy', () => {
|
|
140
|
+
it('takes the placeholder from a per-type key rather than the raw enum value', async () => {
|
|
141
|
+
// With no I18nProvider the widget falls back to the English defaults, so
|
|
142
|
+
// the assertion is on the *wording*: 'Select a business unit' can only come
|
|
143
|
+
// from the keyed string — interpolating the enum produced
|
|
144
|
+
// 'Select a business unit' only by accident and 'Select a unit and
|
|
145
|
+
// subordinates' for the next type over.
|
|
146
|
+
const ds = mockDataSource([]);
|
|
147
|
+
renderPicker('unit_and_subordinates', ds);
|
|
148
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
149
|
+
expect(await screen.findByText('Select a business unit')).toBeInTheDocument();
|
|
150
|
+
expect(screen.queryByText(/unit and subordinates/i)).not.toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('prompts for a recipient type before one is chosen', () => {
|
|
154
|
+
renderPicker('', mockDataSource([]));
|
|
155
|
+
expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { FieldWidgetProps } from './types';
|
|
5
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* RecipientPickerField — dependent record picker for a polymorphic recipient
|
|
@@ -31,14 +32,20 @@ interface RecipientMapping {
|
|
|
31
32
|
storeField: 'id' | 'name';
|
|
32
33
|
/** Candidate display-label fields, in preference order. */
|
|
33
34
|
labelFields: string[];
|
|
35
|
+
/**
|
|
36
|
+
* i18n key for the "choose one" placeholder. Keyed per type rather than
|
|
37
|
+
* interpolating the enum value into an English sentence — "Select a
|
|
38
|
+
* business unit" and "选择业务单元" share no structure (objectstack#3821).
|
|
39
|
+
*/
|
|
40
|
+
placeholderKey: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
|
|
37
|
-
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
|
|
38
|
-
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
|
|
39
|
-
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
40
|
-
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
41
|
-
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
|
|
44
|
+
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' },
|
|
45
|
+
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
|
|
46
|
+
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
|
|
47
|
+
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
|
|
48
|
+
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
|
|
42
49
|
};
|
|
43
50
|
|
|
44
51
|
export function RecipientPickerField({
|
|
@@ -49,6 +56,7 @@ export function RecipientPickerField({
|
|
|
49
56
|
...props
|
|
50
57
|
}: FieldWidgetProps<string>) {
|
|
51
58
|
const ctx = React.useContext(SchemaRendererContext);
|
|
59
|
+
const { t } = useFieldTranslation();
|
|
52
60
|
const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
53
61
|
const disabled = (props as any).disabled as boolean | undefined;
|
|
54
62
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
@@ -57,12 +65,18 @@ export function RecipientPickerField({
|
|
|
57
65
|
|
|
58
66
|
const [records, setRecords] = React.useState<any[] | null>(null);
|
|
59
67
|
|
|
60
|
-
// Reset the stored recipient when the
|
|
61
|
-
// user is not a valid team/business-unit id).
|
|
62
|
-
//
|
|
63
|
-
|
|
68
|
+
// Reset the stored recipient when the admin PICKS a different type (an id for
|
|
69
|
+
// a user is not a valid team/business-unit id).
|
|
70
|
+
//
|
|
71
|
+
// "Different type" means one non-empty type replacing another. The empty
|
|
72
|
+
// string is not a type — it is the edit form before the record has hydrated,
|
|
73
|
+
// and treating `'' → 'user'` as a change wiped the saved recipient the moment
|
|
74
|
+
// an existing rule was opened for editing (objectstack#3821): the picker went
|
|
75
|
+
// blank, and saving persisted the blank. Only a non-empty predecessor can
|
|
76
|
+
// invalidate the stored id.
|
|
77
|
+
const prevType = React.useRef<string>('');
|
|
64
78
|
React.useEffect(() => {
|
|
65
|
-
if (prevType.current
|
|
79
|
+
if (prevType.current && recipientType && prevType.current !== recipientType && value) {
|
|
66
80
|
onChange('' as any);
|
|
67
81
|
}
|
|
68
82
|
prevType.current = recipientType;
|
|
@@ -75,7 +89,11 @@ export function RecipientPickerField({
|
|
|
75
89
|
let cancelled = false;
|
|
76
90
|
(async () => {
|
|
77
91
|
try {
|
|
78
|
-
|
|
92
|
+
// Object form, not the 'name asc' clause string: the clause string is
|
|
93
|
+
// supported again (objectstack#3821 fixed ApiDataSource walking it
|
|
94
|
+
// character by character), but the structured form can't regress that
|
|
95
|
+
// way for any data source.
|
|
96
|
+
const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
|
|
79
97
|
const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
|
|
80
98
|
if (!cancelled) setRecords(Array.isArray(list) ? list : []);
|
|
81
99
|
} catch {
|
|
@@ -105,7 +123,7 @@ export function RecipientPickerField({
|
|
|
105
123
|
if (!recipientType) {
|
|
106
124
|
return (
|
|
107
125
|
<p className={cn('text-sm text-muted-foreground', className)}>
|
|
108
|
-
|
|
126
|
+
{t('fields.recipient.selectTypeFirst')}
|
|
109
127
|
</p>
|
|
110
128
|
);
|
|
111
129
|
}
|
|
@@ -137,12 +155,14 @@ export function RecipientPickerField({
|
|
|
137
155
|
value={value ?? ''}
|
|
138
156
|
onValueChange={(v) => onChange(v as any)}
|
|
139
157
|
placeholder={
|
|
140
|
-
records === null
|
|
158
|
+
records === null
|
|
159
|
+
? t('fields.recipient.loading')
|
|
160
|
+
: t(mapping.placeholderKey ?? 'fields.recipient.select')
|
|
141
161
|
}
|
|
142
|
-
searchPlaceholder=
|
|
143
|
-
emptyText={records === null ? '
|
|
162
|
+
searchPlaceholder={t('fields.recipient.search')}
|
|
163
|
+
emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
|
|
144
164
|
disabled={disabled}
|
|
145
|
-
className={className}
|
|
165
|
+
className={cn('w-full', className)}
|
|
146
166
|
/>
|
|
147
167
|
);
|
|
148
168
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Select,
|
|
4
4
|
SelectContent,
|
|
@@ -7,19 +7,38 @@ 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
|
+
import { isValueStillOffered } from '@object-ui/core';
|
|
17
11
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
18
12
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
19
13
|
import { FieldWidgetProps } from './types';
|
|
14
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
15
|
+
import { useCascadingOptions } from './useCascadingOptions';
|
|
20
16
|
|
|
21
17
|
/**
|
|
22
|
-
* SelectField -
|
|
18
|
+
* SelectField - dropdown selection widget.
|
|
19
|
+
*
|
|
20
|
+
* A field declared `multiple: true` selects zero-or-more values (spec:
|
|
21
|
+
* `multiple` is valid on `select`), so it renders the multi-value chip picker
|
|
22
|
+
* — the same widget the `multiselect` type uses. Delegating here (rather than
|
|
23
|
+
* only at a type-resolution layer) means every surface that renders the
|
|
24
|
+
* `select` widget — the object form, the inline grid editor, and
|
|
25
|
+
* `ActionParamDialog` — inherits multi-select identically, with no drift
|
|
26
|
+
* between them. Single-value selects keep the cascading dropdown below.
|
|
27
|
+
*
|
|
28
|
+
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
|
|
29
|
+
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
30
|
+
* in lockstep.
|
|
31
|
+
*/
|
|
32
|
+
export function SelectField(props: FieldWidgetProps<any>) {
|
|
33
|
+
const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
|
|
34
|
+
if ((config as any)?.multiple) {
|
|
35
|
+
return <MultiSelectField {...props} />;
|
|
36
|
+
}
|
|
37
|
+
return <SingleSelectField {...(props as FieldWidgetProps<string>)} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* SingleSelectField - single-value dropdown with configurable options.
|
|
23
42
|
*
|
|
24
43
|
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
25
44
|
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
@@ -29,7 +48,7 @@ import { FieldWidgetProps } from './types';
|
|
|
29
48
|
* of those is empty the control is gated with a "select the parent first" hint,
|
|
30
49
|
* mirroring the dependent-lookup UX.
|
|
31
50
|
*/
|
|
32
|
-
|
|
51
|
+
function SingleSelectField({
|
|
33
52
|
value,
|
|
34
53
|
onChange,
|
|
35
54
|
field,
|
|
@@ -50,28 +69,11 @@ export function SelectField({
|
|
|
50
69
|
// react-hook-form field name spread in by the form renderer (FormField).
|
|
51
70
|
const fieldName = (props as any).name || (config as any)?.name || props.id || '';
|
|
52
71
|
|
|
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
72
|
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
64
|
-
const dependsOnFields =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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],
|
|
73
|
+
const { options, gated, dependsOnFields } = useCascadingOptions(
|
|
74
|
+
rawOptions,
|
|
75
|
+
dependsOn,
|
|
76
|
+
dependentValues,
|
|
75
77
|
);
|
|
76
78
|
|
|
77
79
|
// Cascade clear: once the offered set no longer includes the current value
|
|
@@ -22,6 +22,10 @@ import { FieldWidgetProps } from './types';
|
|
|
22
22
|
* textarea trapped between other fields.
|
|
23
23
|
*/
|
|
24
24
|
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
25
|
+
// Hooks must run before any early return (readonly) to keep hook order stable.
|
|
26
|
+
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
|
27
|
+
const [draft, setDraft] = useState(value ?? '');
|
|
28
|
+
|
|
25
29
|
if (readonly) {
|
|
26
30
|
return (
|
|
27
31
|
<div className="text-sm whitespace-pre-wrap">
|
|
@@ -32,7 +36,10 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
|
|
|
32
36
|
|
|
33
37
|
const textareaField = (field || (props as any).schema) as any;
|
|
34
38
|
const rows = textareaField?.rows || 4;
|
|
35
|
-
|
|
39
|
+
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
|
|
40
|
+
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
|
|
41
|
+
// spec-authored maxLength gave neither the textarea cap nor the counter.
|
|
42
|
+
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
|
|
36
43
|
// Mobile fullscreen flag may arrive on the field metadata, on the form-field
|
|
37
44
|
// schema (when called via the form renderer where `field` is the ObjectQL
|
|
38
45
|
// metadata sub-object), or as an explicit widget prop.
|
|
@@ -42,8 +49,6 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
|
|
|
42
49
|
(props as any).mobile_fullscreen ??
|
|
43
50
|
(props as any).schema?.mobile_fullscreen,
|
|
44
51
|
);
|
|
45
|
-
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
|
46
|
-
const [draft, setDraft] = useState(value ?? '');
|
|
47
52
|
|
|
48
53
|
const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
|
|
49
54
|
const cancelFullscreen = () => setFullscreenOpen(false);
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
* Empty-criteria guard for the sharing-rule criteria builder
|
|
11
|
+
* (objectstack#3896).
|
|
12
|
+
*
|
|
13
|
+
* This widget used to tell an admin that an empty criteria meant "All
|
|
14
|
+
* records" — the most dangerous thing it could say, because it was describing
|
|
15
|
+
* a bug as a feature. A sharing rule with no predicate was stored as
|
|
16
|
+
* `criteria_json: null` and then evaluated as `find(object, { filter: {} })`,
|
|
17
|
+
* i.e. every record of the object granted to the recipient. `SharingRuleSchema`
|
|
18
|
+
* had always forbidden that shape ("never seeded as a permissive match-all",
|
|
19
|
+
* ADR-0049); the REST and data-API entries just never checked.
|
|
20
|
+
*
|
|
21
|
+
* The server now refuses to save such a rule and shares nothing for one
|
|
22
|
+
* already stored. These tests pin the client-side mirror of that predicate, so
|
|
23
|
+
* the widget warns while the admin is still looking at the empty builder
|
|
24
|
+
* rather than after a rejected save.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { isMatchAllCriteria } from '../FilterConditionField';
|
|
28
|
+
|
|
29
|
+
describe('isMatchAllCriteria — shapes that would share every record', () => {
|
|
30
|
+
it('flags the empty and vacuous shapes', () => {
|
|
31
|
+
for (const shape of [
|
|
32
|
+
undefined,
|
|
33
|
+
null,
|
|
34
|
+
{}, // what a blank builder / blank textarea parses to
|
|
35
|
+
[],
|
|
36
|
+
true,
|
|
37
|
+
42,
|
|
38
|
+
{ $and: [] },
|
|
39
|
+
{ $or: [] },
|
|
40
|
+
{ $and: [{}] },
|
|
41
|
+
{ $or: [{ stage: 'won' }, {}] }, // one unconstrained branch opens the OR
|
|
42
|
+
]) {
|
|
43
|
+
expect(isMatchAllCriteria(shape), `${JSON.stringify(shape) ?? String(shape)}`).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('passes anything that actually narrows the result set', () => {
|
|
48
|
+
for (const shape of [
|
|
49
|
+
{ stage: 'won' },
|
|
50
|
+
{ amount: { $gte: 100000 } },
|
|
51
|
+
[{ stage: 'won' }],
|
|
52
|
+
{ $and: [{ stage: 'won' }] },
|
|
53
|
+
{ $or: [{ a: 1 }, { b: 2 }] },
|
|
54
|
+
{ health: 'red', budget: { $gt: 1 } },
|
|
55
|
+
]) {
|
|
56
|
+
expect(isMatchAllCriteria(shape), JSON.stringify(shape)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('agrees with the server on the reported case: a rule saved with no criteria', () => {
|
|
61
|
+
// The builder emits '' for an empty group, which the widget parses to {}.
|
|
62
|
+
expect(isMatchAllCriteria({})).toBe(true);
|
|
63
|
+
// …and one real condition is enough to clear the gate.
|
|
64
|
+
expect(isMatchAllCriteria({ health: 'red' })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|