@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,112 @@
|
|
|
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
|
+
* Record Picker select-column label resolution — #3333.
|
|
11
|
+
*
|
|
12
|
+
* The "Browse all records" picker table must format cells with the SAME
|
|
13
|
+
* field metadata the list view uses. Pre-fix the dialog handed the cell
|
|
14
|
+
* renderer a bare `{ name, type }` descriptor, so a `select` column had no
|
|
15
|
+
* `options` and fell back to title-casing the raw stored value
|
|
16
|
+
* (`manufacturing` → "Manufacturing" instead of the authored label).
|
|
17
|
+
*
|
|
18
|
+
* With `fieldsMeta` (the referenced object's schema `fields` map) the dialog
|
|
19
|
+
* enriches each column's field descriptor — options, currency, scale, … —
|
|
20
|
+
* so select columns resolve their option labels, and string-authored
|
|
21
|
+
* `lookup_columns` (no `type` on the column def) inherit the schema field's
|
|
22
|
+
* type and format identically.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
26
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
+
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
28
|
+
import { getCellRenderer } from '../index';
|
|
29
|
+
|
|
30
|
+
const projects = [
|
|
31
|
+
{ id: 'p1', name: 'Line A retooling', project_phase: 'manufacturing' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const projectFields = {
|
|
35
|
+
name: { type: 'text', label: 'Name' },
|
|
36
|
+
project_phase: {
|
|
37
|
+
type: 'select',
|
|
38
|
+
label: 'Project Phase',
|
|
39
|
+
options: [
|
|
40
|
+
{ label: '01 立项', value: 'initiation' },
|
|
41
|
+
{ label: '03 制造', value: 'manufacturing' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function makeDataSource() {
|
|
47
|
+
const find = vi.fn(async () => ({ data: projects, total: projects.length }));
|
|
48
|
+
return { find } as any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('RecordPickerDialog — select columns resolve option labels (#3333)', () => {
|
|
52
|
+
it('renders the authored option label when fieldsMeta provides options', async () => {
|
|
53
|
+
render(
|
|
54
|
+
<RecordPickerDialog
|
|
55
|
+
open
|
|
56
|
+
onOpenChange={() => {}}
|
|
57
|
+
dataSource={makeDataSource()}
|
|
58
|
+
objectName="projects"
|
|
59
|
+
columns={[
|
|
60
|
+
{ field: 'name', label: 'Name', type: 'text' },
|
|
61
|
+
{ field: 'project_phase', label: 'Project Phase', type: 'select' },
|
|
62
|
+
]}
|
|
63
|
+
onSelect={() => {}}
|
|
64
|
+
cellRenderer={getCellRenderer}
|
|
65
|
+
fieldsMeta={projectFields}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(screen.getByText('03 制造')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
expect(screen.queryByText('Manufacturing')).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('string-authored columns without a type inherit the schema field type', async () => {
|
|
76
|
+
render(
|
|
77
|
+
<RecordPickerDialog
|
|
78
|
+
open
|
|
79
|
+
onOpenChange={() => {}}
|
|
80
|
+
dataSource={makeDataSource()}
|
|
81
|
+
objectName="projects"
|
|
82
|
+
columns={['name', 'project_phase']}
|
|
83
|
+
onSelect={() => {}}
|
|
84
|
+
cellRenderer={getCellRenderer}
|
|
85
|
+
fieldsMeta={projectFields}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(screen.getByText('03 制造')).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
expect(screen.queryByText('Manufacturing')).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('falls back to the humanized raw value without fieldsMeta (legacy behavior)', async () => {
|
|
96
|
+
render(
|
|
97
|
+
<RecordPickerDialog
|
|
98
|
+
open
|
|
99
|
+
onOpenChange={() => {}}
|
|
100
|
+
dataSource={makeDataSource()}
|
|
101
|
+
objectName="projects"
|
|
102
|
+
columns={[{ field: 'project_phase', label: 'Project Phase', type: 'select' }]}
|
|
103
|
+
onSelect={() => {}}
|
|
104
|
+
cellRenderer={getCellRenderer}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -38,6 +38,11 @@ import {
|
|
|
38
38
|
X,
|
|
39
39
|
} from 'lucide-react';
|
|
40
40
|
import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
|
|
41
|
+
// The repo's single filter sink (`packages/core/src/utils/filter-converter.ts`)
|
|
42
|
+
// — shared with plugin-list's `buildEffectiveFilter` and plugin-view's
|
|
43
|
+
// ObjectView, so a spec `ViewFilterRule[]` lowers in exactly one place.
|
|
44
|
+
import { mergeFilterNodes } from '@object-ui/core';
|
|
45
|
+
import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n';
|
|
41
46
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
42
47
|
import { useRecordQuery } from './useRecordQuery';
|
|
43
48
|
|
|
@@ -184,10 +189,107 @@ export function lookupFiltersToRecord(
|
|
|
184
189
|
return result;
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
/**
|
|
193
|
+
* A select option as the object schema declares it — `{ value, label }` plus
|
|
194
|
+
* whatever the renderer also reads (`color`, …), which the i18n translation
|
|
195
|
+
* carries through untouched.
|
|
196
|
+
*/
|
|
197
|
+
type SchemaOption = { value: any; label: string; [key: string]: any };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a field's select options from the referenced object's schema field
|
|
201
|
+
* definition (`fieldsMeta[field]`), translated through the shared i18n option
|
|
202
|
+
* path.
|
|
203
|
+
*
|
|
204
|
+
* This is the SINGLE source for both surfaces that need option labels: the
|
|
205
|
+
* table cells (#3333) and the filter panel's select inputs (#3336) — so a
|
|
206
|
+
* picker column and the filter input for that same column can never disagree
|
|
207
|
+
* about what an option is called.
|
|
208
|
+
*
|
|
209
|
+
* Returns `undefined` when the schema field declares no options: a select
|
|
210
|
+
* field with no authored options genuinely has nothing to offer, and
|
|
211
|
+
* synthesising entries from the loaded page's raw stored values would paper
|
|
212
|
+
* over the metadata gap with a list that changes per page.
|
|
213
|
+
*/
|
|
214
|
+
function resolveSchemaOptions(
|
|
215
|
+
meta: { options?: unknown } | undefined,
|
|
216
|
+
objectName: string,
|
|
217
|
+
fieldName: string,
|
|
218
|
+
translateOptions: (o: string, f: string, opts: SchemaOption[]) => SchemaOption[],
|
|
219
|
+
): SchemaOption[] | undefined {
|
|
220
|
+
const raw = meta?.options;
|
|
221
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
222
|
+
const options = raw as SchemaOption[];
|
|
223
|
+
if (!objectName) return options;
|
|
224
|
+
return translateOptions(objectName, fieldName, options);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Option-value round-trip helpers for the filter panel's `select` input
|
|
229
|
+
* (#3422).
|
|
230
|
+
*
|
|
231
|
+
* Radix `Select` speaks strings only: an option renders as
|
|
232
|
+
* `value={String(opt.value)}` and `onValueChange` hands that same string back.
|
|
233
|
+
* A filter option's value, however, is whatever the metadata author wrote —
|
|
234
|
+
* `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]`
|
|
235
|
+
* derives options with NUMBER values (`LookupFilterDef.value` is `unknown`) —
|
|
236
|
+
* so writing the control's string straight into `$filter` queried
|
|
237
|
+
* `{ level: "1" }` against records storing `level: 1`, and the panel returned
|
|
238
|
+
* nothing for an option that plainly has records.
|
|
239
|
+
*
|
|
240
|
+
* The remap therefore happens at the CONTROL boundary, not in
|
|
241
|
+
* `filterValuesToRecord`: the control speaks string, the payload keeps the
|
|
242
|
+
* authored type. Coercing downstream would mean guessing whether `"1"` meant
|
|
243
|
+
* `1` or `"1"` — a guess the option list already answers exactly.
|
|
244
|
+
*
|
|
245
|
+
* Replicated from `matchOptionValue` / `toControlValue` in
|
|
246
|
+
* `packages/components/src/renderers/form/option-value.ts` (#3090), which
|
|
247
|
+
* solved the identical morph for the standalone form's select. Those two are
|
|
248
|
+
* module-private to `@object-ui/components` (its public barrel does not export
|
|
249
|
+
* them and the package publishes no deep subpath), so `@object-ui/fields`
|
|
250
|
+
* keeps its own copy of the four lines rather than widening another package's
|
|
251
|
+
* API to share them.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Stringify a value for a string-speaking control, preserving null/undefined
|
|
256
|
+
* (an absent value must stay absent, not become `"undefined"`).
|
|
257
|
+
*/
|
|
258
|
+
function toControlValue(value: unknown): string | undefined {
|
|
259
|
+
return value == null ? undefined : String(value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Map a control's string back to the authored option value, so a numeric /
|
|
264
|
+
* boolean / object option round-trips with its type intact. Falls back to the
|
|
265
|
+
* raw string when nothing matches (a stale value, or a column that declares no
|
|
266
|
+
* options) — the pre-#3422 behaviour, so unmatched paths change nothing.
|
|
267
|
+
*
|
|
268
|
+
* When two options share a `String()` form — `1` and `'1'`, or two object
|
|
269
|
+
* values that both print `[object Object]` — the FIRST match wins. Such a list
|
|
270
|
+
* is already unrenderable as a dropdown (Radix would receive two `SelectItem`s
|
|
271
|
+
* with the same `value`, and React two children with the same key), so this
|
|
272
|
+
* tie-break exists to be deterministic, not to make an ambiguous option list
|
|
273
|
+
* work.
|
|
274
|
+
*/
|
|
275
|
+
function matchOptionValue(
|
|
276
|
+
options: ReadonlyArray<{ value: unknown }> | undefined,
|
|
277
|
+
raw: string,
|
|
278
|
+
): unknown {
|
|
279
|
+
const hit = options?.find(o => String(o.value) === raw);
|
|
280
|
+
return hit ? hit.value : raw;
|
|
281
|
+
}
|
|
282
|
+
|
|
187
283
|
/**
|
|
188
284
|
* Convert user-entered filter bar values into a $filter Record.
|
|
189
285
|
* Each key is a field name, each value the user-entered value.
|
|
190
286
|
* Empty/null values are ignored.
|
|
287
|
+
*
|
|
288
|
+
* Note the empty string is the "no filter on this field" sentinel here (and
|
|
289
|
+
* the `select` control's "nothing picked" value), so an option whose authored
|
|
290
|
+
* value is `''` cannot be expressed as an active filter. That predates #3422
|
|
291
|
+
* and is unchanged by it — the round-trip above restores an option's TYPE, it
|
|
292
|
+
* does not redefine what counts as an empty selection.
|
|
191
293
|
*/
|
|
192
294
|
function filterValuesToRecord(
|
|
193
295
|
values: Record<string, any>,
|
|
@@ -262,13 +364,39 @@ export interface RecordPickerDialogProps {
|
|
|
262
364
|
lookupFilters?: LookupFilterDef[];
|
|
263
365
|
|
|
264
366
|
/**
|
|
265
|
-
* Hard filter
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
367
|
+
* Hard filter constraint applied to every query. Unlike `lookupFilters`,
|
|
368
|
+
* entries here never surface in the filter bar and cannot be overridden by
|
|
369
|
+
* user filter input.
|
|
370
|
+
*
|
|
371
|
+
* Two shapes, discriminated STRUCTURALLY (`Array.isArray`), because the two
|
|
372
|
+
* callers speak two legitimate vocabularies and neither should be bent into
|
|
373
|
+
* the other:
|
|
374
|
+
*
|
|
375
|
+
* - **`QueryParams.$filter` record form** (`{ account: 'a1' }`) — the
|
|
376
|
+
* dependent (cascading) lookup chain, where the parent field's value MUST
|
|
377
|
+
* scope the candidate set (#2215). Merged by KEY OVERWRITE, so a cascaded
|
|
378
|
+
* value REPLACES a stale `lookupFilters` entry on the same field instead of
|
|
379
|
+
* intersecting with it. That precedence is load-bearing: an `and` of both
|
|
380
|
+
* would ask for `account = 'stale' AND account = 'a1'` and return nothing.
|
|
381
|
+
* - **A spec `ViewFilterRule[]`** (`[{ field, operator, value? }]`) — an
|
|
382
|
+
* author's `record:related_list.add.picker.filter`, handed over VERBATIM
|
|
383
|
+
* (#3831). Lowered by `mergeFilterNodes`, the repo's single filter sink, so
|
|
384
|
+
* all 19 `VIEW_FILTER_OPERATORS` reach the wire — including the four
|
|
385
|
+
* (`before`, `after`, `is_empty`, `is_not_empty`) the record form has no
|
|
386
|
+
* `$op` for. No second operator vocabulary is introduced here: two already
|
|
387
|
+
* exist (the spec's `AST_OPERATOR_MAP`, data-objectstack's
|
|
388
|
+
* `FILTER_OPERATOR_ALIASES`) and #3948 is what a third costs.
|
|
389
|
+
*
|
|
390
|
+
* The discriminator is exact rather than heuristic — every AST node is an
|
|
391
|
+
* ARRAY and a rule is a plain OBJECT, the same predicate `toFilterNode` uses.
|
|
392
|
+
*
|
|
393
|
+
* Typed `unknown` rather than `Record< string, any >` on purpose: that type
|
|
394
|
+
* ACCEPTED a rule array (TypeScript lets an array satisfy a string index of
|
|
395
|
+
* `any`), the old object-spread merge then flattened it to
|
|
396
|
+
* `{"0": {...}, "1": {...}}`, and the query filtered on columns literally
|
|
397
|
+
* named `0`/`1` — type-check green, wrong query, no diagnostic anywhere.
|
|
270
398
|
*/
|
|
271
|
-
baseFilter?:
|
|
399
|
+
baseFilter?: unknown;
|
|
272
400
|
|
|
273
401
|
/**
|
|
274
402
|
* Cell renderer resolver function.
|
|
@@ -278,6 +406,23 @@ export interface RecordPickerDialogProps {
|
|
|
278
406
|
*/
|
|
279
407
|
cellRenderer?: CellRendererResolver;
|
|
280
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The referenced object's schema `fields` map (field name → field
|
|
411
|
+
* definition). When provided, cell renderers receive the FULL field
|
|
412
|
+
* metadata — `options`, `currency`, `scale`, `precision`, `format`,
|
|
413
|
+
* `reference_to`, … — exactly like the list view enriches its columns from
|
|
414
|
+
* the object schema. Without it a `select` column falls back to
|
|
415
|
+
* title-casing the raw stored value instead of resolving the option label
|
|
416
|
+
* (#3333: `manufacturing` rendered as "Manufacturing" instead of the
|
|
417
|
+
* authored option label).
|
|
418
|
+
*
|
|
419
|
+
* The filter bar reads the same map: a `select` filter column with no
|
|
420
|
+
* authored `options` takes them from the schema field here, so the filter
|
|
421
|
+
* panel's dropdown offers exactly the options the table cells render
|
|
422
|
+
* (#3336 — it used to open empty, leaving the field unfilterable).
|
|
423
|
+
*/
|
|
424
|
+
fieldsMeta?: Record<string, any>;
|
|
425
|
+
|
|
281
426
|
/**
|
|
282
427
|
* Filter bar column definitions.
|
|
283
428
|
* When provided, shows an inline filter bar below the search input.
|
|
@@ -344,11 +489,16 @@ export function RecordPickerDialog({
|
|
|
344
489
|
lookupFilters,
|
|
345
490
|
baseFilter,
|
|
346
491
|
cellRenderer,
|
|
492
|
+
fieldsMeta,
|
|
347
493
|
filterColumns,
|
|
348
494
|
renderFilterBar,
|
|
349
495
|
renderGrid,
|
|
350
496
|
}: RecordPickerDialogProps) {
|
|
351
497
|
const { t } = useFieldTranslation();
|
|
498
|
+
const { translateOptions } = useSafeFieldLabel();
|
|
499
|
+
// The one date/number locale resolver: tenant regional default → active UI
|
|
500
|
+
// language → 'en' (objectui#4272). Read unconditionally at component level.
|
|
501
|
+
const displayLocale = useDisplayLocale();
|
|
352
502
|
|
|
353
503
|
// Query state (records/loading/error/total + page/search/sort) lives in the
|
|
354
504
|
// shared useRecordQuery kernel — instantiated after mergedFilter below.
|
|
@@ -385,9 +535,31 @@ export function RecordPickerDialog({
|
|
|
385
535
|
return [{ field: displayField, label: fieldToLabel(displayField) }];
|
|
386
536
|
}, [columnsProp, displayField]);
|
|
387
537
|
|
|
538
|
+
// Field descriptors handed to the type-aware cell renderers, enriched from
|
|
539
|
+
// the referenced object's schema (`fieldsMeta`) the same way the list view
|
|
540
|
+
// enriches its columns. This is what lets a `select` column resolve its
|
|
541
|
+
// option label (options + i18n) instead of title-casing the raw value
|
|
542
|
+
// (#3333). Columns whose def carries no `type` inherit the schema field's
|
|
543
|
+
// type so authored string `lookup_columns` format identically.
|
|
544
|
+
const columnFieldDescriptors = useMemo<Record<string, any>>(() => {
|
|
545
|
+
const map: Record<string, any> = {};
|
|
546
|
+
for (const col of resolvedColumns) {
|
|
547
|
+
const meta = fieldsMeta?.[col.field];
|
|
548
|
+
const type = col.type ?? meta?.type;
|
|
549
|
+
if (!type) continue;
|
|
550
|
+
const descriptor: any = meta
|
|
551
|
+
? { ...meta, name: col.field, type }
|
|
552
|
+
: { name: col.field, type };
|
|
553
|
+
const options = resolveSchemaOptions(meta, objectName, col.field, translateOptions);
|
|
554
|
+
if (options) descriptor.options = options;
|
|
555
|
+
map[col.field] = descriptor;
|
|
556
|
+
}
|
|
557
|
+
return map;
|
|
558
|
+
}, [resolvedColumns, fieldsMeta, objectName, translateOptions]);
|
|
559
|
+
|
|
388
560
|
// Auto-generate filter columns from lookupFilters when no explicit filterColumns given.
|
|
389
561
|
// Each LookupFilterDef becomes a filterable field with inferred type.
|
|
390
|
-
const
|
|
562
|
+
const baseFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
391
563
|
if (filterColumns && filterColumns.length > 0) return filterColumns;
|
|
392
564
|
// Auto-derive from lookupFilters: each filter entry becomes a filterable field
|
|
393
565
|
if (lookupFilters && lookupFilters.length > 0) {
|
|
@@ -425,18 +597,56 @@ export function RecordPickerDialog({
|
|
|
425
597
|
return undefined;
|
|
426
598
|
}, [filterColumns, lookupFilters]);
|
|
427
599
|
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
600
|
+
// Filter columns as the filter bar consumes them: every `select` filter
|
|
601
|
+
// carries the options its schema field declares. The filter panel's Select reads
|
|
602
|
+
// `col.options` — a derived select column (LookupField turns each typed picker
|
|
603
|
+
// column into a filter column) carries none, so the dropdown opened empty and
|
|
604
|
+
// the field could not be filtered at all (#3336).
|
|
605
|
+
//
|
|
606
|
+
// The options come from `fieldsMeta` through `resolveSchemaOptions`, i.e. the
|
|
607
|
+
// SAME schema source + i18n translation the table cells use (#3333) — not a
|
|
608
|
+
// second derivation that could drift from the cells. An explicitly authored
|
|
609
|
+
// `options` list on the filter column always wins (it is the more specific
|
|
610
|
+
// statement), and a schema field with no options stays optionless: an empty
|
|
611
|
+
// dropdown for THAT field is the honest rendering of missing metadata.
|
|
612
|
+
const effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
613
|
+
if (!baseFilterColumns) return undefined;
|
|
614
|
+
return baseFilterColumns.map(col => {
|
|
615
|
+
if (col.type !== 'select') return col;
|
|
616
|
+
if (col.options && col.options.length > 0) return col;
|
|
617
|
+
const options = resolveSchemaOptions(fieldsMeta?.[col.field], objectName, col.field, translateOptions);
|
|
618
|
+
return options ? { ...col, options } : col;
|
|
619
|
+
});
|
|
620
|
+
}, [baseFilterColumns, fieldsMeta, objectName, translateOptions]);
|
|
621
|
+
|
|
622
|
+
// Merge base lookup_filters with user filter bar values, then apply the hard
|
|
623
|
+
// `baseFilter` constraint BY SHAPE (see the prop's own doc for why the two
|
|
624
|
+
// shapes exist):
|
|
625
|
+
//
|
|
626
|
+
// record form → spread LAST, exactly as this merge has always done, so
|
|
627
|
+
// user filter-bar input can never widen it back out and a
|
|
628
|
+
// cascaded parent value replaces a stale same-field
|
|
629
|
+
// `lookupFilters` entry (#2215).
|
|
630
|
+
// rule array → its OWN `and` child via `mergeFilterNodes`, the shared
|
|
631
|
+
// sink, so a spec `ViewFilterRule[]` lowers losslessly
|
|
632
|
+
// (#3831) instead of being spread into `{0: rule}`.
|
|
633
|
+
//
|
|
634
|
+
// When BOTH are in play the record side is still built first and lowered as
|
|
635
|
+
// one node, so its key-overwrite precedence survives the conjunction.
|
|
636
|
+
const mergedFilter = useMemo<unknown>(() => {
|
|
432
637
|
const lookupBase = lookupFilters?.length
|
|
433
638
|
? lookupFiltersToRecord(lookupFilters)
|
|
434
639
|
: {};
|
|
435
640
|
const userFilter = effectiveFilterColumns?.length
|
|
436
641
|
? filterValuesToRecord(filterValues, effectiveFilterColumns)
|
|
437
642
|
: {};
|
|
438
|
-
const
|
|
439
|
-
|
|
643
|
+
const rules = Array.isArray(baseFilter) ? baseFilter : undefined;
|
|
644
|
+
const recordBase = rules
|
|
645
|
+
? undefined
|
|
646
|
+
: (baseFilter as Record<string, any> | undefined);
|
|
647
|
+
const combined = { ...lookupBase, ...userFilter, ...(recordBase ?? {}) };
|
|
648
|
+
const record = Object.keys(combined).length > 0 ? combined : undefined;
|
|
649
|
+
return rules ? mergeFilterNodes(record, rules) : record;
|
|
440
650
|
}, [lookupFilters, effectiveFilterColumns, filterValues, baseFilter]);
|
|
441
651
|
|
|
442
652
|
// Shared query kernel: builds params, fetches, and owns records/loading/error/
|
|
@@ -637,11 +847,13 @@ export function RecordPickerDialog({
|
|
|
637
847
|
|
|
638
848
|
const val = record[col.field];
|
|
639
849
|
|
|
640
|
-
// Use type-aware renderer when column type
|
|
641
|
-
|
|
642
|
-
|
|
850
|
+
// Use type-aware renderer when a field descriptor (column `type`, or the
|
|
851
|
+
// schema field's type via `fieldsMeta`) and a resolver are available.
|
|
852
|
+
const descriptor = columnFieldDescriptors[col.field];
|
|
853
|
+
if (descriptor && cellRenderer) {
|
|
854
|
+
const Renderer = cellRenderer(descriptor.type);
|
|
643
855
|
if (Renderer) {
|
|
644
|
-
return <Renderer value={val} field={
|
|
856
|
+
return <Renderer value={val} field={descriptor} />;
|
|
645
857
|
}
|
|
646
858
|
}
|
|
647
859
|
|
|
@@ -651,13 +863,13 @@ export function RecordPickerDialog({
|
|
|
651
863
|
// Handle MongoDB types / expanded references
|
|
652
864
|
if (val.$numberDecimal) return String(Number(val.$numberDecimal));
|
|
653
865
|
if (val.$oid) return String(val.$oid);
|
|
654
|
-
if (val.$date) return new Date(val.$date).toLocaleDateString();
|
|
866
|
+
if (val.$date) return new Date(val.$date).toLocaleDateString(displayLocale);
|
|
655
867
|
if (val.name || val.label) return String(val.name || val.label);
|
|
656
868
|
return JSON.stringify(val);
|
|
657
869
|
}
|
|
658
870
|
if (typeof val === 'boolean') return val ? 'Yes' : 'No';
|
|
659
871
|
return String(val);
|
|
660
|
-
}, [cellRenderer, titleFormat, displayField]);
|
|
872
|
+
}, [cellRenderer, titleFormat, displayField, columnFieldDescriptors, displayLocale]);
|
|
661
873
|
|
|
662
874
|
// Render sort indicator for a column
|
|
663
875
|
const renderSortIcon = useCallback((field: string) => {
|
|
@@ -727,9 +939,11 @@ export function RecordPickerDialog({
|
|
|
727
939
|
return (
|
|
728
940
|
<div className="space-y-1">
|
|
729
941
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
942
|
+
{/* The control speaks string; the stored filter value keeps the
|
|
943
|
+
authored option type (#3422 — see `matchOptionValue`). */}
|
|
730
944
|
<Select
|
|
731
|
-
value={
|
|
732
|
-
onValueChange={v => handleFilterChange(col.field, v)}
|
|
945
|
+
value={toControlValue(val) ?? ''}
|
|
946
|
+
onValueChange={v => handleFilterChange(col.field, matchOptionValue(col.options, v))}
|
|
733
947
|
>
|
|
734
948
|
<SelectTrigger className="h-8 text-xs">
|
|
735
949
|
<SelectValue placeholder={t('lookup.filterPlaceholder', { label })} />
|
|
@@ -1,18 +1,112 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Textarea, EmptyValue } from '@object-ui/components';
|
|
2
|
+
import { cn, Textarea, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { useObjectTranslation } from '@object-ui/react';
|
|
4
|
+
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
|
|
4
5
|
import { FieldWidgetComponentProps } from './types';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The rich-text editing surface, rendered by `RichTextField` in BOTH positions:
|
|
9
|
+
* inline in the form, and inside the fullscreen dialog.
|
|
10
|
+
*
|
|
11
|
+
* Sharing it is the point of objectui#3301's acceptance criterion "the dialog
|
|
12
|
+
* holds the real editor". Had the dialog inlined a bare `<Textarea>` instead,
|
|
13
|
+
* the two surfaces would already disagree (no format indicator) and would keep
|
|
14
|
+
* disagreeing every time this widget gains an affordance — whatever the editor
|
|
15
|
+
* grows into (a toolbar, a TipTap/Lexical instance, a preview toggle), both
|
|
16
|
+
* positions get it at once because there is only one of them.
|
|
17
|
+
*
|
|
18
|
+
* `fullHeight` is the only difference between the two renderings: inline the
|
|
19
|
+
* textarea is `rows`-sized, in the dialog it fills the available height.
|
|
20
|
+
*/
|
|
21
|
+
function RichTextEditorSurface({
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
formatLabel,
|
|
25
|
+
hint,
|
|
26
|
+
placeholder,
|
|
27
|
+
rows,
|
|
28
|
+
disabled,
|
|
29
|
+
error,
|
|
30
|
+
className,
|
|
31
|
+
fullHeight,
|
|
32
|
+
autoFocus,
|
|
33
|
+
textareaTestId,
|
|
34
|
+
overlay,
|
|
35
|
+
}: {
|
|
36
|
+
value: string;
|
|
37
|
+
onChange: (next: string) => void;
|
|
38
|
+
formatLabel: string;
|
|
39
|
+
hint: string;
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
rows?: number;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
error?: string;
|
|
44
|
+
className?: string;
|
|
45
|
+
fullHeight?: boolean;
|
|
46
|
+
autoFocus?: boolean;
|
|
47
|
+
textareaTestId?: string;
|
|
48
|
+
/** Absolutely-positioned children over the textarea (the expand affordance). */
|
|
49
|
+
overlay?: React.ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={cn('space-y-2', fullHeight && 'flex flex-col h-full')}>
|
|
53
|
+
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
54
|
+
<span>{formatLabel}</span>
|
|
55
|
+
<span className="italic">{hint}</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div className={cn('relative', fullHeight && 'flex-1 min-h-0')}>
|
|
58
|
+
<Textarea
|
|
59
|
+
value={value || ''}
|
|
60
|
+
onChange={(e) => onChange(e.target.value)}
|
|
61
|
+
placeholder={placeholder}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
autoFocus={autoFocus}
|
|
64
|
+
rows={fullHeight ? undefined : rows}
|
|
65
|
+
// `text-base` in the dialog for the same reason `TextAreaField` uses
|
|
66
|
+
// it there: sub-16px inputs make iOS Safari zoom on focus, which is
|
|
67
|
+
// exactly wrong for a surface the user opened to get more room.
|
|
68
|
+
className={cn(
|
|
69
|
+
'font-mono',
|
|
70
|
+
fullHeight ? 'text-base h-full min-h-full resize-none' : 'text-sm',
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
aria-invalid={!!error}
|
|
74
|
+
data-testid={textareaTestId}
|
|
75
|
+
/>
|
|
76
|
+
{overlay}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
6
82
|
/**
|
|
7
83
|
* Rich text field with markdown/HTML support
|
|
8
84
|
* For now, this is a simple textarea. A full implementation would use
|
|
9
85
|
* a rich text editor like TipTap, Lexical, or Slate.
|
|
86
|
+
*
|
|
87
|
+
* Reached by forms as `field:markdown` and `field:html`; both resolve here.
|
|
88
|
+
*
|
|
89
|
+
* ## Fullscreen editing (objectui#3301)
|
|
90
|
+
*
|
|
91
|
+
* `ObjectForm` stamps `mobile_fullscreen` onto the metadata of every long-text
|
|
92
|
+
* field — textarea AND rich-text — when `ObjectFormSchema.mobile
|
|
93
|
+
* .fullscreenLongText` is set, and `ObjectFormSchema.mobile`'s own JSDoc has
|
|
94
|
+
* always promised "textarea/rich-text get an expand button". This widget never
|
|
95
|
+
* read the flag, so for `field:markdown` / `field:html` that promise did
|
|
96
|
+
* nothing at all: the producer stamped, and no consumer existed.
|
|
97
|
+
*
|
|
98
|
+
* The flag is read off `field` and nowhere else — the single metadata carrier
|
|
99
|
+
* since objectui#3233 — matching `TextAreaField` read for read, so a
|
|
100
|
+
* misspelled flag stays inert in both widgets rather than being caught by a
|
|
101
|
+
* tolerant fallback in one of them. The affordance and dialog themselves come
|
|
102
|
+
* from the shared `FullscreenFieldEditor`, so one form-level setting keeps
|
|
103
|
+
* producing one behaviour across both widgets.
|
|
10
104
|
*/
|
|
11
|
-
export function RichTextField({ value, onChange, field, readonly,
|
|
105
|
+
export function RichTextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
106
|
const { t } = useObjectTranslation();
|
|
13
107
|
if (readonly) {
|
|
14
108
|
return (
|
|
15
|
-
<div
|
|
109
|
+
<div
|
|
16
110
|
className="text-sm prose prose-sm max-w-none"
|
|
17
111
|
>
|
|
18
112
|
{value || <EmptyValue />}
|
|
@@ -20,30 +114,63 @@ export function RichTextField({ value, onChange, field, readonly, errorMessage,
|
|
|
20
114
|
);
|
|
21
115
|
}
|
|
22
116
|
|
|
23
|
-
const richField =
|
|
117
|
+
const richField = field as any;
|
|
24
118
|
const rows = richField?.rows || 8;
|
|
25
119
|
const format = richField?.format || 'markdown'; // 'markdown' or 'html'
|
|
120
|
+
// Same single read as `TextAreaField`: the field metadata is the only
|
|
121
|
+
// carrier, and this widget is the second consumer the flag always had.
|
|
122
|
+
const showFullscreenButton = Boolean(richField?.mobile_fullscreen);
|
|
123
|
+
// Resolved once and given to BOTH renderings of the editor (objectui#3402) —
|
|
124
|
+
// exactly like `formatLabel` / `hint` / `placeholder` below, and for the same
|
|
125
|
+
// reason. Landing it on the inline surface alone left a disabled rich-text
|
|
126
|
+
// field greyed out next to a live expand button whose dialog committed any
|
|
127
|
+
// edit through `onCommit`. `disabled` also carries the form's `isSubmitting`.
|
|
128
|
+
const disabled = Boolean(props.disabled);
|
|
129
|
+
|
|
130
|
+
// Resolved once and handed to BOTH renderings of the editor, so the dialog
|
|
131
|
+
// cannot drift into showing different copy than the inline surface.
|
|
132
|
+
const formatLabel = t('fields.richText.format', { format, defaultValue: `Format: ${format}` });
|
|
133
|
+
const hint = t('fields.richText.basicEditorHint', { defaultValue: 'Rich text editor (basic)' });
|
|
134
|
+
const placeholder =
|
|
135
|
+
richField?.placeholder ||
|
|
136
|
+
t('fields.richText.placeholder', { defaultValue: 'Enter text…' });
|
|
26
137
|
|
|
27
138
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
139
|
+
<RichTextEditorSurface
|
|
140
|
+
value={value}
|
|
141
|
+
onChange={onChange}
|
|
142
|
+
formatLabel={formatLabel}
|
|
143
|
+
hint={hint}
|
|
144
|
+
placeholder={placeholder}
|
|
145
|
+
rows={rows}
|
|
146
|
+
disabled={readonly || disabled}
|
|
147
|
+
error={error}
|
|
148
|
+
className={props.className}
|
|
149
|
+
overlay={
|
|
150
|
+
showFullscreenButton && (
|
|
151
|
+
<FullscreenFieldEditor
|
|
152
|
+
value={value ?? ''}
|
|
153
|
+
onCommit={onChange}
|
|
154
|
+
label={richField?.label}
|
|
155
|
+
testIdPrefix="richtext"
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
>
|
|
158
|
+
{(draft, setDraft, editorDisabled) => (
|
|
159
|
+
<RichTextEditorSurface
|
|
160
|
+
value={draft}
|
|
161
|
+
onChange={setDraft}
|
|
162
|
+
formatLabel={formatLabel}
|
|
163
|
+
hint={hint}
|
|
164
|
+
placeholder={placeholder}
|
|
165
|
+
disabled={editorDisabled}
|
|
166
|
+
autoFocus
|
|
167
|
+
fullHeight
|
|
168
|
+
textareaTestId="richtext-fullscreen-input"
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</FullscreenFieldEditor>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
48
175
|
);
|
|
49
176
|
}
|