@object-ui/fields 17.2.0 → 17.5.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 +1533 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageField — a declared `maxSize` is enforced BEFORE any upload starts
|
|
3
|
+
* (objectui#4141).
|
|
4
|
+
*
|
|
5
|
+
* The defect this pins: `paramToField` copies `maxSize` onto the field config
|
|
6
|
+
* for every param type, so the image widget RECEIVES the constraint — it just
|
|
7
|
+
* never read it. An oversized pick therefore fired the whole
|
|
8
|
+
* `presigned → PUT → complete` chain and rendered a preview, while the sibling
|
|
9
|
+
* file param rejected the same pick with zero uploads.
|
|
10
|
+
*
|
|
11
|
+
* So the assertion that matters is **zero upload attempts**, not "no preview":
|
|
12
|
+
* a widget that uploads the bytes and then hides the thumbnail has still
|
|
13
|
+
* stored an over-limit file. The spy sits on the `UploadProvider` adapter —
|
|
14
|
+
* the layer that performs the presigned request — so a call there IS the
|
|
15
|
+
* network attempt.
|
|
16
|
+
*
|
|
17
|
+
* Two upload entry points exist on this widget and both are covered here:
|
|
18
|
+
* 1. the native picker (`handleFileChange`, single and `multiple`);
|
|
19
|
+
* 2. the crop dialog's confirm (`handleCropConfirm`), which uploads a
|
|
20
|
+
* freshly re-encoded PNG blob — canvas re-encoding can push a crop of an
|
|
21
|
+
* in-limit JPEG *over* the limit, so it is a real second door.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
24
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
25
|
+
import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
|
|
26
|
+
import { ImageField } from './ImageField';
|
|
27
|
+
// Imported at module scope (not awaited in a hook) so the widget's
|
|
28
|
+
// `React.lazy(() => import('./ImageCropperDialog'))` resolves from the ESM
|
|
29
|
+
// cache instead of racing RTL's 1000ms `findBy` budget — AGENTS.md §测试纪律.
|
|
30
|
+
import './ImageCropperDialog';
|
|
31
|
+
|
|
32
|
+
const MB = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** A File whose reported `size` is the declared one (constructor bytes are not). */
|
|
35
|
+
function imageOfSize(name: string, bytes: number): File {
|
|
36
|
+
const file = new File(['x'], name, { type: 'image/png' });
|
|
37
|
+
Object.defineProperty(file, 'size', { value: bytes });
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function blobOfSize(bytes: number): Blob {
|
|
42
|
+
const blob = new Blob(['x'], { type: 'image/png' });
|
|
43
|
+
Object.defineProperty(blob, 'size', { value: bytes });
|
|
44
|
+
return blob;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderField(field: Record<string, unknown>, value: unknown = undefined) {
|
|
48
|
+
const upload = vi.fn(async (f: File | Blob) => ({
|
|
49
|
+
url: 'https://cdn.example/uploaded.png',
|
|
50
|
+
name: (f as File).name ?? 'cropped.png',
|
|
51
|
+
size: f.size,
|
|
52
|
+
mimeType: f.type || 'image/png',
|
|
53
|
+
}));
|
|
54
|
+
const adapter: UploadAdapter = { name: 'spy', upload };
|
|
55
|
+
const onChange = vi.fn();
|
|
56
|
+
render(
|
|
57
|
+
<UploadProvider adapter={adapter}>
|
|
58
|
+
<ImageField field={field as any} value={value} onChange={onChange} />
|
|
59
|
+
</UploadProvider>,
|
|
60
|
+
);
|
|
61
|
+
return { upload, onChange, input: document.querySelector('input[type="file"]') as HTMLInputElement };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('ImageField — client-side maxSize guard (#4141)', () => {
|
|
65
|
+
it('never reaches the upload layer for a pick over the declared maxSize', async () => {
|
|
66
|
+
const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
|
|
67
|
+
|
|
68
|
+
fireEvent.change(input, { target: { files: [imageOfSize('huge.png', 6.3 * MB)] } });
|
|
69
|
+
|
|
70
|
+
// The message is the proof the guard ran; the spy is the proof nothing was sent.
|
|
71
|
+
await screen.findByText(/"huge\.png" exceeds max size \(5\.0 MB\)/);
|
|
72
|
+
expect(upload).not.toHaveBeenCalled();
|
|
73
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('still uploads a pick inside the declared maxSize', async () => {
|
|
77
|
+
const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
|
|
78
|
+
|
|
79
|
+
fireEvent.change(input, { target: { files: [imageOfSize('ok.png', 1 * MB)] } });
|
|
80
|
+
|
|
81
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
82
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(screen.queryByText(/exceeds max size/)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uploads unrestricted when the param declares no maxSize', async () => {
|
|
87
|
+
const { upload, onChange, input } = renderField({ name: 'p_cover' });
|
|
88
|
+
|
|
89
|
+
fireEvent.change(input, { target: { files: [imageOfSize('enormous.png', 500 * MB)] } });
|
|
90
|
+
|
|
91
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
92
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(screen.queryByText(/exceeds max size/)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects only the oversized picks of a multi-select and uploads the rest', async () => {
|
|
97
|
+
const { upload, onChange, input } = renderField({ name: 'p_gallery', multiple: true, maxSize: 5 * MB });
|
|
98
|
+
|
|
99
|
+
fireEvent.change(input, {
|
|
100
|
+
target: { files: [imageOfSize('small.png', 1 * MB), imageOfSize('huge.png', 6.3 * MB)] },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
104
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect((upload.mock.calls[0][0] as File).name).toBe('small.png');
|
|
106
|
+
expect(screen.getByText(/"huge\.png" exceeds max size \(5\.0 MB\)/)).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('guards the crop dialog too — an oversized re-encode is never uploaded', async () => {
|
|
110
|
+
const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
|
|
111
|
+
const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByTestId('image-field-crop-0'));
|
|
114
|
+
const confirm = await screen.findByTestId('fake-crop-confirm');
|
|
115
|
+
fireEvent.click(confirm);
|
|
116
|
+
|
|
117
|
+
await screen.findByText(/exceeds max size \(5\.0 MB\)/);
|
|
118
|
+
expect(upload).not.toHaveBeenCalled();
|
|
119
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('still uploads a crop that fits inside the declared maxSize', async () => {
|
|
123
|
+
const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
|
|
124
|
+
const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
|
|
125
|
+
|
|
126
|
+
fireEvent.click(screen.getByTestId('image-field-crop-0'));
|
|
127
|
+
fireEvent.click(await screen.findByTestId('fake-crop-confirm-small'));
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
130
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The real cropper is a canvas + `toBlob` pipeline that happy-dom cannot run;
|
|
136
|
+
* this stand-in drives the ONE contract ImageField has with it — `onConfirm`
|
|
137
|
+
* hands back a blob and a name — with a blob size on either side of the limit.
|
|
138
|
+
*/
|
|
139
|
+
vi.mock('./ImageCropperDialog', () => ({
|
|
140
|
+
ImageCropperDialog: ({ onConfirm }: { onConfirm: (blob: Blob, name: string) => void }) => (
|
|
141
|
+
<div>
|
|
142
|
+
<button type="button" data-testid="fake-crop-confirm" onClick={() => onConfirm(blobOfSize(6.3 * MB), 'cover.png')}>
|
|
143
|
+
confirm big
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
data-testid="fake-crop-confirm-small"
|
|
148
|
+
onClick={() => onConfirm(blobOfSize(0.5 * MB), 'cover.png')}
|
|
149
|
+
>
|
|
150
|
+
confirm small
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
),
|
|
154
|
+
}));
|
|
@@ -4,8 +4,10 @@ import { useUpload } from '@object-ui/providers';
|
|
|
4
4
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
5
5
|
import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
|
|
6
6
|
import { FieldWidgetComponentProps } from './types';
|
|
7
|
+
import { toDomProps } from './toDomProps';
|
|
7
8
|
import { ImageLightbox } from './ImageLightbox';
|
|
8
9
|
import { useUploadingSignal } from './useUploadingSignal';
|
|
10
|
+
import { maxSizeError, type TranslateFn } from './file-size-guard';
|
|
9
11
|
import {
|
|
10
12
|
fileValueForSubmit,
|
|
11
13
|
readFileValues,
|
|
@@ -24,11 +26,17 @@ const ImageCropperDialog = lazy(() =>
|
|
|
24
26
|
* ImageField - Image upload widget with preview thumbnails
|
|
25
27
|
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
26
28
|
*/
|
|
27
|
-
export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
|
|
29
|
+
export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
28
30
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
-
const imageField =
|
|
31
|
+
const imageField = field as any;
|
|
30
32
|
const multiple = imageField?.multiple || false;
|
|
31
33
|
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
34
|
+
/**
|
|
35
|
+
* Max upload size in bytes. Delivered by `paramToField` for an action param
|
|
36
|
+
* and by the field config for a record field; enforced before any upload
|
|
37
|
+
* starts, exactly as FileField does (objectui#4141).
|
|
38
|
+
*/
|
|
39
|
+
const maxSize = imageField?.maxSize as number | undefined;
|
|
32
40
|
/**
|
|
33
41
|
* Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
|
|
34
42
|
*/
|
|
@@ -38,6 +46,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
38
46
|
const { upload } = useUpload();
|
|
39
47
|
const { t } = useObjectTranslation();
|
|
40
48
|
const [uploading, setUploading] = useState(false);
|
|
49
|
+
/** Client-side rejections (oversize picks), cleared on the next attempt. */
|
|
50
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
41
51
|
// Display details of just-uploaded images, keyed by their new `sys_file` id.
|
|
42
52
|
// Submitting the reference form means the field value no longer carries the
|
|
43
53
|
// URL a thumbnail needs; these keep the preview visible until the next read
|
|
@@ -58,6 +68,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
58
68
|
const handleCropConfirm = useCallback(
|
|
59
69
|
async (blob: Blob, name: string) => {
|
|
60
70
|
if (!cropTarget) return;
|
|
71
|
+
// The size that matters here is the CROP's, not the source image's: the
|
|
72
|
+
// cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a
|
|
73
|
+
// blob over the limit. Checked before `setUploading` so a rejected crop
|
|
74
|
+
// never flashes the spinner, and the dialog closes so the message lands
|
|
75
|
+
// in the field's error row rather than behind the open dialog.
|
|
76
|
+
const rejection = maxSizeError(t as TranslateFn, { name, size: blob.size }, maxSize);
|
|
77
|
+
if (rejection) {
|
|
78
|
+
setErrors([rejection]);
|
|
79
|
+
setCropTarget(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setErrors([]);
|
|
61
83
|
setUploading(true);
|
|
62
84
|
try {
|
|
63
85
|
const result = await upload(blob);
|
|
@@ -75,7 +97,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
75
97
|
setCropTarget(null);
|
|
76
98
|
}
|
|
77
99
|
},
|
|
78
|
-
[cropTarget, images, multiple, onChange, upload, remember],
|
|
100
|
+
[cropTarget, images, multiple, onChange, upload, remember, maxSize, t],
|
|
79
101
|
);
|
|
80
102
|
|
|
81
103
|
const openCropper = useCallback(
|
|
@@ -127,12 +149,32 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
127
149
|
|
|
128
150
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
129
151
|
const selectedFiles = Array.from(e.target.files || []);
|
|
152
|
+
// Reset the input up front rather than in `finally`: a pick rejected below
|
|
153
|
+
// returns before the upload runs, and leaving the old value in place would
|
|
154
|
+
// stop the user re-picking the same name after shrinking the image.
|
|
155
|
+
// `selectedFiles` already holds the File objects, so clearing is safe.
|
|
156
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
130
157
|
if (selectedFiles.length === 0) return;
|
|
131
158
|
|
|
159
|
+
// Enforce the declared limit before anything touches the network. Partial
|
|
160
|
+
// acceptance matches FileField: the in-limit picks of a multi-select still
|
|
161
|
+
// upload, and only the oversize ones are reported.
|
|
162
|
+
const rejections: string[] = [];
|
|
163
|
+
const validFiles = selectedFiles.filter((file) => {
|
|
164
|
+
const rejection = maxSizeError(t as TranslateFn, file, maxSize);
|
|
165
|
+
if (rejection) {
|
|
166
|
+
rejections.push(rejection);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
setErrors(rejections);
|
|
172
|
+
if (validFiles.length === 0) return;
|
|
173
|
+
|
|
132
174
|
setUploading(true);
|
|
133
175
|
try {
|
|
134
176
|
const imageObjects = await Promise.all(
|
|
135
|
-
|
|
177
|
+
validFiles.map(async (file) => {
|
|
136
178
|
const result = await upload(file);
|
|
137
179
|
remember(result, file.name);
|
|
138
180
|
return fileValueForSubmit(result, file.name);
|
|
@@ -146,8 +188,6 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
146
188
|
}
|
|
147
189
|
} finally {
|
|
148
190
|
setUploading(false);
|
|
149
|
-
// Reset input so picking the same file again still triggers change.
|
|
150
|
-
if (inputRef.current) inputRef.current.value = '';
|
|
151
191
|
}
|
|
152
192
|
};
|
|
153
193
|
|
|
@@ -212,12 +252,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
212
252
|
)}
|
|
213
253
|
|
|
214
254
|
<Button
|
|
255
|
+
// DOM pass-through onto the widget's real focusable control — the
|
|
256
|
+
// upload button is the keyboard path to the hidden file input
|
|
257
|
+
// (objectui#3318).
|
|
258
|
+
{...toDomProps(props)}
|
|
215
259
|
type="button"
|
|
216
260
|
variant="outline"
|
|
217
261
|
onClick={() => inputRef.current?.click()}
|
|
218
262
|
className="w-full"
|
|
219
263
|
disabled={uploading}
|
|
220
264
|
data-testid="image-field-upload-button"
|
|
265
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
266
|
+
aria-invalid={!!error}
|
|
221
267
|
>
|
|
222
268
|
{uploading ? (
|
|
223
269
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
@@ -230,6 +276,17 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
230
276
|
? t('fields.image.addMore')
|
|
231
277
|
: t('fields.image.upload')}
|
|
232
278
|
</Button>
|
|
279
|
+
|
|
280
|
+
{/* Client-side rejections (oversize picks). Same presentation as
|
|
281
|
+
FileField's error row — this widget previously had no surface for
|
|
282
|
+
them at all, because it never rejected anything (objectui#4141). */}
|
|
283
|
+
{errors.length > 0 && (
|
|
284
|
+
<div className="space-y-0.5">
|
|
285
|
+
{errors.map((err, i) => (
|
|
286
|
+
<p key={i} className="text-xs text-destructive">{err}</p>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
233
290
|
</div>
|
|
234
291
|
|
|
235
292
|
{cropEnabled && cropTarget && (
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* LocationField - Geographic coordinate input for latitude and longitude
|
|
7
8
|
* Stores location as { latitude, longitude } object and displays as comma-separated pair
|
|
8
9
|
*/
|
|
9
|
-
export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
-
const config = field
|
|
10
|
+
export function LocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
11
|
+
const config = field;
|
|
11
12
|
// Location is stored as { latitude, longitude } object
|
|
12
13
|
// For display, convert to "latitude, longitude" string format
|
|
13
14
|
const displayValue = value && typeof value === 'object'
|
|
@@ -39,12 +40,18 @@ export function LocationField({ value, onChange, field, readonly, ...props }: Fi
|
|
|
39
40
|
|
|
40
41
|
return (
|
|
41
42
|
<Input
|
|
43
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
44
|
+
{...toDomProps(props)}
|
|
42
45
|
type="text"
|
|
43
46
|
value={displayValue}
|
|
44
47
|
onChange={handleChange}
|
|
45
48
|
placeholder={config?.placeholder || 'latitude, longitude'}
|
|
46
49
|
disabled={readonly || props.disabled}
|
|
47
50
|
className={props.className}
|
|
51
|
+
// AFTER the spread so this widget's own computation wins: `error` is
|
|
52
|
+
// the published validation slot (#3222), `!!undefined` → explicit
|
|
53
|
+
// "false".
|
|
54
|
+
aria-invalid={!!error}
|
|
48
55
|
/>
|
|
49
56
|
);
|
|
50
57
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
* A gated lookup names its controlling field by LABEL — objectstack#5407.
|
|
11
|
+
*
|
|
12
|
+
* The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
|
|
13
|
+
* its `{{fields}}` interpolation came straight off `depends_on`, which holds
|
|
14
|
+
* API names. So the rendered hint was localized around a raw identifier in
|
|
15
|
+
* every locale, English included:
|
|
16
|
+
*
|
|
17
|
+
* en Select crm_account first
|
|
18
|
+
* zh-CN 请先选择crm_account
|
|
19
|
+
*
|
|
20
|
+
* The `en` row is why this is not merely an i18n bug: an English user was shown
|
|
21
|
+
* an internal identifier. The form renderer knows the label (it already
|
|
22
|
+
* resolves the same map for the fixed-option widgets' `emptyHint`) and now
|
|
23
|
+
* hands it over as `dependsOnLabels`.
|
|
24
|
+
*
|
|
25
|
+
* All three gated surfaces are asserted — the trigger's visible text, the
|
|
26
|
+
* trigger's `title`, and the "browse all" button's `title` — because they were
|
|
27
|
+
* three independent copies of the same expression and a fix applied to one of
|
|
28
|
+
* them looks identical from the outside.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
32
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
33
|
+
import { LookupField } from './LookupField';
|
|
34
|
+
|
|
35
|
+
/** Mirrors the reported metadata: the parent field's API name IS an object-ish
|
|
36
|
+
* name (`crm_account`), which is exactly what made the leak so legible. */
|
|
37
|
+
const gatedField = {
|
|
38
|
+
name: 'contact',
|
|
39
|
+
label: 'Contact',
|
|
40
|
+
reference_to: 'crm_contact',
|
|
41
|
+
reference_field: 'name',
|
|
42
|
+
depends_on: ['crm_account'],
|
|
43
|
+
} as any;
|
|
44
|
+
|
|
45
|
+
const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
|
|
46
|
+
|
|
47
|
+
function renderGated(extra: Record<string, unknown>) {
|
|
48
|
+
return render(
|
|
49
|
+
<LookupField
|
|
50
|
+
field={gatedField}
|
|
51
|
+
value={undefined}
|
|
52
|
+
onChange={vi.fn()}
|
|
53
|
+
readonly={false}
|
|
54
|
+
dataSource={dataSource}
|
|
55
|
+
{...({ dependentValues: { crm_account: null }, ...extra } as any)}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
|
|
62
|
+
window.matchMedia = ((query: string) => ({
|
|
63
|
+
matches: false,
|
|
64
|
+
media: query,
|
|
65
|
+
onchange: null,
|
|
66
|
+
addEventListener: () => {},
|
|
67
|
+
removeEventListener: () => {},
|
|
68
|
+
addListener: () => {},
|
|
69
|
+
removeListener: () => {},
|
|
70
|
+
dispatchEvent: () => false,
|
|
71
|
+
})) as any;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => cleanup());
|
|
75
|
+
|
|
76
|
+
describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
|
|
77
|
+
it('interpolates the label the host supplied, not the API name', () => {
|
|
78
|
+
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
79
|
+
|
|
80
|
+
const trigger = screen.getByTestId('lookup-trigger-gated');
|
|
81
|
+
expect(trigger).toHaveTextContent('Select Account first');
|
|
82
|
+
// The identifier must be gone from the copy entirely, not merely
|
|
83
|
+
// accompanied by the label.
|
|
84
|
+
expect(trigger).not.toHaveTextContent('crm_account');
|
|
85
|
+
expect(trigger).toHaveAttribute('title', 'Select Account first');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('labels the gated "browse all" button the same way', () => {
|
|
89
|
+
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
90
|
+
|
|
91
|
+
const browse = screen.getByTestId('browse-all-records');
|
|
92
|
+
expect(browse).toBeDisabled();
|
|
93
|
+
expect(browse).toHaveAttribute('title', 'Select Account first');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('joins several controlling fields by their labels', () => {
|
|
97
|
+
render(
|
|
98
|
+
<LookupField
|
|
99
|
+
field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
|
|
100
|
+
value={undefined}
|
|
101
|
+
onChange={vi.fn()}
|
|
102
|
+
readonly={false}
|
|
103
|
+
dataSource={dataSource}
|
|
104
|
+
{...({
|
|
105
|
+
dependentValues: { crm_account: null, lead_source: null },
|
|
106
|
+
dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
|
|
107
|
+
} as any)}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
112
|
+
'Select Account, Lead Source first',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to the API name for a field the host did not map', () => {
|
|
117
|
+
// Standalone use (no host form) has to keep rendering what it rendered
|
|
118
|
+
// before — the map is an enrichment, not a new requirement. This is also
|
|
119
|
+
// the assertion that would go red if `dependsOnLabels` were consulted for
|
|
120
|
+
// its VALUES rather than keyed by field name.
|
|
121
|
+
renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
|
|
122
|
+
|
|
123
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
124
|
+
'Select crm_account first',
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('falls back to the API name when the host passes no map at all', () => {
|
|
129
|
+
renderGated({});
|
|
130
|
+
|
|
131
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
132
|
+
'Select crm_account first',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -71,7 +71,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108
|
|
|
71
71
|
|
|
72
72
|
// In-flight: loading indicator + raw committed count, no "Select…".
|
|
73
73
|
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
|
|
74
|
-
expect(screen.queryByText('Select
|
|
74
|
+
expect(screen.queryByText('Select…')).toBeNull();
|
|
75
75
|
expect(screen.getByText(/3/)).toBeTruthy();
|
|
76
76
|
|
|
77
77
|
resolveFind({ data: USERS });
|
|
@@ -11,6 +11,7 @@ import { cn,
|
|
|
11
11
|
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
12
12
|
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
13
|
import { FieldWidgetComponentProps } from './types';
|
|
14
|
+
import { toDomProps } from './toDomProps';
|
|
14
15
|
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
15
16
|
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
16
17
|
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
@@ -191,7 +192,7 @@ function mapFieldTypeToFilterType(
|
|
|
191
192
|
* from the referenced object using `DataSource.find()`.
|
|
192
193
|
* Falls back to static `options` when no DataSource is available.
|
|
193
194
|
*/
|
|
194
|
-
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
|
|
195
|
+
export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
|
|
195
196
|
const [isOpen, setIsOpen] = useState(false);
|
|
196
197
|
const { t } = useFieldTranslation();
|
|
197
198
|
const listboxId = React.useId();
|
|
@@ -215,11 +216,15 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
215
216
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
216
217
|
const listRef = useRef<HTMLDivElement>(null);
|
|
217
218
|
|
|
218
|
-
const lookupField =
|
|
219
|
+
const lookupField = field as any;
|
|
219
220
|
|
|
220
|
-
//
|
|
221
|
-
// metadata (
|
|
222
|
-
//
|
|
221
|
+
// The form renderer passes `field: field.field || field` — `.field` is the
|
|
222
|
+
// declared metadata slot (objectui#3090) — so the actual objectSchema field
|
|
223
|
+
// metadata (reference_to, display_field, etc.) can arrive nested at
|
|
224
|
+
// `lookupField.field`. Unwrap it so lookup-specific properties resolve
|
|
225
|
+
// correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper,
|
|
226
|
+
// which never produced the nesting and was removed in objectui#3910; the form
|
|
227
|
+
// path is the real producer.)
|
|
223
228
|
// ObjectStack convention uses `reference` while the types use `reference_to`,
|
|
224
229
|
// so we check for both property names.
|
|
225
230
|
const innerField = lookupField?.field;
|
|
@@ -279,6 +284,23 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
279
284
|
return [];
|
|
280
285
|
}, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
|
|
281
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The gate sentence's `{{fields}}` — the controlling fields named the way the
|
|
289
|
+
* user sees them on the form, not the way the metadata spells them.
|
|
290
|
+
*
|
|
291
|
+
* `depends_on` holds API names, and this used to interpolate them straight
|
|
292
|
+
* into the sentence, so every locale — `en` included — read "Select
|
|
293
|
+
* crm_account first" (objectstack#5407): an internal identifier in the UI,
|
|
294
|
+
* not merely an untranslated word. The host form supplies the name→label map
|
|
295
|
+
* (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
|
|
296
|
+
* standalone widget with no host renders exactly what it did before.
|
|
297
|
+
*/
|
|
298
|
+
const dependsOnLabelsProp = props.dependsOnLabels;
|
|
299
|
+
const dependsOnFieldsText = useMemo(
|
|
300
|
+
() => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
|
|
301
|
+
[dependsOn, dependsOnLabelsProp],
|
|
302
|
+
);
|
|
303
|
+
|
|
282
304
|
// Resolve dependent field values from explicit prop or SchemaRendererContext.data
|
|
283
305
|
const dependentValuesProp = props.dependentValues;
|
|
284
306
|
|
|
@@ -912,19 +934,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
912
934
|
// Shared field trigger — the anchor for either the inline PeoplePicker
|
|
913
935
|
// (search fields) or the classic quick-select popover. No onClick: the Radix
|
|
914
936
|
// trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
|
|
937
|
+
//
|
|
938
|
+
// DOM pass-through onto this button — the widget's real focusable control —
|
|
939
|
+
// per objectui#3318. `name` is withheld: no native control here takes part
|
|
940
|
+
// in form submission, and a stray `name` on a button invites exactly the
|
|
941
|
+
// accidental-submitter semantics #3306 kept it off the SelectTrigger for.
|
|
942
|
+
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
915
943
|
const triggerButton = (
|
|
916
944
|
<Button
|
|
945
|
+
{...triggerDomProps}
|
|
917
946
|
variant="outline"
|
|
918
947
|
className={cn(
|
|
919
|
-
|
|
948
|
+
// max-w-full: in a BLOCK parent (detail-section inline edit) the
|
|
949
|
+
// inline-flex button is content-sized — a long selected label pushed
|
|
950
|
+
// it past the card edge; bounding it lets the inner `truncate` span
|
|
951
|
+
// clip instead (objectui#3466). flex-1/min-w-0 keep handling the
|
|
952
|
+
// flex-parent (form row) case.
|
|
953
|
+
'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
|
|
920
954
|
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
921
955
|
)}
|
|
922
956
|
type="button"
|
|
923
957
|
disabled={dependenciesMissing || props.disabled}
|
|
924
958
|
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
|
|
925
959
|
title={dependenciesMissing
|
|
926
|
-
? t('lookup.selectFirst', { fields:
|
|
960
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
927
961
|
: undefined}
|
|
962
|
+
// AFTER the spread so this widget's own computation wins (#3222):
|
|
963
|
+
// `fieldError` is the published validation slot — NOT the popover's
|
|
964
|
+
// fetch error, which is a widget-internal state named `error` below.
|
|
965
|
+
aria-invalid={!!fieldError}
|
|
928
966
|
>
|
|
929
967
|
{hydrating ? (
|
|
930
968
|
<Loader2
|
|
@@ -936,7 +974,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
936
974
|
)}
|
|
937
975
|
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
938
976
|
{dependenciesMissing
|
|
939
|
-
? t('lookup.selectFirst', { fields:
|
|
977
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
940
978
|
: hydrating
|
|
941
979
|
// The value EXISTS but its labels are still loading — say so
|
|
942
980
|
// instead of the empty placeholder (#3108).
|
|
@@ -1044,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1044
1082
|
<div className="relative">
|
|
1045
1083
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
1046
1084
|
<Input
|
|
1047
|
-
placeholder={t('
|
|
1085
|
+
placeholder={t('table.search')}
|
|
1048
1086
|
value={searchQuery}
|
|
1049
1087
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
1050
1088
|
onKeyDown={handleSearchKeyDown}
|
|
@@ -1218,7 +1256,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1218
1256
|
onClick={() => setIsPickerOpen(true)}
|
|
1219
1257
|
aria-label={t('lookup.browseAll')}
|
|
1220
1258
|
title={dependenciesMissing
|
|
1221
|
-
? t('lookup.selectFirst', { fields:
|
|
1259
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
1222
1260
|
: t('lookup.browseAll')}
|
|
1223
1261
|
data-testid="browse-all-records"
|
|
1224
1262
|
>
|
|
@@ -1248,6 +1286,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1248
1286
|
lookupFilters={lookupFilters}
|
|
1249
1287
|
baseFilter={dependentFilter}
|
|
1250
1288
|
cellRenderer={getCellRendererResolver()}
|
|
1289
|
+
fieldsMeta={refObjectSchema?.fields}
|
|
1251
1290
|
filterColumns={filterColumns}
|
|
1252
1291
|
/>
|
|
1253
1292
|
)}
|
|
@@ -25,7 +25,7 @@ export function MasterDetailField({
|
|
|
25
25
|
...props
|
|
26
26
|
}: FieldWidgetComponentProps<MasterDetailValue[]>) {
|
|
27
27
|
const items = value || [];
|
|
28
|
-
const config = field
|
|
28
|
+
const config = field;
|
|
29
29
|
|
|
30
30
|
const handleAdd = () => {
|
|
31
31
|
// This would typically open a dialog to select/create related records
|