@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.
Files changed (171) hide show
  1. package/CHANGELOG.md +786 -0
  2. package/dist/FieldEditWidget.d.ts +2 -2
  3. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  4. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  5. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  6. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  7. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  8. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  9. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  10. package/dist/index.cjs +5 -5
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +2882 -2569
  13. package/dist/widgets/AddressField.d.ts +2 -2
  14. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  15. package/dist/widgets/AutoNumberField.d.ts +2 -2
  16. package/dist/widgets/AvatarField.d.ts +2 -2
  17. package/dist/widgets/BooleanField.d.ts +2 -2
  18. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  19. package/dist/widgets/CharacterCount.d.ts +56 -0
  20. package/dist/widgets/CheckboxesField.d.ts +2 -2
  21. package/dist/widgets/CodeField.d.ts +2 -2
  22. package/dist/widgets/ColorField.d.ts +2 -2
  23. package/dist/widgets/CurrencyField.d.ts +2 -2
  24. package/dist/widgets/DateField.d.ts +2 -2
  25. package/dist/widgets/DateTimeField.d.ts +2 -2
  26. package/dist/widgets/EmailField.d.ts +2 -2
  27. package/dist/widgets/FileField.d.ts +2 -2
  28. package/dist/widgets/FilterConditionField.d.ts +2 -2
  29. package/dist/widgets/FormulaField.d.ts +2 -2
  30. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  31. package/dist/widgets/GeolocationField.d.ts +2 -2
  32. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  33. package/dist/widgets/GridField.d.ts +2 -2
  34. package/dist/widgets/ImageField.d.ts +2 -2
  35. package/dist/widgets/LocationField.d.ts +2 -2
  36. package/dist/widgets/LookupField.d.ts +2 -2
  37. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  38. package/dist/widgets/MasterDetailField.d.ts +2 -2
  39. package/dist/widgets/MultiSelectField.d.ts +2 -2
  40. package/dist/widgets/NumberField.d.ts +2 -2
  41. package/dist/widgets/ObjectField.d.ts +2 -2
  42. package/dist/widgets/ObjectRefField.d.ts +2 -2
  43. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  44. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  45. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  46. package/dist/widgets/PasswordField.d.ts +2 -2
  47. package/dist/widgets/PercentField.d.ts +2 -2
  48. package/dist/widgets/PhoneField.d.ts +2 -2
  49. package/dist/widgets/QRCodeField.d.ts +2 -2
  50. package/dist/widgets/RadioField.d.ts +2 -2
  51. package/dist/widgets/RatingField.d.ts +2 -2
  52. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  53. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  54. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  55. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  56. package/dist/widgets/RichTextField.d.ts +20 -2
  57. package/dist/widgets/SelectField.d.ts +2 -2
  58. package/dist/widgets/SignatureField.d.ts +2 -2
  59. package/dist/widgets/SliderField.d.ts +2 -2
  60. package/dist/widgets/SummaryField.d.ts +2 -2
  61. package/dist/widgets/TagsField.d.ts +2 -2
  62. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  63. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  64. package/dist/widgets/TextAreaField.d.ts +45 -6
  65. package/dist/widgets/TextField.d.ts +2 -2
  66. package/dist/widgets/TimeField.d.ts +2 -2
  67. package/dist/widgets/UrlField.d.ts +2 -2
  68. package/dist/widgets/UserField.d.ts +2 -2
  69. package/dist/widgets/VectorField.d.ts +2 -2
  70. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  71. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  72. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  73. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  74. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  75. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  76. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  77. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  78. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  79. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  80. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  81. package/dist/widgets/file-value.d.ts +20 -33
  82. package/dist/widgets/toDomProps.d.ts +91 -0
  83. package/dist/widgets/types.d.ts +215 -4
  84. package/dist/withFieldCarrier.d.ts +56 -0
  85. package/package.json +13 -13
  86. package/src/FieldEditWidget.tsx +6 -4
  87. package/src/__tests__/cell-truncation.test.tsx +96 -0
  88. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  89. package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
  90. package/src/__tests__/validation-feedback.test.tsx +22 -13
  91. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  92. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  93. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  94. package/src/__tests__/widget-props-contract.test.tsx +142 -0
  95. package/src/complex-widgets.test.tsx +3 -3
  96. package/src/datetime-widgets.test.tsx +2 -2
  97. package/src/index.tsx +77 -27
  98. package/src/widgets/AddressField.tsx +32 -13
  99. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  100. package/src/widgets/AutoNumberField.tsx +2 -2
  101. package/src/widgets/AvatarField.tsx +10 -3
  102. package/src/widgets/BooleanField.tsx +5 -5
  103. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  104. package/src/widgets/CharacterCount.tsx +263 -0
  105. package/src/widgets/CheckboxesField.tsx +35 -18
  106. package/src/widgets/CodeField.tsx +10 -3
  107. package/src/widgets/ColorField.tsx +12 -3
  108. package/src/widgets/CurrencyField.tsx +6 -5
  109. package/src/widgets/DateField.tsx +4 -4
  110. package/src/widgets/DateTimeField.tsx +4 -4
  111. package/src/widgets/EmailField.tsx +6 -6
  112. package/src/widgets/FileField.tsx +14 -4
  113. package/src/widgets/FilterConditionField.tsx +14 -3
  114. package/src/widgets/FormulaField.tsx +3 -3
  115. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  116. package/src/widgets/GeolocationField.tsx +22 -7
  117. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  118. package/src/widgets/GridField.tsx +4 -4
  119. package/src/widgets/ImageField.tsx +10 -3
  120. package/src/widgets/LocationField.tsx +10 -3
  121. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  122. package/src/widgets/LookupField.tsx +50 -15
  123. package/src/widgets/MasterDetailField.tsx +3 -3
  124. package/src/widgets/MultiSelectField.tsx +34 -18
  125. package/src/widgets/NumberField.tsx +5 -5
  126. package/src/widgets/ObjectField.tsx +18 -9
  127. package/src/widgets/ObjectRefField.tsx +14 -4
  128. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  129. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  130. package/src/widgets/OptionsEmptyState.tsx +83 -0
  131. package/src/widgets/PasswordField.tsx +5 -5
  132. package/src/widgets/PeoplePicker.tsx +7 -1
  133. package/src/widgets/PercentField.tsx +6 -5
  134. package/src/widgets/PhoneField.tsx +6 -6
  135. package/src/widgets/QRCodeField.tsx +10 -3
  136. package/src/widgets/RadioField.tsx +28 -18
  137. package/src/widgets/RatingField.tsx +19 -4
  138. package/src/widgets/RecipientPickerField.tsx +22 -4
  139. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  140. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  141. package/src/widgets/RecordPickerDialog.tsx +173 -8
  142. package/src/widgets/RichTextField.tsx +152 -25
  143. package/src/widgets/SelectField.tsx +53 -22
  144. package/src/widgets/SignatureField.tsx +2 -2
  145. package/src/widgets/SliderField.tsx +3 -3
  146. package/src/widgets/SummaryField.tsx +3 -3
  147. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  148. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  149. package/src/widgets/TagsField.tsx +25 -4
  150. package/src/widgets/TextAreaField.tsx +172 -94
  151. package/src/widgets/TextField.tsx +5 -5
  152. package/src/widgets/TimeField.tsx +4 -4
  153. package/src/widgets/UrlField.tsx +6 -6
  154. package/src/widgets/UserField.tsx +3 -3
  155. package/src/widgets/VectorField.tsx +3 -3
  156. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  157. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  158. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  159. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  160. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  161. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  162. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  163. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  164. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  165. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  166. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  167. package/src/widgets/file-value.ts +21 -5
  168. package/src/widgets/toDomProps.ts +175 -0
  169. package/src/widgets/types.ts +230 -7
  170. package/src/widgets/useFieldTranslation.ts +60 -0
  171. 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 effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
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 and resolver are available
641
- if (col.type && cellRenderer) {
642
- const Renderer = cellRenderer(col.type);
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={{ name: col.field, type: col.type } as any} />;
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={val !== undefined && val !== null ? String(val) : ''}
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 { FieldWidgetProps } from './types';
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, errorMessage, ...props }: FieldWidgetProps<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
  }