@object-ui/fields 16.1.0 → 17.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 +626 -0
- package/dist/FieldEditWidget.d.ts +9 -2
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2426 -2100
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +34 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RichTextField.d.ts +2 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TextAreaField.d.ts +2 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- 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/file-value.d.ts +99 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +119 -3
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +50 -8
- package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
- package/src/__tests__/widget-props-contract.test.tsx +129 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +97 -134
- package/src/widgets/AddressField.tsx +2 -2
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +2 -2
- package/src/widgets/BooleanField.tsx +2 -2
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CheckboxesField.tsx +12 -9
- package/src/widgets/CodeField.tsx +2 -2
- package/src/widgets/ColorField.tsx +2 -2
- package/src/widgets/CurrencyField.tsx +2 -2
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +2 -2
- package/src/widgets/FileField.tsx +90 -59
- package/src/widgets/FilterConditionField.tsx +112 -12
- package/src/widgets/FormulaField.tsx +2 -2
- package/src/widgets/GeolocationField.tsx +2 -2
- package/src/widgets/GridField.tsx +3 -3
- package/src/widgets/ImageField.tsx +73 -35
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +116 -19
- package/src/widgets/MasterDetailField.tsx +2 -2
- package/src/widgets/MultiSelectField.tsx +10 -7
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/ObjectRefField.tsx +13 -8
- package/src/widgets/PasswordField.tsx +2 -2
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/PercentField.tsx +2 -2
- package/src/widgets/PhoneField.tsx +2 -2
- package/src/widgets/QRCodeField.tsx +2 -2
- package/src/widgets/RadioField.tsx +10 -7
- package/src/widgets/RatingField.tsx +2 -2
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +40 -20
- package/src/widgets/RichTextField.tsx +2 -2
- package/src/widgets/SelectField.tsx +5 -5
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +2 -2
- package/src/widgets/SummaryField.tsx +2 -2
- package/src/widgets/TagsField.tsx +3 -3
- package/src/widgets/TextAreaField.tsx +6 -3
- package/src/widgets/TextField.tsx +2 -2
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +2 -2
- package/src/widgets/UserField.tsx +2 -2
- package/src/widgets/VectorField.tsx +2 -2
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +246 -0
- package/src/widgets/types.ts +129 -6
- package/src/widgets/useFieldTranslation.ts +30 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Slider, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* PercentField - Percentage input with configurable decimal precision
|
|
7
7
|
* Stores values as decimals (0-1) and displays as percentages (0-100%)
|
|
8
8
|
* Includes a slider for interactive control.
|
|
9
9
|
*/
|
|
10
|
-
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }:
|
|
10
|
+
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetComponentProps<number>) {
|
|
11
11
|
const percentField = (field || (props as any).schema) as any;
|
|
12
12
|
const precision = percentField?.precision ?? 2;
|
|
13
13
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* PhoneField - Telephone number input with tel link in readonly mode
|
|
7
7
|
* Renders as a clickable tel link when readonly, standard phone input otherwise
|
|
8
8
|
*/
|
|
9
|
-
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }:
|
|
9
|
+
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
|
|
10
10
|
const config = field || (props as any).schema;
|
|
11
11
|
if (readonly) {
|
|
12
12
|
if (!value) return <EmptyValue />;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Button, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { QrCode, Copy } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* QR Code field widget - generates QR codes from text
|
|
8
8
|
* Uses a simple SVG-based QR code generator
|
|
9
9
|
*/
|
|
10
|
-
export function QRCodeField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function QRCodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const [showQR, setShowQR] = React.useState(false);
|
|
12
12
|
const config = field || (props as any).schema;
|
|
13
13
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
6
|
|
|
7
7
|
type Option = OptionLike;
|
|
@@ -30,11 +30,11 @@ export function RadioField({
|
|
|
30
30
|
emptyHint: _emptyHint,
|
|
31
31
|
dataSource: _dataSource,
|
|
32
32
|
...props
|
|
33
|
-
}:
|
|
33
|
+
}: FieldWidgetComponentProps<string>) {
|
|
34
34
|
const config = (field || schema) as any;
|
|
35
35
|
const rawOptions: Option[] = config?.options || [];
|
|
36
36
|
const groupId = useId();
|
|
37
|
-
const fieldName =
|
|
37
|
+
const fieldName = props.name || config?.name || props.id || '';
|
|
38
38
|
|
|
39
39
|
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
40
40
|
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
@@ -81,14 +81,17 @@ export function RadioField({
|
|
|
81
81
|
<RadioGroup
|
|
82
82
|
value={value ?? ''}
|
|
83
83
|
onValueChange={onChange}
|
|
84
|
-
disabled={
|
|
84
|
+
disabled={props.disabled}
|
|
85
85
|
className={className}
|
|
86
86
|
>
|
|
87
87
|
{options.map((opt) => {
|
|
88
|
-
|
|
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}`;
|
|
89
92
|
return (
|
|
90
|
-
<div key={
|
|
91
|
-
<RadioGroupItem value={
|
|
93
|
+
<div key={value} className="flex items-center space-x-2">
|
|
94
|
+
<RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
|
|
92
95
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
93
96
|
</div>
|
|
94
97
|
);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Star } from 'lucide-react';
|
|
3
3
|
import { cn } from '@object-ui/components';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Rating field widget - provides a star rating input
|
|
8
8
|
* Supports numeric values from 0 to max (default 5)
|
|
9
9
|
*/
|
|
10
|
-
export function RatingField({ value, onChange, field, readonly, className, ...props }:
|
|
10
|
+
export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<number>) {
|
|
11
11
|
// Get rating-specific configuration from field metadata
|
|
12
12
|
const ratingField = (field || (props as any).schema) as any;
|
|
13
13
|
const max = ratingField?.max ?? 5;
|
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FieldWidgetComponentProps } 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({
|
|
@@ -47,22 +54,29 @@ export function RecipientPickerField({
|
|
|
47
54
|
readonly,
|
|
48
55
|
className,
|
|
49
56
|
...props
|
|
50
|
-
}:
|
|
57
|
+
}: FieldWidgetComponentProps<string>) {
|
|
51
58
|
const ctx = React.useContext(SchemaRendererContext);
|
|
52
|
-
const
|
|
53
|
-
const
|
|
59
|
+
const { t } = useFieldTranslation();
|
|
60
|
+
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
61
|
+
const disabled = props.disabled;
|
|
54
62
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
55
63
|
const recipientType = String(dependentValues.recipient_type ?? '');
|
|
56
64
|
const mapping = TYPE_TO_OBJECT[recipientType];
|
|
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,14 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Textarea, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { useObjectTranslation } from '@object-ui/react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Rich text field with markdown/HTML support
|
|
8
8
|
* For now, this is a simple textarea. A full implementation would use
|
|
9
9
|
* a rich text editor like TipTap, Lexical, or Slate.
|
|
10
10
|
*/
|
|
11
|
-
export function RichTextField({ value, onChange, field, readonly, errorMessage, ...props }:
|
|
11
|
+
export function RichTextField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
12
|
const { t } = useObjectTranslation();
|
|
13
13
|
if (readonly) {
|
|
14
14
|
return (
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { isValueStillOffered } from '@object-ui/core';
|
|
11
11
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
12
12
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
13
|
-
import {
|
|
13
|
+
import { FieldWidgetComponentProps } from './types';
|
|
14
14
|
import { MultiSelectField } from './MultiSelectField';
|
|
15
15
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
16
16
|
|
|
@@ -29,12 +29,12 @@ import { useCascadingOptions } from './useCascadingOptions';
|
|
|
29
29
|
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
30
30
|
* in lockstep.
|
|
31
31
|
*/
|
|
32
|
-
export function SelectField(props:
|
|
32
|
+
export function SelectField(props: FieldWidgetComponentProps<any>) {
|
|
33
33
|
const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
|
|
34
34
|
if ((config as any)?.multiple) {
|
|
35
35
|
return <MultiSelectField {...props} />;
|
|
36
36
|
}
|
|
37
|
-
return <SingleSelectField {...(props as
|
|
37
|
+
return <SingleSelectField {...(props as FieldWidgetComponentProps<string>)} />;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -59,7 +59,7 @@ function SingleSelectField({
|
|
|
59
59
|
emptyHint: _emptyHint,
|
|
60
60
|
dataSource: _dataSource,
|
|
61
61
|
...props
|
|
62
|
-
}:
|
|
62
|
+
}: FieldWidgetComponentProps<string>) {
|
|
63
63
|
const config = (field || schema) as SelectFieldMetadata;
|
|
64
64
|
const rawOptions = config?.options || [];
|
|
65
65
|
const { t } = useFieldTranslation();
|
|
@@ -67,7 +67,7 @@ function SingleSelectField({
|
|
|
67
67
|
// driven by synthetic DOM events, so e2e must target the trigger/options by a
|
|
68
68
|
// deterministic testid keyed on the field name. `props.name` is the
|
|
69
69
|
// react-hook-form field name spread in by the form renderer (FormField).
|
|
70
|
-
const fieldName =
|
|
70
|
+
const fieldName = props.name || (config as any)?.name || props.id || '';
|
|
71
71
|
|
|
72
72
|
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
73
73
|
const { options, gated, dependsOnFields } = useCascadingOptions(
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React, { useRef, useEffect } from 'react';
|
|
2
2
|
import { Button } from '@object-ui/components';
|
|
3
3
|
import { Eraser } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Signature field widget - provides a signature pad for capturing signatures
|
|
8
8
|
* Outputs signature as a base64 PNG image
|
|
9
9
|
*/
|
|
10
|
-
export function SignatureField({ value, onChange, readonly }:
|
|
10
|
+
export function SignatureField({ value, onChange, readonly }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
12
12
|
const [isDrawing, setIsDrawing] = React.useState(false);
|
|
13
13
|
const [isEmpty, setIsEmpty] = React.useState(!value);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Slider } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Slider field widget - provides a range slider input
|
|
7
7
|
* Supports numeric values with configurable min, max, and step
|
|
8
8
|
*/
|
|
9
|
-
export function SliderField({ value, onChange, field, readonly, ...props }:
|
|
9
|
+
export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
10
10
|
// Get slider-specific configuration from field metadata
|
|
11
11
|
const sliderField = (field || (props as any).schema) as any;
|
|
12
12
|
const min = sliderField?.min ?? 0;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SummaryField - Read-only aggregation field
|
|
7
7
|
* Values are aggregated from related records and cannot be edited
|
|
8
8
|
*/
|
|
9
|
-
export function SummaryField({ value, field, ...props }:
|
|
9
|
+
export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
10
|
const summaryField = (field || (props as any).schema) as any;
|
|
11
11
|
const summaryType = summaryField?.summary_type || 'count';
|
|
12
12
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* TagsField - free-form list of string tags. Type a value and press Enter (or
|
|
7
7
|
* comma) to add it; click a tag's × to remove it. The stored value is a
|
|
8
8
|
* string[]. Used for the `tags` field type.
|
|
9
9
|
*/
|
|
10
|
-
export function TagsField({ value, onChange, field, readonly, className, ...props }:
|
|
10
|
+
export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string[]>) {
|
|
11
11
|
const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
12
12
|
const [draft, setDraft] = React.useState('');
|
|
13
13
|
|
|
@@ -58,7 +58,7 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
|
|
|
58
58
|
onChange={(e) => setDraft(e.target.value)}
|
|
59
59
|
onKeyDown={onKeyDown}
|
|
60
60
|
onBlur={() => addTag(draft)}
|
|
61
|
-
disabled={
|
|
61
|
+
disabled={props.disabled}
|
|
62
62
|
placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
|
|
63
63
|
className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
|
|
64
64
|
/>
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Button,
|
|
11
11
|
} from '@object-ui/components';
|
|
12
12
|
import { Maximize2, Check, X } from 'lucide-react';
|
|
13
|
-
import {
|
|
13
|
+
import { FieldWidgetComponentProps } from './types';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* TextAreaField - Multi-line text input widget
|
|
@@ -21,7 +21,7 @@ import { FieldWidgetProps } from './types';
|
|
|
21
21
|
* a fullscreen edit dialog — much easier on phones than tapping a 4-row
|
|
22
22
|
* textarea trapped between other fields.
|
|
23
23
|
*/
|
|
24
|
-
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }:
|
|
24
|
+
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
|
|
25
25
|
// Hooks must run before any early return (readonly) to keep hook order stable.
|
|
26
26
|
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
|
27
27
|
const [draft, setDraft] = useState(value ?? '');
|
|
@@ -36,7 +36,10 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
|
|
|
36
36
|
|
|
37
37
|
const textareaField = (field || (props as any).schema) as any;
|
|
38
38
|
const rows = textareaField?.rows || 4;
|
|
39
|
-
|
|
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;
|
|
40
43
|
// Mobile fullscreen flag may arrive on the field metadata, on the form-field
|
|
41
44
|
// schema (when called via the form renderer where `field` is the ObjectQL
|
|
42
45
|
// metadata sub-object), or as an explicit widget prop.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Textarea, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { TextareaFieldMetadata } from '@object-ui/types';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* TextField - Standard single-line or multi-line text input
|
|
8
8
|
* Automatically renders as a textarea when rows are configured in field metadata
|
|
9
9
|
*/
|
|
10
|
-
export function TextField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const fieldData = field || (props as any).schema;
|
|
12
12
|
|
|
13
13
|
if (readonly) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
import { openNativePicker } from './openNativePicker';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* TimeField - Time picker input widget
|
|
8
8
|
* Uses native time input for hour and minute selection
|
|
9
9
|
*/
|
|
10
|
-
export function TimeField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function TimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
if (readonly) {
|
|
12
12
|
return <span className="text-sm">{value || <EmptyValue />}</span>;
|
|
13
13
|
}
|
package/src/widgets/UrlField.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* UrlField - URL input with clickable link in readonly mode
|
|
7
7
|
* Validates URLs to only render http/https links for security
|
|
8
8
|
*/
|
|
9
|
-
export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }:
|
|
9
|
+
export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
|
|
10
10
|
const config = field || (props as any).schema;
|
|
11
11
|
if (readonly) {
|
|
12
12
|
if (!value) return <EmptyValue />;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { FieldWidgetComponentProps } from './types';
|
|
3
3
|
import { LookupField } from './LookupField';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -29,7 +29,7 @@ function withBannedFilter(filters?: any[]): any[] {
|
|
|
29
29
|
: [...base, { field: 'banned', operator: 'ne', value: true }];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function UserField(props:
|
|
32
|
+
export function UserField(props: FieldWidgetComponentProps<any>) {
|
|
33
33
|
const raw = (props.field || (props as any).schema) as any;
|
|
34
34
|
|
|
35
35
|
// The objectSchema field metadata may live directly on `field`, or nested at
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* VectorField - Vector/embedding data display
|
|
7
7
|
* Shows vector embeddings in a read-only format
|
|
8
8
|
*/
|
|
9
|
-
export function VectorField({ value, field, ...props }:
|
|
9
|
+
export function VectorField({ value, field, ...props }: FieldWidgetComponentProps<number[]>) {
|
|
10
10
|
const vectorField = (field || (props as any).schema) as any;
|
|
11
11
|
const dimensions = vectorField?.dimensions || (Array.isArray(value) ? value.length : 0);
|
|
12
12
|
|