@object-ui/fields 17.1.0 → 17.3.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 +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- 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/CharacterCount.d.ts +56 -0
- 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 +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -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/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- 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/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -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/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +45 -6
- 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__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- 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 +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- 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 +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- 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/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -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,7 @@ import {
|
|
|
38
38
|
X,
|
|
39
39
|
} from 'lucide-react';
|
|
40
40
|
import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
|
|
41
|
+
import { useSafeFieldLabel } from '@object-ui/i18n';
|
|
41
42
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
42
43
|
import { useRecordQuery } from './useRecordQuery';
|
|
43
44
|
|
|
@@ -184,10 +185,107 @@ export function lookupFiltersToRecord(
|
|
|
184
185
|
return result;
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A select option as the object schema declares it — `{ value, label }` plus
|
|
190
|
+
* whatever the renderer also reads (`color`, …), which the i18n translation
|
|
191
|
+
* carries through untouched.
|
|
192
|
+
*/
|
|
193
|
+
type SchemaOption = { value: any; label: string; [key: string]: any };
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve a field's select options from the referenced object's schema field
|
|
197
|
+
* definition (`fieldsMeta[field]`), translated through the shared i18n option
|
|
198
|
+
* path.
|
|
199
|
+
*
|
|
200
|
+
* This is the SINGLE source for both surfaces that need option labels: the
|
|
201
|
+
* table cells (#3333) and the filter panel's select inputs (#3336) — so a
|
|
202
|
+
* picker column and the filter input for that same column can never disagree
|
|
203
|
+
* about what an option is called.
|
|
204
|
+
*
|
|
205
|
+
* Returns `undefined` when the schema field declares no options: a select
|
|
206
|
+
* field with no authored options genuinely has nothing to offer, and
|
|
207
|
+
* synthesising entries from the loaded page's raw stored values would paper
|
|
208
|
+
* over the metadata gap with a list that changes per page.
|
|
209
|
+
*/
|
|
210
|
+
function resolveSchemaOptions(
|
|
211
|
+
meta: { options?: unknown } | undefined,
|
|
212
|
+
objectName: string,
|
|
213
|
+
fieldName: string,
|
|
214
|
+
translateOptions: (o: string, f: string, opts: SchemaOption[]) => SchemaOption[],
|
|
215
|
+
): SchemaOption[] | undefined {
|
|
216
|
+
const raw = meta?.options;
|
|
217
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
218
|
+
const options = raw as SchemaOption[];
|
|
219
|
+
if (!objectName) return options;
|
|
220
|
+
return translateOptions(objectName, fieldName, options);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Option-value round-trip helpers for the filter panel's `select` input
|
|
225
|
+
* (#3422).
|
|
226
|
+
*
|
|
227
|
+
* Radix `Select` speaks strings only: an option renders as
|
|
228
|
+
* `value={String(opt.value)}` and `onValueChange` hands that same string back.
|
|
229
|
+
* A filter option's value, however, is whatever the metadata author wrote —
|
|
230
|
+
* `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]`
|
|
231
|
+
* derives options with NUMBER values (`LookupFilterDef.value` is `unknown`) —
|
|
232
|
+
* so writing the control's string straight into `$filter` queried
|
|
233
|
+
* `{ level: "1" }` against records storing `level: 1`, and the panel returned
|
|
234
|
+
* nothing for an option that plainly has records.
|
|
235
|
+
*
|
|
236
|
+
* The remap therefore happens at the CONTROL boundary, not in
|
|
237
|
+
* `filterValuesToRecord`: the control speaks string, the payload keeps the
|
|
238
|
+
* authored type. Coercing downstream would mean guessing whether `"1"` meant
|
|
239
|
+
* `1` or `"1"` — a guess the option list already answers exactly.
|
|
240
|
+
*
|
|
241
|
+
* Replicated from `matchOptionValue` / `toControlValue` in
|
|
242
|
+
* `packages/components/src/renderers/form/option-value.ts` (#3090), which
|
|
243
|
+
* solved the identical morph for the standalone form's select. Those two are
|
|
244
|
+
* module-private to `@object-ui/components` (its public barrel does not export
|
|
245
|
+
* them and the package publishes no deep subpath), so `@object-ui/fields`
|
|
246
|
+
* keeps its own copy of the four lines rather than widening another package's
|
|
247
|
+
* API to share them.
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stringify a value for a string-speaking control, preserving null/undefined
|
|
252
|
+
* (an absent value must stay absent, not become `"undefined"`).
|
|
253
|
+
*/
|
|
254
|
+
function toControlValue(value: unknown): string | undefined {
|
|
255
|
+
return value == null ? undefined : String(value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Map a control's string back to the authored option value, so a numeric /
|
|
260
|
+
* boolean / object option round-trips with its type intact. Falls back to the
|
|
261
|
+
* raw string when nothing matches (a stale value, or a column that declares no
|
|
262
|
+
* options) — the pre-#3422 behaviour, so unmatched paths change nothing.
|
|
263
|
+
*
|
|
264
|
+
* When two options share a `String()` form — `1` and `'1'`, or two object
|
|
265
|
+
* values that both print `[object Object]` — the FIRST match wins. Such a list
|
|
266
|
+
* is already unrenderable as a dropdown (Radix would receive two `SelectItem`s
|
|
267
|
+
* with the same `value`, and React two children with the same key), so this
|
|
268
|
+
* tie-break exists to be deterministic, not to make an ambiguous option list
|
|
269
|
+
* work.
|
|
270
|
+
*/
|
|
271
|
+
function matchOptionValue(
|
|
272
|
+
options: ReadonlyArray<{ value: unknown }> | undefined,
|
|
273
|
+
raw: string,
|
|
274
|
+
): unknown {
|
|
275
|
+
const hit = options?.find(o => String(o.value) === raw);
|
|
276
|
+
return hit ? hit.value : raw;
|
|
277
|
+
}
|
|
278
|
+
|
|
187
279
|
/**
|
|
188
280
|
* Convert user-entered filter bar values into a $filter Record.
|
|
189
281
|
* Each key is a field name, each value the user-entered value.
|
|
190
282
|
* Empty/null values are ignored.
|
|
283
|
+
*
|
|
284
|
+
* Note the empty string is the "no filter on this field" sentinel here (and
|
|
285
|
+
* the `select` control's "nothing picked" value), so an option whose authored
|
|
286
|
+
* value is `''` cannot be expressed as an active filter. That predates #3422
|
|
287
|
+
* and is unchanged by it — the round-trip above restores an option's TYPE, it
|
|
288
|
+
* does not redefine what counts as an empty selection.
|
|
191
289
|
*/
|
|
192
290
|
function filterValuesToRecord(
|
|
193
291
|
values: Record<string, any>,
|
|
@@ -278,6 +376,23 @@ export interface RecordPickerDialogProps {
|
|
|
278
376
|
*/
|
|
279
377
|
cellRenderer?: CellRendererResolver;
|
|
280
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The referenced object's schema `fields` map (field name → field
|
|
381
|
+
* definition). When provided, cell renderers receive the FULL field
|
|
382
|
+
* metadata — `options`, `currency`, `scale`, `precision`, `format`,
|
|
383
|
+
* `reference_to`, … — exactly like the list view enriches its columns from
|
|
384
|
+
* the object schema. Without it a `select` column falls back to
|
|
385
|
+
* title-casing the raw stored value instead of resolving the option label
|
|
386
|
+
* (#3333: `manufacturing` rendered as "Manufacturing" instead of the
|
|
387
|
+
* authored option label).
|
|
388
|
+
*
|
|
389
|
+
* The filter bar reads the same map: a `select` filter column with no
|
|
390
|
+
* authored `options` takes them from the schema field here, so the filter
|
|
391
|
+
* panel's dropdown offers exactly the options the table cells render
|
|
392
|
+
* (#3336 — it used to open empty, leaving the field unfilterable).
|
|
393
|
+
*/
|
|
394
|
+
fieldsMeta?: Record<string, any>;
|
|
395
|
+
|
|
281
396
|
/**
|
|
282
397
|
* Filter bar column definitions.
|
|
283
398
|
* When provided, shows an inline filter bar below the search input.
|
|
@@ -344,11 +459,13 @@ export function RecordPickerDialog({
|
|
|
344
459
|
lookupFilters,
|
|
345
460
|
baseFilter,
|
|
346
461
|
cellRenderer,
|
|
462
|
+
fieldsMeta,
|
|
347
463
|
filterColumns,
|
|
348
464
|
renderFilterBar,
|
|
349
465
|
renderGrid,
|
|
350
466
|
}: RecordPickerDialogProps) {
|
|
351
467
|
const { t } = useFieldTranslation();
|
|
468
|
+
const { translateOptions } = useSafeFieldLabel();
|
|
352
469
|
|
|
353
470
|
// Query state (records/loading/error/total + page/search/sort) lives in the
|
|
354
471
|
// shared useRecordQuery kernel — instantiated after mergedFilter below.
|
|
@@ -385,9 +502,31 @@ export function RecordPickerDialog({
|
|
|
385
502
|
return [{ field: displayField, label: fieldToLabel(displayField) }];
|
|
386
503
|
}, [columnsProp, displayField]);
|
|
387
504
|
|
|
505
|
+
// Field descriptors handed to the type-aware cell renderers, enriched from
|
|
506
|
+
// the referenced object's schema (`fieldsMeta`) the same way the list view
|
|
507
|
+
// enriches its columns. This is what lets a `select` column resolve its
|
|
508
|
+
// option label (options + i18n) instead of title-casing the raw value
|
|
509
|
+
// (#3333). Columns whose def carries no `type` inherit the schema field's
|
|
510
|
+
// type so authored string `lookup_columns` format identically.
|
|
511
|
+
const columnFieldDescriptors = useMemo<Record<string, any>>(() => {
|
|
512
|
+
const map: Record<string, any> = {};
|
|
513
|
+
for (const col of resolvedColumns) {
|
|
514
|
+
const meta = fieldsMeta?.[col.field];
|
|
515
|
+
const type = col.type ?? meta?.type;
|
|
516
|
+
if (!type) continue;
|
|
517
|
+
const descriptor: any = meta
|
|
518
|
+
? { ...meta, name: col.field, type }
|
|
519
|
+
: { name: col.field, type };
|
|
520
|
+
const options = resolveSchemaOptions(meta, objectName, col.field, translateOptions);
|
|
521
|
+
if (options) descriptor.options = options;
|
|
522
|
+
map[col.field] = descriptor;
|
|
523
|
+
}
|
|
524
|
+
return map;
|
|
525
|
+
}, [resolvedColumns, fieldsMeta, objectName, translateOptions]);
|
|
526
|
+
|
|
388
527
|
// Auto-generate filter columns from lookupFilters when no explicit filterColumns given.
|
|
389
528
|
// Each LookupFilterDef becomes a filterable field with inferred type.
|
|
390
|
-
const
|
|
529
|
+
const baseFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
391
530
|
if (filterColumns && filterColumns.length > 0) return filterColumns;
|
|
392
531
|
// Auto-derive from lookupFilters: each filter entry becomes a filterable field
|
|
393
532
|
if (lookupFilters && lookupFilters.length > 0) {
|
|
@@ -425,6 +564,28 @@ export function RecordPickerDialog({
|
|
|
425
564
|
return undefined;
|
|
426
565
|
}, [filterColumns, lookupFilters]);
|
|
427
566
|
|
|
567
|
+
// Filter columns as the filter bar consumes them: every `select` filter
|
|
568
|
+
// carries the options its schema field declares. The filter panel's Select reads
|
|
569
|
+
// `col.options` — a derived select column (LookupField turns each typed picker
|
|
570
|
+
// column into a filter column) carries none, so the dropdown opened empty and
|
|
571
|
+
// the field could not be filtered at all (#3336).
|
|
572
|
+
//
|
|
573
|
+
// The options come from `fieldsMeta` through `resolveSchemaOptions`, i.e. the
|
|
574
|
+
// SAME schema source + i18n translation the table cells use (#3333) — not a
|
|
575
|
+
// second derivation that could drift from the cells. An explicitly authored
|
|
576
|
+
// `options` list on the filter column always wins (it is the more specific
|
|
577
|
+
// statement), and a schema field with no options stays optionless: an empty
|
|
578
|
+
// dropdown for THAT field is the honest rendering of missing metadata.
|
|
579
|
+
const effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
580
|
+
if (!baseFilterColumns) return undefined;
|
|
581
|
+
return baseFilterColumns.map(col => {
|
|
582
|
+
if (col.type !== 'select') return col;
|
|
583
|
+
if (col.options && col.options.length > 0) return col;
|
|
584
|
+
const options = resolveSchemaOptions(fieldsMeta?.[col.field], objectName, col.field, translateOptions);
|
|
585
|
+
return options ? { ...col, options } : col;
|
|
586
|
+
});
|
|
587
|
+
}, [baseFilterColumns, fieldsMeta, objectName, translateOptions]);
|
|
588
|
+
|
|
428
589
|
// Merge base lookup_filters with user filter bar values. The hard
|
|
429
590
|
// `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so
|
|
430
591
|
// user filter-bar input can never widen it back out.
|
|
@@ -637,11 +798,13 @@ export function RecordPickerDialog({
|
|
|
637
798
|
|
|
638
799
|
const val = record[col.field];
|
|
639
800
|
|
|
640
|
-
// Use type-aware renderer when column type
|
|
641
|
-
|
|
642
|
-
|
|
801
|
+
// Use type-aware renderer when a field descriptor (column `type`, or the
|
|
802
|
+
// schema field's type via `fieldsMeta`) and a resolver are available.
|
|
803
|
+
const descriptor = columnFieldDescriptors[col.field];
|
|
804
|
+
if (descriptor && cellRenderer) {
|
|
805
|
+
const Renderer = cellRenderer(descriptor.type);
|
|
643
806
|
if (Renderer) {
|
|
644
|
-
return <Renderer value={val} field={
|
|
807
|
+
return <Renderer value={val} field={descriptor} />;
|
|
645
808
|
}
|
|
646
809
|
}
|
|
647
810
|
|
|
@@ -657,7 +820,7 @@ export function RecordPickerDialog({
|
|
|
657
820
|
}
|
|
658
821
|
if (typeof val === 'boolean') return val ? 'Yes' : 'No';
|
|
659
822
|
return String(val);
|
|
660
|
-
}, [cellRenderer, titleFormat, displayField]);
|
|
823
|
+
}, [cellRenderer, titleFormat, displayField, columnFieldDescriptors]);
|
|
661
824
|
|
|
662
825
|
// Render sort indicator for a column
|
|
663
826
|
const renderSortIcon = useCallback((field: string) => {
|
|
@@ -727,9 +890,11 @@ export function RecordPickerDialog({
|
|
|
727
890
|
return (
|
|
728
891
|
<div className="space-y-1">
|
|
729
892
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
893
|
+
{/* The control speaks string; the stored filter value keeps the
|
|
894
|
+
authored option type (#3422 — see `matchOptionValue`). */}
|
|
730
895
|
<Select
|
|
731
|
-
value={
|
|
732
|
-
onValueChange={v => handleFilterChange(col.field, v)}
|
|
896
|
+
value={toControlValue(val) ?? ''}
|
|
897
|
+
onValueChange={v => handleFilterChange(col.field, matchOptionValue(col.options, v))}
|
|
733
898
|
>
|
|
734
899
|
<SelectTrigger className="h-8 text-xs">
|
|
735
900
|
<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 {
|
|
4
|
+
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
|
|
5
|
+
import { FieldWidgetComponentProps } from './types';
|
|
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
|
+
}
|
|
5
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
|
}
|