@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.
Files changed (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. 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 constraints applied to every query, in QueryParams.$filter
266
- * record form. Unlike `lookupFilters`, entries here never surface in the
267
- * filter bar and cannot be overridden by user filter input — used for the
268
- * dependent (cascading) lookup chain, where the parent field's value MUST
269
- * scope the candidate set (#2215).
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?: Record<string, any>;
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 effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
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
- // Merge base lookup_filters with user filter bar values. The hard
429
- // `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so
430
- // user filter-bar input can never widen it back out.
431
- const mergedFilter = useMemo<Record<string, any> | undefined>(() => {
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 combined = { ...lookupBase, ...userFilter, ...(baseFilter ?? {}) };
439
- return Object.keys(combined).length > 0 ? combined : undefined;
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 and resolver are available
641
- if (col.type && cellRenderer) {
642
- const Renderer = cellRenderer(col.type);
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={{ name: col.field, type: col.type } as any} />;
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={val !== undefined && val !== null ? String(val) : ''}
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, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
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 = (field || (props as any).schema) as any;
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
- <div className="space-y-2">
29
- <div className="flex items-center justify-between text-xs text-gray-500">
30
- <span>{t('fields.richText.format', { format, defaultValue: `Format: ${format}` })}</span>
31
- <span className="italic">
32
- {t('fields.richText.basicEditorHint', { defaultValue: 'Rich text editor (basic)' })}
33
- </span>
34
- </div>
35
- <Textarea
36
- value={value || ''}
37
- onChange={(e) => onChange(e.target.value)}
38
- placeholder={
39
- richField?.placeholder ||
40
- t('fields.richText.placeholder', { defaultValue: 'Enter text...' })
41
- }
42
- disabled={readonly || props.disabled}
43
- rows={rows}
44
- className={`font-mono text-sm ${props.className || ''}`}
45
- aria-invalid={!!errorMessage}
46
- />
47
- </div>
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
  }