@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  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/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
package/src/index.tsx DELETED
@@ -1,2745 +0,0 @@
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
- import React from 'react';
10
- import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
- import { ComponentRegistry, percentDisplayValue, getRecordDisplayName } from '@object-ui/core';
12
- import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
13
- import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
14
- import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
15
- import { useObjectTranslation } from '@object-ui/react';
16
- import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
17
- import { useRelatedRecordActions } from '@object-ui/react';
18
- import { withFieldCarrier } from './withFieldCarrier';
19
- // Pure formatting rule shared with `AddressField`'s readonly branch — no React,
20
- // so this does not pull the widget out of its lazy chunk (objectui#4037).
21
- import { formatAddress, type AddressValue } from './widgets/address-format';
22
-
23
- // Module-level cache so multiple renderers fetching the same lookup ID
24
- // only trigger one network call. Keyed by `${objectName}:${id}`.
25
- type LookupCacheEntry =
26
- | { state: 'pending'; promise: Promise<void> }
27
- | { state: 'ok'; name: string | undefined }
28
- | { state: 'err' };
29
- const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
30
-
31
- /**
32
- * Pick the most reasonable display name from an arbitrary record object.
33
- * Tries common name-like keys in priority order, then falls back to undefined.
34
- */
35
- export function pickRecordDisplayName(
36
- record: Record<string, unknown> | null | undefined,
37
- preferredField?: string,
38
- ): string | undefined {
39
- if (!record || typeof record !== 'object') return undefined;
40
- // Caller-provided hint (typically the target object's displayNameField)
41
- // beats every heuristic so domain-specific names like `legal_name` win.
42
- if (preferredField) {
43
- const pv = record[preferredField];
44
- if (typeof pv === 'string' && pv.trim()) return pv.trim();
45
- if (typeof pv === 'number') return String(pv);
46
- }
47
- const candidates = ['name', 'full_name', 'display_name', 'label', 'title', 'subject', 'username'];
48
- for (const k of candidates) {
49
- const v = record[k];
50
- if (typeof v === 'string' && v.trim()) return v.trim();
51
- if (typeof v === 'number') return String(v);
52
- }
53
- // Salesforce-style: build a composite name from common person-record
54
- // fields when no top-level display field is present. Preferred over the
55
- // raw `email` fallback below so `Bob Lin` beats `bob.lin@acme.com`.
56
- const first = record['first_name'];
57
- const last = record['last_name'];
58
- const salutation = record['salutation'];
59
- const composite = [salutation, first, last]
60
- .filter((p) => typeof p === 'string' && (p as string).trim())
61
- .map((p) => (p as string).trim())
62
- .join(' ');
63
- if (composite) return composite;
64
- // Email is the last-resort identifier (better than the opaque id).
65
- const email = record['email'];
66
- if (typeof email === 'string' && email.trim()) return email.trim();
67
- // Heuristic fallback: pick the first string-valued field whose name looks
68
- // like a human-facing identifier (legal_name, framework_name, control_number,
69
- // policy_code, etc.). This covers domain schemas that don't use the
70
- // hardcoded canonical names above. We skip obvious metadata keys.
71
- const SKIP = new Set([
72
- 'id', '_id', 'organization_id', 'created_by', 'updated_by',
73
- 'created_at', 'updated_at', 'tenant_id',
74
- ]);
75
- const SUFFIXES = ['_name', '_title', '_number', '_code', '_label'];
76
- for (const [k, v] of Object.entries(record)) {
77
- if (SKIP.has(k)) continue;
78
- if (k.endsWith('_id')) continue;
79
- if (!SUFFIXES.some((s) => k.endsWith(s))) continue;
80
- if (typeof v === 'string' && v.trim()) return v.trim();
81
- if (typeof v === 'number') return String(v);
82
- }
83
- return undefined;
84
- }
85
-
86
- // Module-level cache of referenced-object schemas (keyed by object name) so
87
- // many lookup cells pointing at the same object trigger ONE metadata fetch.
88
- type RefSchemaCacheEntry =
89
- | { state: 'pending'; promise: Promise<void> }
90
- | { state: 'ok'; schema: unknown }
91
- | { state: 'err' };
92
- const refObjectSchemaCache: Map<string, RefSchemaCacheEntry> = new Map();
93
-
94
- /**
95
- * Fetch (and cache) the referenced object's schema via
96
- * `dataSource.getObjectSchema`. Resolves to `undefined` when the data source
97
- * doesn't expose schema metadata or the fetch fails — callers fall back to the
98
- * key heuristic in that case.
99
- */
100
- async function fetchRefObjectSchema(dataSource: any, referenceTo: string): Promise<any> {
101
- const settled = () => {
102
- const entry = refObjectSchemaCache.get(referenceTo);
103
- return entry?.state === 'ok' ? entry.schema : undefined;
104
- };
105
- const existing = refObjectSchemaCache.get(referenceTo);
106
- if (existing?.state === 'pending') {
107
- await existing.promise;
108
- return settled();
109
- }
110
- if (existing) return settled();
111
- const getSchema = dataSource?.getObjectSchema;
112
- if (typeof getSchema !== 'function') return undefined;
113
- const promise = Promise.resolve()
114
- .then(() => getSchema.call(dataSource, referenceTo))
115
- .then((schema: unknown) => { refObjectSchemaCache.set(referenceTo, { state: 'ok', schema }); })
116
- .catch(() => { refObjectSchemaCache.set(referenceTo, { state: 'err' }); });
117
- refObjectSchemaCache.set(referenceTo, { state: 'pending', promise });
118
- await promise;
119
- return settled();
120
- }
121
-
122
- /**
123
- * React hook wrapper over {@link fetchRefObjectSchema}: returns the referenced
124
- * object's schema once loaded (re-rendering when it lands), `undefined` while
125
- * pending or when schema metadata isn't available.
126
- */
127
- function useRefObjectSchema(referenceTo: string | undefined): any {
128
- const ctx = React.useContext(_SchemaRendererContext);
129
- const dataSource = ctx?.dataSource as any;
130
- const [, force] = React.useState(0);
131
- const canFetch =
132
- !!referenceTo && !!dataSource && typeof dataSource.getObjectSchema === 'function';
133
-
134
- React.useEffect(() => {
135
- if (!canFetch) return;
136
- const entry = refObjectSchemaCache.get(referenceTo!);
137
- if (entry && entry.state !== 'pending') return; // settled before this render
138
- let alive = true;
139
- fetchRefObjectSchema(dataSource, referenceTo!).then(() => {
140
- if (alive) force((n) => n + 1);
141
- });
142
- return () => { alive = false; };
143
- }, [canFetch, referenceTo, dataSource]);
144
-
145
- if (!canFetch) return undefined;
146
- const entry = refObjectSchemaCache.get(referenceTo!);
147
- return entry?.state === 'ok' ? entry.schema : undefined;
148
- }
149
-
150
- /**
151
- * Resolve an expanded/fetched lookup record to its display name the same way
152
- * the picker does (issue #2357): explicit `displayField` first, then the
153
- * unified ADR-0079 resolver against the referenced object's schema
154
- * (`nameField`/`displayNameField` → `titleFormat` → type-aware derivation,
155
- * which excludes autonumber). Only when no schema is available does it fall
156
- * back to {@link pickRecordDisplayName}, whose `_number`/`_code` suffix scan
157
- * can otherwise surface an autonumber (`0001`) over the record's real name.
158
- */
159
- function resolveLookupRecordName(
160
- record: Record<string, unknown> | null | undefined,
161
- refSchema?: any,
162
- displayField?: string,
163
- ): string | undefined {
164
- if (!record || typeof record !== 'object') return undefined;
165
- if (refSchema) {
166
- const resolved = getRecordDisplayName(refSchema, record, { titleField: displayField });
167
- // Stop short of the resolver's `Record #<id>` / `Untitled` floor so the
168
- // cell keeps its own id-placeholder handling for nameless records.
169
- const id = (record as any).id ?? (record as any)._id;
170
- const isFloor =
171
- resolved === 'Untitled' || (id != null && resolved === `Record #${id}`);
172
- if (!isFloor && resolved) return resolved;
173
- }
174
- return pickRecordDisplayName(record, displayField);
175
- }
176
-
177
- /**
178
- * Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
179
- * or BSON ObjectId). Used so we don't display random gibberish to users when
180
- * a lookup wasn't expanded.
181
- */
182
- export function isLikelyOpaqueId(v: unknown): boolean {
183
- if (typeof v !== 'string') return false;
184
- // 12-32 chars, only [A-Za-z0-9_-], no whitespace.
185
- if (!/^[A-Za-z0-9_-]{12,32}$/.test(v)) return false;
186
- // Must have BOTH upper- and lower-case letters (real words rarely do at this length).
187
- // Also accept tokens that contain `_` or `-` separators alongside any case mix.
188
- const hasUpper = /[A-Z]/.test(v);
189
- const hasLower = /[a-z]/.test(v);
190
- const hasDigitOrSep = /[0-9_-]/.test(v);
191
- return (hasUpper && hasLower) || (hasUpper && hasDigitOrSep) || (hasLower && hasDigitOrSep);
192
- }
193
-
194
- /**
195
- * Fetch-on-demand resolver for foreign-key IDs that weren't expanded by the
196
- * server. Reads `dataSource` from SchemaRendererContext; safely no-ops if
197
- * the context isn't installed. Returns the resolved display name or
198
- * `undefined` while pending or unresolvable.
199
- */
200
- function useLookupName(
201
- referenceTo: string | undefined,
202
- value: unknown,
203
- displayField?: string,
204
- ): string | undefined {
205
- const ctx = React.useContext(_SchemaRendererContext);
206
- const dataSource = ctx?.dataSource;
207
- const [, force] = React.useState(0);
208
-
209
- const isResolvable =
210
- !!referenceTo &&
211
- !!dataSource &&
212
- typeof dataSource.find === 'function' &&
213
- (typeof value === 'string' || typeof value === 'number') &&
214
- value !== '';
215
- // The preferred display field is part of the cache identity: two columns
216
- // targeting the same record with different `display_field`s must not
217
- // serve each other's cached name (#2926 ⑧).
218
- const cacheKey = isResolvable
219
- ? `${referenceTo}:${String(value)}:${displayField ?? ''}`
220
- : '';
221
-
222
- React.useEffect(() => {
223
- if (!isResolvable) return;
224
- const existing = lookupNameCache.get(cacheKey);
225
- if (existing && existing.state !== 'pending' && (existing as any).promise == null) return;
226
- if (existing?.state === 'pending') return;
227
-
228
- const promise: Promise<void> = (async () => {
229
- try {
230
- let record: Record<string, unknown> | undefined;
231
- if (typeof (dataSource as any).findOne === 'function') {
232
- record = await (dataSource as any).findOne(referenceTo, value);
233
- } else {
234
- const result = await (dataSource as any).find(referenceTo, {
235
- $filter: { id: value },
236
- options: { $top: 1 },
237
- });
238
- const records: any[] = Array.isArray(result)
239
- ? result
240
- : (result?.value || result?.data || []);
241
- record = records[0];
242
- }
243
- // Resolve through the referenced object's schema (nameField /
244
- // titleFormat) so the chip agrees with the picker — the bare key
245
- // heuristic alone can surface an autonumber over the real name.
246
- const schema = await fetchRefObjectSchema(dataSource, referenceTo!);
247
- const name = resolveLookupRecordName(record, schema, displayField);
248
- lookupNameCache.set(cacheKey, { state: 'ok', name });
249
- } catch {
250
- lookupNameCache.set(cacheKey, { state: 'err' });
251
- }
252
- force((n) => n + 1);
253
- })();
254
-
255
- lookupNameCache.set(cacheKey, { state: 'pending', promise });
256
- }, [cacheKey, isResolvable, referenceTo, value, displayField, dataSource]);
257
-
258
- if (!isResolvable) return undefined;
259
- const entry = lookupNameCache.get(cacheKey);
260
- return entry?.state === 'ok' ? entry.name : undefined;
261
- }
262
-
263
- /**
264
- * Safe label resolver for cell-level UI strings. Falls back to the English
265
- * default when no I18nProvider is available or when the key is missing.
266
- */
267
- function useFieldLabel() {
268
- // useObjectTranslation is provider-safe (its context read is optional and
269
- // react-i18next falls back to the global instance), so no try/catch —
270
- // wrapping a hook call in try/catch violates rules-of-hooks: a throw after
271
- // some hooks ran would desync hook order on the next render.
272
- const { t } = useObjectTranslation();
273
- return (key: string, fallback: string) => {
274
- const v = t(key);
275
- return !v || v === key ? fallback : v;
276
- };
277
- }
278
-
279
- /**
280
- * Raw translate fn (with interpolation params) for cell-level strings, or
281
- * undefined when no I18nProvider is mounted — callers keep their English
282
- * fallback in that case.
283
- */
284
- function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) => string) | undefined {
285
- // useObjectTranslation is provider-safe (optional context read; react-i18next
286
- // falls back to the global instance), so it never throws — no try/catch, which
287
- // would wrap a hook call and violate rules-of-hooks. Mirrors useFieldLabel.
288
- const { t } = useObjectTranslation();
289
- return t as (key: string, params?: Record<string, unknown>) => string;
290
- }
291
-
292
- // Only the two symbols this module actually CALLS are imported eagerly. Every
293
- // field widget used to be imported here as well, statically, purely to hand a
294
- // component reference to the docs-demo `createFieldRenderer()` wrapper —
295
- // removed with it (objectui#3910). The live registration path resolves widgets
296
- // through the lazy loaders in `fieldWidgetMap` below, so nothing here needs a
297
- // static reference; the widgets stay publicly available via the `export * from
298
- // './widgets/…'` block at the end of this file.
299
- import { ImageLightbox } from './widgets/ImageLightbox';
300
- import { readFileValues } from './widgets/file-value';
301
-
302
- /**
303
- * Cell renderer props
304
- */
305
- export interface CellRendererProps {
306
- value: any;
307
- field: FieldMetadata;
308
- isEditing?: boolean;
309
- onChange?: (value: any) => void;
310
- }
311
-
312
- /**
313
- * Coerce a value to a safe primitive for rendering.
314
- * Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
315
- * reference objects, and arrays so that no raw object is ever passed as
316
- * a React child — preventing React error #310.
317
- */
318
- export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
319
- if (value == null) return value as null | undefined;
320
- if (typeof value === 'number' || typeof value === 'boolean') return value;
321
- if (typeof value === 'string') {
322
- // A reference/expanded value can arrive as a JSON-encoded object string —
323
- // e.g. an unresolved external-id reference '{"externalId":"Website Relaunch"}'.
324
- // Parse and extract a human label instead of leaking raw JSON into the cell.
325
- const s = value.trim();
326
- if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
327
- try { return coerceToSafeValue(JSON.parse(s)); } catch { /* not JSON — fall through */ }
328
- }
329
- return value;
330
- }
331
- if (value instanceof Date) return value.toISOString();
332
- if (Array.isArray(value)) {
333
- return value.map((v) => {
334
- if (v != null && typeof v === 'object') {
335
- const obj = v as Record<string, unknown>;
336
- return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
337
- }
338
- return String(v);
339
- }).join(', ');
340
- }
341
- if (typeof value === 'object') {
342
- const obj = value as Record<string, unknown>;
343
- // MongoDB numeric wrapper: { $numberDecimal: "250000" }
344
- if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
345
- // MongoDB ObjectId wrapper: { $oid: "abc123" }
346
- if ('$oid' in obj) return String(obj.$oid);
347
- // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
348
- if ('$date' in obj) return String(obj.$date);
349
- // Expanded reference / general object: extract name/label/externalId/id
350
- return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
351
- }
352
- return String(value);
353
- }
354
-
355
- /**
356
- * Format currency value. When `currency` is undefined, falls back to a
357
- * plain number with thousands separators (no symbol). Silently assuming
358
- * USD for unconfigured currency fields was the #1 source of "why is my
359
- * RMB amount showing as dollars?" bug reports.
360
- *
361
- * Trailing minor units are dropped when the value is a whole number —
362
- * Salesforce convention: `$1,234.50` keeps cents; `$1,234` does not. Wholeness
363
- * picks ONE fraction-digit width, never a range: a whole amount shows 0 digits,
364
- * and a fractional amount shows the width the CURRENCY has — so a real cents
365
- * value of `.50` renders `.50`, not `.5`, and a yen amount renders `¥1,235`
366
- * rather than cents the yen does not have.
367
- */
368
- import { resolveFieldCurrency, currencyFractionDigits } from './currency';
369
- export { resolveFieldCurrency };
370
-
371
- export function formatCurrency(value: number, currency?: string, locale?: string): string {
372
- const isWhole = Number.isFinite(value) && value === Math.trunc(value);
373
- // ONE width for both bounds, not a range (objectui#4332). The symbol branch
374
- // used to pass `minimumFractionDigits: 0` against a wholeness-switched
375
- // maximum, which handed Intl the range [0, 2] — and Intl then emits the
376
- // SHORTEST representation in range, so a genuine cents value of `.50` was
377
- // printed as `.5`: `$1,234.5` instead of the `$1,234.50` promised above.
378
- // It was the only branch that did: the no-currency branch reaches
379
- // `formatNumber`, which sets both bounds to the width it is given, and the
380
- // bad-currency fallback below has always used `toFixed`. Both already
381
- // rendered `1,234.50` for the same amount.
382
- //
383
- // The non-whole width is the CURRENCY's own ISO 4217 minor-unit count, not a
384
- // literal 2 (objectui#4361). Passing 2 for every currency on earth OVERRODE
385
- // what `Intl` already knows: JPY has no minor unit and KWD has three, so a
386
- // yen amount was printed with cents it does not have (`¥1,234.50`) and a
387
- // dinar amount one digit short (`KWD 1.50`).
388
- //
389
- // The wholeness switch itself is NOT retired — dropping both bounds and
390
- // letting `Intl` decide would fix the digit count by turning `$1,234` back
391
- // into `$1,234.00`, which is exactly the convention this function documents
392
- // and objectui#4033 pinned. It is extended instead: whole amounts drop the
393
- // fraction for EVERY currency (`KWD 1`, not `KWD 1.000`), fractional amounts
394
- // take that currency's own count.
395
- //
396
- // With no currency in hand there is nothing to derive from, so that branch
397
- // keeps the historical 2.
398
- const fracDigits = isWhole ? 0 : currency ? currencyFractionDigits(currency) : 2;
399
- if (!currency) {
400
- return formatNumber(value, fracDigits, locale);
401
- }
402
- try {
403
- return formatDisplayNumber(value, {
404
- locale,
405
- currency,
406
- minimumFractionDigits: fracDigits,
407
- maximumFractionDigits: fracDigits,
408
- });
409
- } catch {
410
- return `${currency} ${value.toFixed(fracDigits)}`;
411
- }
412
- }
413
-
414
- /**
415
- * Format currency value in compact form for mobile display.
416
- * E.g., $150,000 → $150K, $1,200,000 → $1.2M
417
- * When `currency` is undefined, returns a compact number without symbol.
418
- */
419
- export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
420
- if (!currency) {
421
- try {
422
- const formatted = formatDisplayNumber(value, {
423
- locale,
424
- notation: 'compact',
425
- maximumFractionDigits: 1,
426
- });
427
- return formatted.replace(/\.0(?=[KMBT])/, '');
428
- } catch {
429
- return String(value);
430
- }
431
- }
432
- try {
433
- const formatted = formatDisplayNumber(value, {
434
- locale,
435
- currency,
436
- notation: 'compact',
437
- maximumFractionDigits: 1,
438
- });
439
- // Strip trailing ".0" before compact suffix for consistent cross-environment output
440
- // e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
441
- return formatted.replace(/\.0(?=[KMBT])/, '');
442
- } catch {
443
- return `${currency} ${value}`;
444
- }
445
- }
446
-
447
- /**
448
- * Format a plain number with thousands separators, no currency symbol.
449
- * Used as a safe fallback when a currency-typed field has no `currency`
450
- * configured — we'd rather render `1,234.50` than silently assume USD.
451
- */
452
- export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
453
- try {
454
- // Deliberately passes NO `scale`: `decimals` here is a display width the
455
- // caller chose, not a field's declared scale, so the ordinal no-grouping
456
- // policy must not fire. `formatCurrency`'s no-currency fallback lands here
457
- // with `decimals: 0` for a whole amount — that is still money and must keep
458
- // its separators.
459
- return formatDisplayNumber(value, {
460
- locale,
461
- minimumFractionDigits: decimals,
462
- maximumFractionDigits: decimals,
463
- });
464
- } catch {
465
- return value.toFixed(decimals);
466
- }
467
- }
468
-
469
- /**
470
- * The percent rendering itself, on a value ALREADY in display magnitude (`80`
471
- * means 80%).
472
- *
473
- * Split out from {@link formatPercent} because `PercentCellRenderer`'s
474
- * whole-percent branch needs this exact rendering under a DIFFERENT scaling
475
- * policy (see there). Two copies of the expression is precisely the drift
476
- * `percentDisplayValue`'s doc comment exists to prevent, so there is one copy
477
- * and the scaling decision is made by the caller.
478
- */
479
- function formatPercentBody(displayValue: number, precision: number, locale?: string): string {
480
- try {
481
- // `style: 'percentPoints'` renders a value that is ALREADY in percentage
482
- // points, so there is no `/ 100` here. Going through `Intl` rather than
483
- // appending a literal '%' is what buys the locale's percent CONVENTION and
484
- // not merely its separators: German writes `1.235 %` with a no-break space
485
- // before the sign, English `1,235%` with none, Turkish puts the sign in
486
- // FRONT. Both bounds are set to `precision` so the width is exactly the one
487
- // the caller asked for — the same contract `toFixed` gave.
488
- //
489
- // ⚠️ NOT `style: 'percent'` (objectui#4590). That style wants a FRACTION, so
490
- // this used to divide by 100 for `Intl` to multiply straight back — and the
491
- // round trip is not value-preserving. `Intl` formats from the SHORTEST
492
- // decimal representation of the double it is handed, and the quotient's is
493
- // not the authored one: `1.005` is `1.005`, but `1.005 / 100` is
494
- // `0.010049999999999999`, which percent-scales to `1.0049999999999999` and
495
- // rounds DOWN — so a stored 1.005 rendered `1.00%` where half-up is `1.01%`.
496
- // The DIVISION lost the digit, not the rounding, which is why it reproduced
497
- // in every locale and why 27,577 of 1,200,003 ordinary en-US forms moved
498
- // (0.005-step grid to 2,000, precisions 0/1/2), every one a last-digit
499
- // off-by-one. The same artefact reached the top of the double range:
500
- // `MAX_SAFE_INTEGER` points rendered `…740,990%` for `…740,991%`.
501
- //
502
- // The affix is unchanged by the switch: `'percentPoints'` is `Intl`'s
503
- // `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`, measured
504
- // byte-identical to `style: 'percent'` across all 171 locale tags in #4576
505
- // and re-measured on THIS call shape in #4590 — 720 combinations (10 locales
506
- // x 18 values x 4 precisions), 0 convention diffs, 130 numeral diffs.
507
- // `formatMeasure` renders through the same option, so a percentage point
508
- // reads identically in a list cell and in a dashboard measure.
509
- return formatDisplayNumber(displayValue, {
510
- locale,
511
- style: 'percentPoints',
512
- minimumFractionDigits: precision,
513
- maximumFractionDigits: precision,
514
- });
515
- } catch {
516
- return `${displayValue.toFixed(precision)}%`;
517
- }
518
- }
519
-
520
- /**
521
- * Format percent value.
522
- * Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
523
- *
524
- * `locale` is the third positional parameter, matching {@link formatNumber} and
525
- * {@link formatCurrency} — the shape the sibling formatters already use.
526
- * Callers should pass the tag from `useDisplayLocale()`.
527
- *
528
- * Before objectui#4553 this function took no locale and never touched `Intl`:
529
- * its whole body was `${percentDisplayValue(value).toFixed(precision)}%`, so it
530
- * rendered in NO locale rather than the machine's — an ASCII decimal mark and
531
- * never a grouping separator, byte-identical on every machine. That made
532
- * `1235%` the output everywhere, which is wrong in en-US as well as in German,
533
- * so the grouping and the locale are fixed together: en output MOVES from
534
- * `1235%` to `1,235%` at four digits and up, and that move is the fix.
535
- */
536
- export function formatPercent(value: number, precision: number = 0, locale?: string): string {
537
- // Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
538
- // the list cell and the dashboard measure formatter (`formatMeasure`) agree.
539
- const displayValue = percentDisplayValue(value);
540
- return formatPercentBody(displayValue, precision, locale);
541
- }
542
-
543
- /**
544
- * Humanize a snake_case or kebab-case string into Title Case.
545
- * Used as fallback label when no explicit option.label exists.
546
- *
547
- * Examples:
548
- * "in_progress" → "In Progress"
549
- * "high-priority" → "High Priority"
550
- * "active" → "Active"
551
- */
552
- export function humanizeLabel(value: string): string {
553
- return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
554
- }
555
-
556
- /** Options shared by {@link formatDate} / {@link formatRelativeDate}. */
557
- export interface DateDisplayOptions {
558
- dueLike?: boolean;
559
- /** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */
560
- locale?: string;
561
- /** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */
562
- t?: (key: string, params?: Record<string, unknown>) => string;
563
- }
564
-
565
- /**
566
- * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天",
567
- * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase.
568
- */
569
- function formatRelativeDays(diffDays: number, locale?: string): string {
570
- try {
571
- const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day');
572
- return phrase.charAt(0).toUpperCase() + phrase.slice(1);
573
- } catch {
574
- // Invalid locale tag — degrade to English rather than crash the cell.
575
- if (diffDays === 0) return 'Today';
576
- if (diffDays === 1) return 'Tomorrow';
577
- if (diffDays === -1) return 'Yesterday';
578
- return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`;
579
- }
580
- }
581
-
582
- /**
583
- * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
584
- * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
585
- *
586
- * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
587
- * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
588
- * past dates render as plain "N days ago" instead. The overdue phrase has no
589
- * `Intl` equivalent, so it resolves through `options.t` (key
590
- * `fields.relativeDate.overdue`) with an English fallback.
591
- */
592
- export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string {
593
- if (value === null || value === undefined || value === '') return '—';
594
- const date = value instanceof Date ? value : new Date(value as any);
595
- if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
596
-
597
- const now = new Date();
598
- const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
599
- const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
600
- const diffMs = startOfDate.getTime() - startOfToday.getTime();
601
- const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
602
-
603
- // Beyond the ±7-day window, fall back to the absolute (already localized) form.
604
- if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options);
605
-
606
- if (diffDays < -1 && options?.dueLike) {
607
- const absDays = Math.abs(diffDays);
608
- const key = 'fields.relativeDate.overdue';
609
- const translated = options.t?.(key, { count: absDays });
610
- return translated && translated !== key ? translated : `Overdue ${absDays}d`;
611
- }
612
- return formatRelativeDays(diffDays, options?.locale);
613
- }
614
-
615
- /**
616
- * Format date value
617
- */
618
- export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string {
619
- if (value === null || value === undefined || value === '') return '—';
620
- const date = value instanceof Date ? value : new Date(value as any);
621
- if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
622
-
623
- if (style === 'short') {
624
- // Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
625
- // Only the MONTH token is localized: the surrounding compact shape (day,
626
- // apostrophe + 2-digit year) is a deliberate fixed layout for narrow
627
- // cards, not a locale-derived one. The tag comes from `options.locale`
628
- // like the default branch below — hardcoding `'en-US'` here made this the
629
- // one branch that ignored a locale its caller had threaded (objectui#4272).
630
- const month = date.toLocaleDateString(options?.locale, { month: 'short' });
631
- const day = date.getDate();
632
- const year = String(date.getFullYear()).slice(-2);
633
- return `${month} ${day}, '${year}`;
634
- }
635
-
636
- if (style === 'relative') {
637
- return formatRelativeDate(date, options);
638
- }
639
-
640
- // Default format: locale-aware human-readable. Drop the year when it
641
- // matches the current year — Salesforce / HubSpot / Linear all do this
642
- // because the year is rarely useful for in-progress records and the
643
- // verbose "2026年7月21日" form crowds cards and table cells. Past- /
644
- // future-year dates keep the year so users can disambiguate.
645
- const isCurrentYear = date.getFullYear() === new Date().getFullYear();
646
- return date.toLocaleDateString(options?.locale, {
647
- year: isCurrentYear ? undefined : 'numeric',
648
- month: 'short',
649
- day: 'numeric',
650
- });
651
- }
652
-
653
- /**
654
- * Format datetime value.
655
- *
656
- * `options` mirrors {@link formatDate}'s and is optional, so an existing
657
- * caller that passes nothing keeps the exact runtime-default behavior it had.
658
- * Before objectui#4272 the parameter did not exist at all, which meant no
659
- * caller could localize this function however hard it tried — it always handed
660
- * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of
661
- * the repo's two locale channels. Callers should pass the tag from
662
- * `useDisplayLocale()`.
663
- */
664
- export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
665
- if (value === null || value === undefined || value === '') return '—';
666
- const date = value instanceof Date ? value : new Date(value as any);
667
- if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
668
-
669
- return date.toLocaleDateString(options?.locale, {
670
- year: 'numeric',
671
- month: 'short',
672
- day: 'numeric',
673
- hour: '2-digit',
674
- minute: '2-digit',
675
- });
676
- }
677
-
678
- /**
679
- * Single-line cell value with a working ellipsis and a full-text fallback.
680
- *
681
- * `truncate` on a bare inline `<span>` never clips — an inline box has no
682
- * width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so
683
- * the value renders at full content width and the tail is silently cut by
684
- * whatever ancestor happens to clip (the detail card edge), with no ellipsis
685
- * (objectui#3466; same mechanism as the JSON cell in objectui#2578).
686
- * Block-level + `max-w-full` gives the span its parent's width to truncate
687
- * against, and as a flex item its `overflow:hidden` zeroes the automatic
688
- * min-size so it can shrink below the text width. The `title` keeps the full
689
- * text reachable on hover — host rows (detail sections) put their inline-edit
690
- * hint in *their* `title`, so the value's full text must live on the value
691
- * element itself, where it takes precedence under the cursor.
692
- */
693
- function TruncatedText({
694
- text,
695
- className,
696
- }: {
697
- text: string;
698
- className?: string;
699
- }): React.ReactElement {
700
- return (
701
- <span className={cn('block max-w-full truncate', className)} title={text}>
702
- {text}
703
- </span>
704
- );
705
- }
706
-
707
- /**
708
- * Text field cell renderer
709
- */
710
- export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
711
- const safe = coerceToSafeValue(value);
712
- if (safe == null || safe === '') return <EmptyValue />;
713
- return <TruncatedText text={String(safe)} />;
714
- }
715
-
716
- /**
717
- * Number field cell renderer
718
- */
719
- export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
720
- // Hook before the empty-value early return — a value flipping between null
721
- // and set must not change the hook count between renders (same rule as
722
- // CurrencyCellRenderer below).
723
- const locale = useDisplayLocale();
724
- if (value == null) return <EmptyValue />;
725
-
726
- const safe = coerceToSafeValue(value);
727
- const numField = field as any;
728
- // Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
729
- // NOT `precision` — `precision` is the TOTAL digit count (`p`), and reading
730
- // it here padded every value out to that width (e.g. `1` from a
731
- // decimal(10, 0) column rendered as "1.0000000000"). When `scale` is
732
- // declared we pad to it so a fixed display is honoured (e.g. an amount with
733
- // scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
734
- // keep the minimum at 0 so trailing zeros are trimmed and only cap the
735
- // maximum (20 = Intl max) to preserve the value's natural precision.
736
- //
737
- // `scale` is also the grouping POLICY input (objectui#4033): a declared
738
- // `scale: 0` with no currency is a discrete integer — a year, a fiscal
739
- // period, an ordinal — and those are rendered ungrouped, so a `Field.number`
740
- // year finally shows `2026` instead of `2,026`. An ABSENT scale keeps
741
- // grouping: absent means "decimals unknown", not "integer". The policy and
742
- // its interim status live in `formatDisplayNumber`, not here.
743
- const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
744
- const num = Number(safe);
745
- const formatted = !isNaN(num)
746
- ? formatDisplayNumber(num, {
747
- locale,
748
- scale,
749
- minimumFractionDigits: scale ?? 0,
750
- maximumFractionDigits: scale ?? 20,
751
- })
752
- : String(safe);
753
-
754
- return <span className="tabular-nums">{formatted}</span>;
755
- }
756
-
757
- /**
758
- * Currency field cell renderer
759
- */
760
- export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
761
- // Hooks before the empty-value early return — a value flipping between
762
- // null and set must not change the hook count between renders.
763
- const { currency: tenantCurrency } = useLocalization();
764
- const locale = useDisplayLocale();
765
- if (value == null) return <EmptyValue />;
766
-
767
- const safe = coerceToSafeValue(value);
768
- // Resolve the display currency via the shared precedence: field `currency` →
769
- // `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none
770
- // is known, render a plain number — never a guessed symbol (silently assuming
771
- // USD mis-displays non-USD orgs, e.g. RMB amounts shown as $).
772
- const currency = resolveFieldCurrency(field as any, tenantCurrency);
773
- const num = Number(safe);
774
- const formatted = !isNaN(num)
775
- ? formatCurrency(num, currency, locale)
776
- : String(safe);
777
-
778
- return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
779
- }
780
-
781
- // Fields that store percentage values as whole numbers (0-100) rather than fractions (0-1)
782
- const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
783
-
784
- /**
785
- * Percent field cell renderer with mini progress bar
786
- */
787
- export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
788
- // Hook before the empty-value early return — a value flipping between null
789
- // and set must not change the hook count between renders (same rule as
790
- // NumberCellRenderer / CurrencyCellRenderer above).
791
- const locale = useDisplayLocale();
792
- if (value == null) return <EmptyValue />;
793
-
794
- const safe = coerceToSafeValue(value);
795
- const percentField = field as any;
796
- const precision = percentField.precision ?? 0;
797
- const numValue = Number(safe);
798
- if (isNaN(numValue)) {
799
- return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
800
- }
801
- // Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
802
- // Fields like "progress" or "completion" store values as 0-100, not 0-1
803
- const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
804
- const barValue = isWholePercentField
805
- ? numValue
806
- : (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
807
- // Both branches render through the same locale-aware body (objectui#4553);
808
- // they differ ONLY in the scaling policy, which is the whole point of the
809
- // branch. The whole-percent branch used to be a second bare `toFixed` path,
810
- // so before this card a `progress` field was ungrouped and unlocalized even
811
- // where an ordinary percent column would not have been — leaving it behind
812
- // would have made ONE grid internally inconsistent, which is worse than the
813
- // uniform defect it had.
814
- const formatted = isWholePercentField
815
- ? formatPercentBody(numValue, precision, locale)
816
- : formatPercent(numValue, precision, locale);
817
- const clampedBar = Math.max(0, Math.min(100, barValue));
818
-
819
- // Layout contract (objectstack#5066): THE NUMBER IS THE CONTENT, THE BAR IS
820
- // DECORATION. The bar used to be `w-16 shrink-0` while the value span was
821
- // shrinkable, so in a narrow clipping container — a `record:highlights` chip
822
- // is `basis-[9rem]`/`min-w-[7rem]` and clips with `truncate` — the 64px bar
823
- // took the space and the value was silently cut mid-digit: a stored `33.33`
824
- // rendered `33%` in the DOM but read as `3` on screen, with no ellipsis and
825
- // nothing in the accessible name to signal the loss.
826
- //
827
- // So the priority is inverted: the value span is `shrink-0` (never sacrificed)
828
- // and the bar keeps `w-16` only as its PREFERRED width, free to shrink away
829
- // under pressure. It is deliberately NOT `flex-1` — growing is not wanted, or
830
- // every wide grid cell would stretch its bar; `w-16` stays the upper bound and
831
- // wide containers look exactly as before.
832
- return (
833
- <div className="flex min-w-0 items-center gap-2">
834
- <div
835
- className="h-1.5 w-16 min-w-0 shrink rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden"
836
- role="progressbar"
837
- aria-valuenow={clampedBar}
838
- aria-valuemin={0}
839
- aria-valuemax={100}
840
- >
841
- <div
842
- className="h-full rounded-full bg-primary transition-all"
843
- style={{ width: `${clampedBar}%` }}
844
- />
845
- </div>
846
- <span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
847
- </div>
848
- );
849
- }
850
-
851
- /** Field names that trigger warning badge when boolean value is false */
852
- const STATUS_FIELD_NAMES = new Set([
853
- 'active', 'is_active', 'enabled', 'is_enabled', 'verified', 'is_verified',
854
- ]);
855
-
856
- /**
857
- * Boolean field cell renderer (Airtable-style checkbox)
858
- * Supports semantic rendering for completion fields (green indicator)
859
- * and warning badge for active/enabled fields when false.
860
- */
861
- export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
862
- if (value == null) {
863
- return <span className="flex items-center justify-center"><EmptyValue /></span>;
864
- }
865
-
866
- // Semantic rendering for completion fields (green circle indicator)
867
- // Only match exact field names to avoid false positives
868
- const fieldName = field?.name?.toLowerCase() || '';
869
- const isCompletionField = fieldName === 'completed' || fieldName === 'is_completed'
870
- || fieldName === 'done' || fieldName === 'is_done';
871
-
872
- if (isCompletionField) {
873
- return (
874
- <div className="flex items-center justify-center">
875
- {value ? (
876
- <div className="size-5 rounded-full bg-green-500 flex items-center justify-center" role="img" aria-label="Completed" data-testid="completion-indicator">
877
- <Check className="size-3 text-white" />
878
- </div>
879
- ) : (
880
- <div className="size-5 rounded-full border-2 border-muted-foreground/30" role="img" aria-label="Not completed" data-testid="completion-indicator" />
881
- )}
882
- </div>
883
- );
884
- }
885
-
886
- // Warning badge for active/enabled fields when false
887
- if (STATUS_FIELD_NAMES.has(fieldName) && !value) {
888
- return (
889
- <Badge variant="destructive" className="text-xs" data-testid="boolean-warning-badge">
890
- {field?.label || humanizeLabel(fieldName)} — Off
891
- </Badge>
892
- );
893
- }
894
-
895
- return (
896
- <div className="flex items-center justify-start">
897
- <Checkbox checked={!!value} disabled className="pointer-events-none" />
898
- </div>
899
- );
900
- }
901
-
902
- /**
903
- * Date field cell renderer
904
- */
905
- export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
906
- // `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The
907
- // raw tenant locale is `undefined` on any workspace that never configured
908
- // one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh`
909
- // console rendered `逾期 6 天` (which resolves through `t`, the ACTIVE UI
910
- // language) beside `In 3 days` and `6 days ago` (which resolve through
911
- // `Intl`) on the SAME row. One channel for both halves, or the row
912
- // disagrees with itself.
913
- const locale = useDisplayLocale();
914
- const t = useFieldTranslate();
915
- if (!value) return <EmptyValue />;
916
- const safe = coerceToSafeValue(value);
917
- const dateField = field as any;
918
- const style = dateField.format || 'relative';
919
-
920
- // A date is only *semantically* a due/deadline when the field says so — a
921
- // plain "start_date" or "created_at" in the past is neither overdue text
922
- // nor red, even though it renders in the same relative-time style.
923
- const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
924
- const dueLike =
925
- dateField?.dueLike === true ||
926
- /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
927
- const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t });
928
-
929
- const date = safe != null ? new Date(safe as string | number) : null;
930
- const isValidDate = date !== null && !isNaN(date.getTime());
931
- const startOfToday = new Date();
932
- startOfToday.setHours(0, 0, 0, 0);
933
- const isOverdue = dueLike && isValidDate && date! < startOfToday;
934
- const isoString = isValidDate ? date!.toISOString() : String(safe);
935
-
936
- return (
937
- <span
938
- className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
939
- title={isoString}
940
- >
941
- {formatted}
942
- </span>
943
- );
944
- }
945
-
946
- /**
947
- * DateTime field cell renderer (Airtable-style with date and time visually separated)
948
- */
949
- export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
950
- // Hook before every early return — a value flipping between null and set
951
- // must not change the hook count between renders (same rule as the number /
952
- // currency renderers above). This is the site objectui#4468 caught rendering
953
- // `8/11/2026 12:00 am` inside an otherwise Chinese grid: both calls passed
954
- // `undefined`, i.e. the machine's locale, on every session.
955
- const locale = useDisplayLocale();
956
- if (!value) return <EmptyValue />;
957
- const safe = coerceToSafeValue(value);
958
- const date = safe != null ? new Date(safe as string | number) : null;
959
- if (date === null || isNaN(date.getTime())) return <EmptyValue />;
960
-
961
- const datePart = date.toLocaleDateString(locale, {
962
- month: 'numeric',
963
- day: 'numeric',
964
- year: 'numeric',
965
- });
966
- // `hour12` stays declared: this is the compact Airtable-style cell, and the
967
- // 12-hour face is its design, not a locale artefact. Locales that write no
968
- // am/pm marker simply ignore it.
969
- const timePart = date.toLocaleTimeString(locale, {
970
- hour: 'numeric',
971
- minute: '2-digit',
972
- hour12: true,
973
- }).toLowerCase();
974
-
975
- return (
976
- <span className="tabular-nums text-sm whitespace-nowrap">
977
- <span>{datePart}</span>
978
- <span className="ml-2 text-muted-foreground">{timePart}</span>
979
- </span>
980
- );
981
- }
982
-
983
- // Semantic color mapping (auto-detect from value text for priority & status fields)
984
- // Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
985
- // Chinese keys are stored as-is and matched directly (no normalization side-effects).
986
- const SEMANTIC_COLOR_MAP: Record<string, string> = {
987
- // Priority values (en)
988
- critical: 'red',
989
- urgent: 'red',
990
- high: 'orange',
991
- medium: 'yellow',
992
- normal: 'blue',
993
- low: 'gray',
994
- none: 'gray',
995
- // Status values (en)
996
- paid: 'green',
997
- completed: 'green',
998
- done: 'green',
999
- active: 'green',
1000
- approved: 'green',
1001
- resolved: 'green',
1002
- pending: 'yellow',
1003
- waiting: 'yellow',
1004
- on_hold: 'yellow',
1005
- shipped: 'blue',
1006
- in_progress: 'blue',
1007
- open: 'blue',
1008
- processing: 'blue',
1009
- draft: 'gray',
1010
- new: 'gray',
1011
- inactive: 'gray',
1012
- closed: 'gray',
1013
- cancelled: 'red',
1014
- canceled: 'red',
1015
- rejected: 'red',
1016
- failed: 'red',
1017
- overdue: 'red',
1018
- delivered: 'purple',
1019
- archived: 'indigo',
1020
- // CRM lifecycle values (en)
1021
- contacted: 'blue',
1022
- qualified: 'purple',
1023
- converted: 'green',
1024
- won: 'green',
1025
- lost: 'red',
1026
- // Priority values (zh)
1027
- 紧急: 'red',
1028
- 严重: 'red',
1029
- 高: 'orange',
1030
- 中: 'yellow',
1031
- 普通: 'blue',
1032
- 低: 'gray',
1033
- 无: 'gray',
1034
- // Status values (zh)
1035
- 新建: 'gray',
1036
- 草稿: 'gray',
1037
- 待处理: 'yellow',
1038
- 待审核: 'yellow',
1039
- 待联系: 'yellow',
1040
- 挂起: 'yellow',
1041
- 进行中: 'blue',
1042
- 处理中: 'blue',
1043
- 已联系: 'blue',
1044
- 跟进中: 'blue',
1045
- 已发货: 'blue',
1046
- 打开: 'blue',
1047
- 已确认: 'green',
1048
- 已审核: 'green',
1049
- 已通过: 'green',
1050
- 已完成: 'green',
1051
- 已支付: 'green',
1052
- 已签收: 'green',
1053
- 已转化: 'green',
1054
- 成单: 'green',
1055
- 赢得: 'green',
1056
- 已签约: 'green',
1057
- 已交付: 'purple',
1058
- 已归档: 'indigo',
1059
- 已关闭: 'gray',
1060
- 已取消: 'red',
1061
- 已拒绝: 'red',
1062
- 失败: 'red',
1063
- 逾期: 'red',
1064
- 流失: 'red',
1065
- 丢失: 'red',
1066
- };
1067
-
1068
- // Color to Tailwind class mapping for custom Badge styling
1069
- // Color → Tailwind class mapping for status-style badges.
1070
- // Uses the modern "soft pill" pattern (Tailwind UI style): -50 background,
1071
- // -700 text, hairline -200 border. Dark mode mirrors with -950/40 surface
1072
- // and -300 text. This keeps status fields readable without the heavy,
1073
- // candy-colored look of the older -100/-300/-800 combination.
1074
- const BADGE_COLOR_MAP: Record<string, string> = {
1075
- gray: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800/50 dark:text-gray-200 dark:border-gray-700/60',
1076
- red: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900/60',
1077
- orange: 'bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900/60',
1078
- yellow: 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-300 dark:border-yellow-900/60',
1079
- green: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900/60',
1080
- blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900/60',
1081
- indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:border-indigo-900/60',
1082
- purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900/60',
1083
- pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
1084
- };
1085
-
1086
- // Solid color → Tailwind background class for the small dot used by the
1087
- // `appearance: 'dot'` rendering of select/status fields. Uses the -500 shade
1088
- // for both light and dark modes so the dot remains a clear visual anchor
1089
- // without becoming a heavy color block.
1090
- const DOT_COLOR_MAP: Record<string, string> = {
1091
- gray: 'bg-gray-400 dark:bg-gray-500',
1092
- red: 'bg-red-500',
1093
- orange: 'bg-orange-500',
1094
- yellow: 'bg-yellow-500',
1095
- green: 'bg-green-500',
1096
- blue: 'bg-blue-500',
1097
- indigo: 'bg-indigo-500',
1098
- purple: 'bg-purple-500',
1099
- pink: 'bg-pink-500',
1100
- };
1101
-
1102
- // Color palette used by the deterministic fallback when no schema/semantic
1103
- // color matches. Excludes 'gray' to ensure visual contrast between values.
1104
- const BADGE_FALLBACK_PALETTE: readonly string[] = [
1105
- 'blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red',
1106
- ];
1107
-
1108
- /**
1109
- * Stable string hash (djb2-ish) → palette index.
1110
- * Same value always yields the same color across renders/sessions.
1111
- */
1112
- function hashToColor(value: string): string {
1113
- let h = 5381;
1114
- for (let i = 0; i < value.length; i++) {
1115
- h = ((h << 5) + h) ^ value.charCodeAt(i);
1116
- }
1117
- const idx = Math.abs(h) % BADGE_FALLBACK_PALETTE.length;
1118
- return BADGE_FALLBACK_PALETTE[idx];
1119
- }
1120
-
1121
- /**
1122
- * Map a hex color (e.g. '#8B5CF6') to the nearest named palette color the
1123
- * badge/dot maps understand. Object field options almost always declare colors
1124
- * as HEX, so without this the explicit author color is ignored and a semantic/
1125
- * hash heuristic takes over (e.g. a purple 'In Review' rendered alarming-red).
1126
- * Low-saturation hexes resolve to 'gray'; otherwise bucket by hue.
1127
- */
1128
- function hexToPaletteName(hex: string): string | undefined {
1129
- const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
1130
- if (!m) return undefined;
1131
- let h = m[1];
1132
- if (h.length === 3) h = h.split('').map((c) => c + c).join('');
1133
- const r = parseInt(h.slice(0, 2), 16) / 255;
1134
- const g = parseInt(h.slice(2, 4), 16) / 255;
1135
- const b = parseInt(h.slice(4, 6), 16) / 255;
1136
- const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
1137
- const l = (max + min) / 2;
1138
- const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
1139
- if (sat < 0.22) return 'gray';
1140
- let hue: number;
1141
- if (max === r) hue = (((g - b) / d) % 6 + 6) % 6;
1142
- else if (max === g) hue = (b - r) / d + 2;
1143
- else hue = (r - g) / d + 4;
1144
- hue *= 60;
1145
- if (hue >= 345 || hue < 15) return 'red';
1146
- if (hue < 30) return 'orange';
1147
- if (hue < 60) return 'yellow';
1148
- if (hue < 170) return 'green';
1149
- if (hue < 238) return 'blue';
1150
- if (hue < 250) return 'indigo';
1151
- if (hue < 295) return 'purple';
1152
- return 'pink';
1153
- }
1154
-
1155
- /** Normalize an option color to a named palette key: pass known names through,
1156
- * resolve hex to the nearest palette color, else undefined. */
1157
- function resolveColorName(color?: string): string | undefined {
1158
- if (!color) return undefined;
1159
- if (BADGE_COLOR_MAP[color]) return color;
1160
- if (color.charAt(0) === '#') return hexToPaletteName(color);
1161
- return undefined;
1162
- }
1163
-
1164
- export function getBadgeColorClasses(color?: string, val?: unknown): string {
1165
- const named = resolveColorName(color);
1166
- if (named && BADGE_COLOR_MAP[named]) return BADGE_COLOR_MAP[named];
1167
- if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
1168
- const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
1169
- const semantic = SEMANTIC_COLOR_MAP[key];
1170
- if (semantic && BADGE_COLOR_MAP[semantic]) return BADGE_COLOR_MAP[semantic];
1171
- // Deterministic fallback so distinct values are visually distinguishable
1172
- // even when metadata declares no colors.
1173
- return BADGE_COLOR_MAP[hashToColor(key)];
1174
- }
1175
-
1176
- /**
1177
- * Resolve a semantic color name (e.g. "red", "green") for a value, suitable
1178
- * for callers that need a raw color token rather than CSS classes (for
1179
- * example, the Gantt renderer paints bars via inline styles).
1180
- *
1181
- * Resolution order: explicit option color → semantic value mapping →
1182
- * deterministic hash fallback. Returns `undefined` only when no value is
1183
- * supplied so the caller can fall back to its own default.
1184
- */
1185
- export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
1186
- const named = resolveColorName(color);
1187
- if (named && BADGE_COLOR_MAP[named]) return named;
1188
- if (val == null || val === '') return undefined;
1189
- const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
1190
- const semantic = SEMANTIC_COLOR_MAP[key];
1191
- if (semantic) return semantic;
1192
- return hashToColor(key);
1193
- }
1194
-
1195
- // Resolved hex values for the -500 shade of each palette color. Mirrors
1196
- // `DOT_COLOR_MAP` and is consumed by callers that paint via inline styles
1197
- // (e.g. Gantt task bars, where Tailwind classes can't be applied to dynamic
1198
- // `style={}` values).
1199
- const COLOR_NAME_HEX: Record<string, string> = {
1200
- gray: '#6b7280',
1201
- red: '#ef4444',
1202
- orange: '#f97316',
1203
- yellow: '#eab308',
1204
- green: '#22c55e',
1205
- blue: '#3b82f6',
1206
- indigo: '#6366f1',
1207
- purple: '#a855f7',
1208
- pink: '#ec4899',
1209
- };
1210
-
1211
- /**
1212
- * Map a semantic color name to its Tailwind -500 hex value. Used by
1213
- * inline-style consumers (Gantt bars). Falls back to the supplied default
1214
- * (or the platform default blue) when the name is unrecognized.
1215
- */
1216
- export function getSemanticHex(name?: string, fallback: string = '#3b82f6'): string {
1217
- if (!name) return fallback;
1218
- return COLOR_NAME_HEX[name] ?? fallback;
1219
- }
1220
-
1221
- /**
1222
- * Select field cell renderer.
1223
- *
1224
- * Two visual styles, controlled by `field.appearance` (renderer-level option,
1225
- * not part of the `@objectstack/spec` field schema):
1226
- * - `'badge'` (default for spec compatibility): soft-pill colored badge.
1227
- * - `'dot'`: a small colored dot followed by the option label. Used by
1228
- * dense list/grid contexts to keep the table visually quiet — repeated
1229
- * filled badges across many rows create heavy visual noise.
1230
- *
1231
- * Metadata always wins: callers can pass `appearance: 'badge'` on the field
1232
- * descriptor to force the legacy badge in any context.
1233
- */
1234
- export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1235
- const selectField = field as any;
1236
- const options: SelectOptionMetadata[] = selectField.options || [];
1237
- const appearance: 'badge' | 'dot' = selectField.appearance === 'dot' ? 'dot' : 'badge';
1238
-
1239
- if (value == null || value === '') return <EmptyValue />;
1240
-
1241
- // Match a stored value to a configured option, falling back to a
1242
- // case-insensitive comparison so seed data with mixed case
1243
- // (e.g. "Referral" stored, "referral" defined) still resolves to the
1244
- // localized option label.
1245
- const findOption = (val: any): SelectOptionMetadata | undefined => {
1246
- const exact = options.find(opt => opt.value === val);
1247
- if (exact) return exact;
1248
- const norm = String(val).toLowerCase();
1249
- return options.find(opt => String(opt.value).toLowerCase() === norm);
1250
- };
1251
-
1252
- const renderOne = (val: any, key?: number): React.ReactElement => {
1253
- const option = findOption(val);
1254
- const label = option?.label || humanizeLabel(String(val));
1255
-
1256
- if (appearance === 'dot') {
1257
- // Resolve a real CSS color for the dot. Prefer explicit option color,
1258
- // then semantic mapping for the value, then deterministic palette.
1259
- const colorName = resolveColorName(option?.color)
1260
- || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
1261
- || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
1262
- const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
1263
- // max-w-full bounds the (otherwise content-sized) inline-flex box so the
1264
- // inner truncate can engage; title keeps the full label on hover
1265
- // (objectui#3466, same class of bug as the badge branch below).
1266
- return (
1267
- <span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
1268
- <span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
1269
- <span className="min-w-0 truncate">{label}</span>
1270
- </span>
1271
- );
1272
- }
1273
-
1274
- const colorClasses = getBadgeColorClasses(option?.color, val);
1275
- // max-w-full + inner truncate: in bounded containers (detail highlight
1276
- // strip columns, grid cells) an overlong label used to clip mid-glyph at
1277
- // the container edge; now the badge shrinks and ellipsizes, with the full
1278
- // label on hover.
1279
- return (
1280
- <Badge
1281
- key={key}
1282
- variant="outline"
1283
- className={cn('max-w-full min-w-0', colorClasses)}
1284
- title={label}
1285
- >
1286
- <span className="truncate">{label}</span>
1287
- </Badge>
1288
- );
1289
- };
1290
-
1291
- // Handle multiple values
1292
- if (Array.isArray(value)) {
1293
- return (
1294
- <div className={cn('flex flex-wrap', appearance === 'dot' ? 'gap-x-3 gap-y-1' : 'gap-1')}>
1295
- {value.map((val, idx) => renderOne(val, idx))}
1296
- </div>
1297
- );
1298
- }
1299
-
1300
- return renderOne(value);
1301
- }
1302
-
1303
- /**
1304
- * Email field cell renderer
1305
- */
1306
- export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
1307
- // Hooks before the empty-value early return (rules-of-hooks).
1308
- const label = useFieldLabel();
1309
- const [copied, setCopied] = React.useState(false);
1310
- if (!value) return <EmptyValue />;
1311
-
1312
- const safe = String(coerceToSafeValue(value) ?? '');
1313
-
1314
- const handleCopy = (e: React.MouseEvent) => {
1315
- e.stopPropagation();
1316
- e.preventDefault();
1317
- navigator.clipboard.writeText(safe).then(() => {
1318
- setCopied(true);
1319
- setTimeout(() => setCopied(false), 2000);
1320
- }).catch(() => { /* clipboard not available */ });
1321
- };
1322
-
1323
- return (
1324
- <span className="inline-flex items-center gap-1 group/email">
1325
- <Button
1326
- variant="link"
1327
- className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
1328
- asChild
1329
- >
1330
- <a
1331
- href={`mailto:${safe}`}
1332
- onClick={(e) => e.stopPropagation()}
1333
- >
1334
- {safe}
1335
- </a>
1336
- </Button>
1337
- <button
1338
- type="button"
1339
- className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
1340
- onClick={handleCopy}
1341
- aria-label={label('detail.copyEmail', 'Copy email')}
1342
- >
1343
- {copied ? (
1344
- <Check className="h-3 w-3 text-green-600" />
1345
- ) : (
1346
- <Copy className="h-3 w-3 text-muted-foreground" />
1347
- )}
1348
- </button>
1349
- </span>
1350
- );
1351
- }
1352
-
1353
- /**
1354
- * URL field cell renderer
1355
- */
1356
- export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
1357
- if (!value) return <EmptyValue />;
1358
-
1359
- const safe = String(coerceToSafeValue(value) ?? '');
1360
- return (
1361
- <Button
1362
- variant="link"
1363
- className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
1364
- asChild
1365
- >
1366
- <a
1367
- href={safe}
1368
- target="_blank"
1369
- rel="noopener noreferrer"
1370
- onClick={(e) => e.stopPropagation()}
1371
- >
1372
- {safe}
1373
- </a>
1374
- </Button>
1375
- );
1376
- }
1377
-
1378
- /**
1379
- * Phone field cell renderer
1380
- */
1381
- export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
1382
- // Hooks before the empty-value early return (rules-of-hooks).
1383
- const label = useFieldLabel();
1384
- const [copied, setCopied] = React.useState(false);
1385
- if (!value) return <EmptyValue />;
1386
-
1387
- const safe = String(coerceToSafeValue(value) ?? '');
1388
-
1389
- const handleCopy = (e: React.MouseEvent) => {
1390
- e.stopPropagation();
1391
- e.preventDefault();
1392
- navigator.clipboard.writeText(safe).then(() => {
1393
- setCopied(true);
1394
- setTimeout(() => setCopied(false), 2000);
1395
- }).catch(() => { /* clipboard not available */ });
1396
- };
1397
-
1398
- return (
1399
- <span className="inline-flex items-center gap-1 group/phone">
1400
- <a
1401
- href={`tel:${safe}`}
1402
- className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
1403
- onClick={(e) => e.stopPropagation()}
1404
- >
1405
- <PhoneIcon className="h-3 w-3" />
1406
- {safe}
1407
- </a>
1408
- <button
1409
- type="button"
1410
- className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
1411
- onClick={handleCopy}
1412
- aria-label={label('detail.copyPhone', 'Copy phone number')}
1413
- >
1414
- {copied ? (
1415
- <Check className="h-3 w-3 text-green-600" />
1416
- ) : (
1417
- <Copy className="h-3 w-3 text-muted-foreground" />
1418
- )}
1419
- </button>
1420
- </span>
1421
- );
1422
- }
1423
-
1424
- /**
1425
- * File field cell renderer
1426
- */
1427
- export function FileCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1428
- if (!value) return <EmptyValue />;
1429
-
1430
- const fileField = field as any;
1431
- const isMultiple = fileField.multiple;
1432
-
1433
- if (Array.isArray(value)) {
1434
- const count = value.length;
1435
- return (
1436
- <span className="text-sm text-gray-600">
1437
- {count} {count === 1 ? 'file' : 'files'}
1438
- </span>
1439
- );
1440
- }
1441
-
1442
- const fileName = value.name || value.original_name || 'File';
1443
- return <TruncatedText text={String(fileName)} className="text-sm" />;
1444
- }
1445
-
1446
- /**
1447
- * Image field cell renderer (with thumbnails + click-to-zoom).
1448
- *
1449
- * An image value may be a plain URL string, an object ({ url | src | href … }),
1450
- * a bare `sys_file` id, or an array of any of those. Normalising through
1451
- * `readFileValues` (which resolves a bare id to its stable download URL) means
1452
- * a string-URL field, a CDN link, and an unexpanded reference all render a
1453
- * thumbnail instead of a broken `<img src="">` placeholder.
1454
- *
1455
- * Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
1456
- * click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
1457
- * doesn't also trigger row navigation.
1458
- */
1459
- export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
1460
- const { t } = useObjectTranslation();
1461
- const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
1462
-
1463
- const imgs = React.useMemo(
1464
- () =>
1465
- readFileValues(value, 'Image')
1466
- .filter((v) => v.url)
1467
- .map((v) => ({ url: v.url as string, name: v.name })),
1468
- [value],
1469
- );
1470
-
1471
- if (!value || imgs.length === 0) return <EmptyValue />;
1472
-
1473
- const imageAlt = (idx: number, name?: string) =>
1474
- name || t('fields.image.imageAlt', { index: idx + 1 });
1475
- const open = (idx: number) => (e: React.MouseEvent) => {
1476
- e.stopPropagation();
1477
- setLightboxIndex(idx);
1478
- };
1479
- const multiple = imgs.length > 1;
1480
-
1481
- const lightbox = lightboxIndex !== null && (
1482
- <ImageLightbox
1483
- images={imgs}
1484
- index={lightboxIndex}
1485
- open
1486
- onOpenChange={(o) => !o && setLightboxIndex(null)}
1487
- onIndexChange={setLightboxIndex}
1488
- />
1489
- );
1490
-
1491
- if (multiple) {
1492
- return (
1493
- <>
1494
- <div className="flex -space-x-2">
1495
- {imgs.slice(0, 3).map((img, idx) => (
1496
- <img
1497
- key={idx}
1498
- src={img.url}
1499
- alt={imageAlt(idx, img.name)}
1500
- onClick={open(idx)}
1501
- className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
1502
- />
1503
- ))}
1504
- {imgs.length > 3 && (
1505
- <button
1506
- type="button"
1507
- onClick={open(3)}
1508
- className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
1509
- >
1510
- +{imgs.length - 3}
1511
- </button>
1512
- )}
1513
- </div>
1514
- {lightbox}
1515
- </>
1516
- );
1517
- }
1518
-
1519
- return (
1520
- <>
1521
- <img
1522
- src={imgs[0].url}
1523
- alt={imageAlt(0, imgs[0].name)}
1524
- onClick={open(0)}
1525
- className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
1526
- />
1527
- {lightbox}
1528
- </>
1529
- );
1530
- }
1531
-
1532
- /**
1533
- * The referenced record's id inside one lookup value — an `$expand`-ed record
1534
- * object (`{ id, name, … }`) or the raw foreign key itself. `undefined` when
1535
- * the value carries no id we could address (e.g. an unresolved external-id
1536
- * reference), which the link below reads as "not navigable".
1537
- */
1538
- function referencedRecordId(item: unknown): string | number | undefined {
1539
- if (item == null || item === '') return undefined;
1540
- if (typeof item === 'object') {
1541
- const id = (item as Record<string, unknown>).id ?? (item as Record<string, unknown>)._id;
1542
- if (typeof id === 'number') return id;
1543
- return typeof id === 'string' && id !== '' ? id : undefined;
1544
- }
1545
- if (typeof item === 'string' || typeof item === 'number') return item;
1546
- return undefined;
1547
- }
1548
-
1549
- /**
1550
- * Wrap a lookup's display value in a link to the record it references
1551
- * (objectui#4336).
1552
- *
1553
- * On the record detail page a valued lookup used to render as plain text plus
1554
- * a copy button — the referenced document's name was right there and there was
1555
- * no way to reach it, so users copied the number and searched for it from the
1556
- * list. Related-list cells pointing at a third object were dead in the same
1557
- * way. Both surfaces resolve through `LookupCellRenderer`, so the affordance
1558
- * belongs here, once.
1559
- *
1560
- * **The URL is not built here.** This package has no router and no business
1561
- * knowing what a record route looks like; the host publishes its own builder
1562
- * through `RelatedRecordActionsContext` (`recordHref` / `openRecord`) — the
1563
- * same one the related list's row navigation uses. No host, or a host that
1564
- * cannot route to that object, renders the value EXACTLY as before: no anchor,
1565
- * no styling change, nothing to un-learn (Studio designer, embedded renderers,
1566
- * standalone grids).
1567
- *
1568
- * A real `href` rather than a click handler, so middle-click / ⌘-click / "copy
1569
- * link address" behave the way a link is supposed to; a plain left click is
1570
- * handed to the host so navigation stays in-app.
1571
- */
1572
- function ReferencedRecordLink({
1573
- objectName,
1574
- recordId,
1575
- className,
1576
- children,
1577
- }: {
1578
- objectName?: string;
1579
- recordId?: string | number;
1580
- className?: string;
1581
- children: React.ReactNode;
1582
- }): React.ReactElement {
1583
- const host = useRelatedRecordActions();
1584
- const navigable = !!objectName && recordId != null && recordId !== '';
1585
- const href =
1586
- navigable && host?.recordHref
1587
- ? host.recordHref(objectName as string, recordId as string | number)
1588
- : null;
1589
-
1590
- if (!href) return <>{children}</>;
1591
-
1592
- return (
1593
- <a
1594
- href={href}
1595
- className={cn('text-primary underline-offset-4 hover:underline', className)}
1596
- onClick={(e) => {
1597
- // The rows this cell sits in carry their own click handlers — the
1598
- // detail row copies the field value, a related-list row opens ITS own
1599
- // record. Following the reference must not also fire those.
1600
- e.stopPropagation();
1601
- // Modifier and non-primary clicks belong to the browser (new tab, new
1602
- // window) — that is the point of rendering a real href.
1603
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
1604
- // No SPA handler: let the anchor navigate on its own.
1605
- if (!host?.openRecord) return;
1606
- e.preventDefault();
1607
- host.openRecord(objectName as string, recordId as string | number);
1608
- }}
1609
- >
1610
- {children}
1611
- </a>
1612
- );
1613
- }
1614
-
1615
- /**
1616
- * Lookup/Master-Detail field cell renderer.
1617
- *
1618
- * Display order:
1619
- * 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name
1620
- * 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label
1621
- * 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
1622
- * is known, resolve via dataSource and show the related record's display name.
1623
- * Falls back to a muted placeholder while pending and on failure.
1624
- *
1625
- * Record → name resolution (1 and 3) goes through the referenced object's
1626
- * schema when the data source exposes it (`displayField` → nameField/titleFormat
1627
- * → derivation, see {@link resolveLookupRecordName}), so the chip and the
1628
- * picker agree (issue #2357).
1629
- */
1630
- export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1631
- // ObjectStack object metadata uses `reference` for the lookup target while the
1632
- // objectui types call it `reference_to`. Every other reader (LookupField,
1633
- // UserField, DetailSection, RelatedList, …) accepts both; this read cell must
1634
- // too, or a picked/opaque id never resolves to a name and the cell shows the
1635
- // muted "—" placeholder forever (e.g. after inline-editing a lookup).
1636
- const referenceTo =
1637
- (field as { reference_to?: string }).reference_to ||
1638
- (field as { reference?: string }).reference;
1639
-
1640
- // Explicit author-chosen display field on the lookup — beats every resolver.
1641
- // ObjectGrid forwards `display_field` on the column meta (RELATIONAL_META_KEYS)
1642
- // the same way it forwards `reference` (#2926 ⑧).
1643
- const displayField =
1644
- (field as { display_field?: string }).display_field ||
1645
- (field as { displayField?: string }).displayField ||
1646
- (field as { reference_field?: string }).reference_field ||
1647
- undefined;
1648
-
1649
- // Referenced object's schema (nameField / titleFormat) so expanded records
1650
- // resolve through the same unified resolver as the picker (issue #2357).
1651
- const refSchema = useRefObjectSchema(referenceTo);
1652
-
1653
- // Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
1654
- // to keep the cell cheap; multi-value lookups should generally be expanded server-side).
1655
- const primaryPrimitiveId = (() => {
1656
- if (Array.isArray(value)) {
1657
- const firstPrimitive = value.find(
1658
- (v) => v != null && (typeof v === 'string' || typeof v === 'number') && v !== '',
1659
- );
1660
- return firstPrimitive;
1661
- }
1662
- if (
1663
- value != null &&
1664
- value !== '' &&
1665
- (typeof value === 'string' || typeof value === 'number') &&
1666
- typeof value !== 'object'
1667
- ) {
1668
- return value;
1669
- }
1670
- return undefined;
1671
- })();
1672
-
1673
- // Always call the hook (rules of hooks). It safely no-ops when inputs are missing.
1674
- const resolvedName = useLookupName(referenceTo, primaryPrimitiveId, displayField);
1675
-
1676
- if (value == null || value === '') return <EmptyValue />;
1677
-
1678
- // A reference can arrive as a JSON-encoded object string — e.g. an
1679
- // unresolved external-id reference '{"externalId":"Website Relaunch"}'.
1680
- // Parse it and render a label instead of leaking raw JSON into the cell.
1681
- if (typeof value === 'string') {
1682
- const s = value.trim();
1683
- if (s.startsWith('{') && s.endsWith('}')) {
1684
- // Compute inside try, render outside — constructing JSX in a try/catch
1685
- // doesn't catch its render errors anyway (react-hooks/error-boundaries).
1686
- let parsedDisplay = '';
1687
- let parsedId: string | number | undefined;
1688
- try {
1689
- const parsed = JSON.parse(s) as Record<string, unknown>;
1690
- if (parsed && typeof parsed === 'object') {
1691
- parsedDisplay =
1692
- resolveLookupRecordName(parsed, refSchema, displayField) ||
1693
- String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1694
- // An external-id reference has no record id yet — stays unlinked.
1695
- parsedId = referencedRecordId(parsed);
1696
- }
1697
- } catch { /* not JSON — fall through to normal resolution */ }
1698
- if (parsedDisplay) {
1699
- return (
1700
- <ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
1701
- <TruncatedText text={parsedDisplay} />
1702
- </ReferencedRecordLink>
1703
- );
1704
- }
1705
- }
1706
- }
1707
-
1708
- // Server-side $expand returns the related record as a nested object
1709
- // (e.g. { id, name }). Render its display name directly — no fetch needed.
1710
- if (!Array.isArray(value) && typeof value === 'object') {
1711
- const obj = value as Record<string, unknown>;
1712
- const display =
1713
- resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
1714
- if (display) {
1715
- return (
1716
- <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
1717
- <TruncatedText text={display} />
1718
- </ReferencedRecordLink>
1719
- );
1720
- }
1721
- }
1722
-
1723
- const options: Array<{ value: unknown; label: string }> =
1724
- (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
1725
-
1726
- // Resolve a primitive ID to a label. Order:
1727
- // options → server-resolved name (via useLookupName) → muted placeholder for opaque IDs → raw value
1728
- const resolveLabel = (val: unknown): { text: string; muted: boolean } => {
1729
- if (options.length > 0) {
1730
- const found = options.find((opt) => String(opt.value) === String(val));
1731
- if (found) return { text: found.label, muted: false };
1732
- }
1733
- if (val === primaryPrimitiveId && resolvedName) {
1734
- return { text: resolvedName, muted: false };
1735
- }
1736
- if (isLikelyOpaqueId(val)) {
1737
- // Don't dump a random-looking ID at the user. Show a soft placeholder
1738
- // that conveys "this is a reference, name unavailable".
1739
- return { text: '—', muted: true };
1740
- }
1741
- return { text: String(val), muted: false };
1742
- };
1743
-
1744
- if (Array.isArray(value)) {
1745
- return (
1746
- <div className="flex flex-wrap gap-1">
1747
- {value.map((item, idx) => {
1748
- let label: string;
1749
- let muted = false;
1750
- if (item != null && typeof item === 'object') {
1751
- label = resolveLookupRecordName(item as Record<string, unknown>, refSchema, displayField) || String((item as any).id || (item as any)._id || '[Object]');
1752
- } else {
1753
- const r = resolveLabel(item);
1754
- label = r.text;
1755
- muted = r.muted;
1756
- }
1757
- // Each chip is one referenced record, so each links on its own —
1758
- // there is no single destination a multi-value cell could point at.
1759
- return (
1760
- <ReferencedRecordLink
1761
- key={idx}
1762
- objectName={referenceTo}
1763
- recordId={referencedRecordId(item)}
1764
- className="text-inherit"
1765
- >
1766
- <span
1767
- className={cn(
1768
- 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
1769
- muted
1770
- ? 'bg-muted/40 text-muted-foreground'
1771
- : 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
1772
- )}
1773
- >
1774
- {label}
1775
- </span>
1776
- </ReferencedRecordLink>
1777
- );
1778
- })}
1779
- </div>
1780
- );
1781
- }
1782
-
1783
- if (typeof value === 'object' && value !== null) {
1784
- const label =
1785
- resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
1786
- String((value as any).id || (value as any)._id || '[Object]');
1787
- return (
1788
- <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1789
- <TruncatedText text={label} />
1790
- </ReferencedRecordLink>
1791
- );
1792
- }
1793
-
1794
- // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
1795
- // The value IS the foreign key, so it addresses the record even when the
1796
- // display name could not be resolved (the muted placeholder) — a reference
1797
- // that is present but unnamed is still worth being able to open.
1798
- const { text, muted } = resolveLabel(value);
1799
- return (
1800
- <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1801
- <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
1802
- </ReferencedRecordLink>
1803
- );
1804
- }
1805
-
1806
- /**
1807
- * Formula field cell renderer (read-only)
1808
- */
1809
- export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
1810
- const safe = coerceToSafeValue(value);
1811
- if (safe == null || safe === '') return <EmptyValue />;
1812
- return (
1813
- <span className="text-gray-700 font-mono text-sm">
1814
- {String(safe)}
1815
- </span>
1816
- );
1817
- }
1818
-
1819
- /**
1820
- * User/Owner field cell renderer (with avatars)
1821
- */
1822
- export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
1823
- if (!value) return <EmptyValue />;
1824
-
1825
- // Primitive value: just display the ID/username as text
1826
- if (typeof value !== 'object') {
1827
- return <TruncatedText text={String(value)} />;
1828
- }
1829
-
1830
- if (Array.isArray(value)) {
1831
- return (
1832
- <div className="flex -space-x-2">
1833
- {value.slice(0, 3).map((user, idx) => {
1834
- // Primitive user in array
1835
- if (typeof user !== 'object' || user === null) {
1836
- return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
1837
- }
1838
- const name = user.name || user.username || 'User';
1839
- const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
1840
-
1841
- return (
1842
- <Avatar
1843
- key={idx}
1844
- className="size-8 border-2 border-white"
1845
- title={name}
1846
- >
1847
- {user.image && <AvatarImage src={user.image} alt={name} />}
1848
- <AvatarFallback className="bg-blue-500 text-white text-xs">
1849
- {initials}
1850
- </AvatarFallback>
1851
- </Avatar>
1852
- );
1853
- })}
1854
- {value.length > 3 && (
1855
- <Avatar className="size-8 border-2 border-white">
1856
- <AvatarFallback className="bg-gray-200 text-gray-600 text-xs">
1857
- +{value.length - 3}
1858
- </AvatarFallback>
1859
- </Avatar>
1860
- )}
1861
- </div>
1862
- );
1863
- }
1864
-
1865
- const name = value.name || value.username || 'User';
1866
- const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
1867
-
1868
- return (
1869
- <div className="flex items-center gap-2">
1870
- <Avatar className="size-8">
1871
- {value.image && <AvatarImage src={value.image} alt={name} />}
1872
- <AvatarFallback className="bg-blue-500 text-white text-xs">
1873
- {initials}
1874
- </AvatarFallback>
1875
- </Avatar>
1876
- <TruncatedText text={String(name)} />
1877
- </div>
1878
- );
1879
- }
1880
-
1881
- /**
1882
- * Field Registry
1883
- * Stores mapping between field types and their renderers.
1884
- */
1885
- const fieldRegistry = new Map<string, React.FC<CellRendererProps>>();
1886
-
1887
- /**
1888
- * Register a custom field renderer
1889
- * @param type Field type (e.g. 'text', 'location', 'my-custom-type')
1890
- * @param renderer React component to render the field
1891
- */
1892
- export function registerFieldRenderer(type: string, renderer: React.FC<CellRendererProps>) {
1893
- fieldRegistry.set(type, renderer);
1894
- }
1895
-
1896
- /**
1897
- * Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should
1898
- * map to a richer cell renderer than the bare type would imply. The
1899
- * canonical ObjectStack pattern uses `Field.text({ format: 'phone' })`
1900
- * for plain text columns that should still render with a tel: link,
1901
- * `Field.text({ format: 'url' })` for clickable links, etc.
1902
- *
1903
- * Only applies when the field's base type is a generic text type;
1904
- * explicit types like 'phone'/'email'/'currency' already win without
1905
- * any format hint.
1906
- */
1907
- const FORMAT_TO_RENDERER: Record<string, string> = {
1908
- phone: 'phone',
1909
- tel: 'phone',
1910
- telephone: 'phone',
1911
- email: 'email',
1912
- url: 'url',
1913
- uri: 'url',
1914
- link: 'url',
1915
- currency: 'currency',
1916
- money: 'currency',
1917
- percent: 'percent',
1918
- percentage: 'percent',
1919
- };
1920
-
1921
- const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '']);
1922
-
1923
- /**
1924
- * Resolve the canonical cell-renderer key for a field. Accepts either a
1925
- * raw type string (back-compat) or a field-metadata object so that
1926
- * format hints (`format: 'phone'` etc.) can promote a plain `text`
1927
- * field to its richer renderer counterpart.
1928
- */
1929
- export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string {
1930
- if (!fieldOrType) return 'text';
1931
- if (typeof fieldOrType === 'string') return fieldOrType;
1932
- const baseType = (fieldOrType.type || '').toLowerCase();
1933
- const formatRaw = fieldOrType.format;
1934
- const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : '';
1935
- if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) {
1936
- return FORMAT_TO_RENDERER[format];
1937
- }
1938
- return fieldOrType.type || 'text';
1939
- }
1940
-
1941
- /**
1942
- * Renders structured/embedded values (json, object, composite, record,
1943
- * address, geolocation) as compact, readable JSON. Objects and arrays are
1944
- * stringified; primitives fall through to their string form.
1945
- */
1946
- export function JsonCellRenderer({ value }: CellRendererProps): React.ReactElement {
1947
- if (value == null || value === '') return <EmptyValue />;
1948
- let text: string;
1949
- if (typeof value === 'object') {
1950
- try {
1951
- text = JSON.stringify(value);
1952
- } catch {
1953
- text = String(value);
1954
- }
1955
- } else {
1956
- text = String(value);
1957
- }
1958
- // The original site of the block-level+max-w-full+title pattern
1959
- // (objectui#2578) — now shared with every single-line value renderer via
1960
- // TruncatedText (objectui#3466).
1961
- return <TruncatedText text={text} className="font-mono text-xs text-gray-600" />;
1962
- }
1963
-
1964
- /**
1965
- * Renders a `color` value as a swatch alongside its hex/string value.
1966
- */
1967
- export function ColorSwatchCellRenderer({ value }: CellRendererProps): React.ReactElement {
1968
- if (value == null || value === '') return <EmptyValue />;
1969
- const color = String(value);
1970
- return (
1971
- <span className="inline-flex items-center gap-1.5 text-sm">
1972
- <span
1973
- className="h-3.5 w-3.5 rounded border border-black/10 shrink-0"
1974
- style={{ backgroundColor: color }}
1975
- aria-hidden="true"
1976
- />
1977
- <span className="font-mono text-xs">{color}</span>
1978
- </span>
1979
- );
1980
- }
1981
-
1982
- const LazyMarkdownContent = React.lazy(() => import('./widgets/MarkdownContent'));
1983
-
1984
- /**
1985
- * Renders `markdown` / `richtext` values as formatted GFM markdown (lazy-loaded,
1986
- * sanitized) instead of the raw markup string.
1987
- */
1988
- export function MarkdownCellRenderer({ value }: CellRendererProps): React.ReactElement {
1989
- if (value == null || value === '') return <EmptyValue />;
1990
- return (
1991
- <React.Suspense fallback={<span className="text-sm text-muted-foreground">{String(value).slice(0, 80)}</span>}>
1992
- <LazyMarkdownContent value={String(value)} />
1993
- </React.Suspense>
1994
- );
1995
- }
1996
-
1997
- /**
1998
- * Minimal HTML sanitizer for display: drops <script>/<style>/<iframe> blocks,
1999
- * inline event handlers, and javascript: URLs. Defense-in-depth — stored HTML
2000
- * is authored by users with write access, but is never trusted blindly.
2001
- */
2002
- function sanitizeHtml(html: string): string {
2003
- return html
2004
- .replace(/<\s*(script|style|iframe|object|embed)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
2005
- .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
2006
- .replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
2007
- }
2008
-
2009
- /**
2010
- * Renders an `html` value as sanitized, formatted HTML instead of raw markup.
2011
- */
2012
- export function HtmlCellRenderer({ value }: CellRendererProps): React.ReactElement {
2013
- if (value == null || value === '') return <EmptyValue />;
2014
- return (
2015
- <div
2016
- className="prose prose-sm max-w-none dark:prose-invert break-words"
2017
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(String(value)) }}
2018
- />
2019
- );
2020
- }
2021
-
2022
- /**
2023
- * Renders a `location`/`geolocation` value as readable coordinates with a pin.
2024
- * Accepts `{ lat, lng }` / `{ latitude, longitude }`, a `"lat,lng"` string,
2025
- * or a `[lat, lng]` array. Falls back to compact JSON for anything else.
2026
- */
2027
- export function LocationCellRenderer({ value }: CellRendererProps): React.ReactElement {
2028
- if (value == null || value === '') return <EmptyValue />;
2029
- let lat: number | undefined;
2030
- let lng: number | undefined;
2031
- if (typeof value === 'object' && !Array.isArray(value)) {
2032
- const v = value as Record<string, any>;
2033
- lat = typeof v.lat === 'number' ? v.lat : typeof v.latitude === 'number' ? v.latitude : undefined;
2034
- lng = typeof v.lng === 'number' ? v.lng : typeof v.lon === 'number' ? v.lon : typeof v.longitude === 'number' ? v.longitude : undefined;
2035
- } else if (Array.isArray(value) && value.length === 2) {
2036
- lat = Number(value[0]);
2037
- lng = Number(value[1]);
2038
- } else if (typeof value === 'string') {
2039
- const parts = value.split(',').map((s) => parseFloat(s.trim()));
2040
- if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
2041
- [lat, lng] = parts;
2042
- }
2043
- }
2044
- if (typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
2045
- return (
2046
- <span className="inline-flex items-center gap-1 text-sm tabular-nums">
2047
- <MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
2048
- {lat.toFixed(4)}, {lng.toFixed(4)}
2049
- </span>
2050
- );
2051
- }
2052
- return <JsonCellRenderer value={value} field={{} as any} />;
2053
- }
2054
-
2055
- /**
2056
- * Renders an `address` value as a formatted single-line postal address instead
2057
- * of stringified JSON (objectui#4037).
2058
- *
2059
- * The detail page's display registry mapped `address` to {@link JsonCellRenderer},
2060
- * so a populated address read as `{"street":"中策路 1 号","city":"杭州",…}` on the
2061
- * detail page and in its inline-edit read state — while the very same value
2062
- * rendered correctly in the create/edit dialog, whose input registry has always
2063
- * carried `address`. Read side only: nothing about the input widget changes.
2064
- *
2065
- * Layout is not invented here. It is {@link formatAddress} — the rule
2066
- * `AddressField`'s own readonly branch already applied, over the sub-field set
2067
- * its inputs expose — so a readonly form and a detail page cannot spell one
2068
- * stored address two ways.
2069
- *
2070
- * Anything `formatAddress` cannot recognize falls back to compact JSON rather
2071
- * than rendering blank: an unknown shape stays visible (today's behaviour) and
2072
- * is never silently swallowed by the fix.
2073
- */
2074
- export function AddressCellRenderer({ value }: CellRendererProps): React.ReactElement {
2075
- if (value == null || value === '') return <EmptyValue />;
2076
- // A plain string address (some apps store one) is already display-ready.
2077
- if (typeof value === 'string') return <TruncatedText text={value} className="text-sm" />;
2078
- if (typeof value === 'object' && !Array.isArray(value)) {
2079
- const formatted = formatAddress(value as AddressValue);
2080
- if (formatted) return <TruncatedText text={formatted} className="text-sm" />;
2081
- // An object carrying no recognized part: `{}` reads as empty, while
2082
- // `{ foo: 1 }` keeps its JSON so real data is never hidden.
2083
- if (Object.keys(value as Record<string, unknown>).length === 0) return <EmptyValue />;
2084
- }
2085
- return <JsonCellRenderer value={value} field={{} as any} />;
2086
- }
2087
-
2088
- /**
2089
- * Get the appropriate cell renderer for a field type
2090
- */
2091
- export function getCellRenderer(fieldType: string): React.FC<CellRendererProps> {
2092
- // 1. Try exact match in registry
2093
- if (fieldRegistry.has(fieldType)) {
2094
- return fieldRegistry.get(fieldType)!;
2095
- }
2096
-
2097
- // 2. Fallback to standard mappings if not overridden
2098
- const standardMap: Record<string, React.FC<CellRendererProps>> = {
2099
- text: TextCellRenderer,
2100
- textarea: TextCellRenderer,
2101
- markdown: MarkdownCellRenderer,
2102
- html: HtmlCellRenderer,
2103
- richtext: MarkdownCellRenderer,
2104
- code: TextCellRenderer,
2105
- qrcode: TextCellRenderer,
2106
- number: NumberCellRenderer,
2107
- currency: CurrencyCellRenderer,
2108
- percent: PercentCellRenderer,
2109
- progress: PercentCellRenderer,
2110
- slider: NumberCellRenderer,
2111
- rating: NumberCellRenderer,
2112
- boolean: BooleanCellRenderer,
2113
- toggle: BooleanCellRenderer,
2114
- date: DateCellRenderer,
2115
- datetime: DateTimeCellRenderer,
2116
- time: TextCellRenderer,
2117
- select: SelectCellRenderer,
2118
- status: SelectCellRenderer,
2119
- multiselect: SelectCellRenderer,
2120
- radio: SelectCellRenderer,
2121
- checkboxes: SelectCellRenderer,
2122
- tags: SelectCellRenderer,
2123
- lookup: LookupCellRenderer,
2124
- master_detail: LookupCellRenderer,
2125
- tree: LookupCellRenderer,
2126
- email: EmailCellRenderer,
2127
- url: UrlCellRenderer,
2128
- phone: PhoneCellRenderer,
2129
- file: FileCellRenderer,
2130
- video: FileCellRenderer,
2131
- audio: FileCellRenderer,
2132
- image: ImageCellRenderer,
2133
- avatar: ImageCellRenderer,
2134
- signature: ImageCellRenderer,
2135
- formula: FormulaCellRenderer,
2136
- summary: FormulaCellRenderer,
2137
- auto_number: TextCellRenderer,
2138
- user: UserCellRenderer,
2139
- owner: UserCellRenderer,
2140
- password: () => <span>••••••</span>,
2141
- secret: () => <span>••••••</span>,
2142
- location: LocationCellRenderer,
2143
- geolocation: LocationCellRenderer,
2144
- address: AddressCellRenderer,
2145
- color: ColorSwatchCellRenderer,
2146
- json: JsonCellRenderer,
2147
- object: JsonCellRenderer,
2148
- composite: JsonCellRenderer,
2149
- record: JsonCellRenderer,
2150
- repeater: ({ value }: CellRendererProps) => {
2151
- const n = Array.isArray(value) ? value.length : 0;
2152
- return n > 0
2153
- ? <span className="text-gray-500 italic">{n} 项</span>
2154
- : <EmptyValue />;
2155
- },
2156
- vector: () => <span className="text-gray-500 italic">[Vector]</span>,
2157
- grid: () => <span className="text-gray-500 italic">[Grid]</span>,
2158
- };
2159
-
2160
- // 3. Register standard renderers implicitly if not present
2161
- // This ensures that if we call registerFieldRenderer('text', Custom), it works,
2162
- // but if we don't, we get the standard one.
2163
- return standardMap[fieldType] || TextCellRenderer;
2164
- }
2165
-
2166
- // Register standard renderers immediately
2167
- registerFieldRenderer('lookup', LookupCellRenderer);
2168
- registerFieldRenderer('master_detail', LookupCellRenderer);
2169
- registerFieldRenderer('select', SelectCellRenderer);
2170
- registerFieldRenderer('status', SelectCellRenderer);
2171
- registerFieldRenderer('user', UserCellRenderer);
2172
- registerFieldRenderer('owner', UserCellRenderer);
2173
-
2174
- // Register getCellRenderer in the bridge so RecordPickerDialog can access it
2175
- // via LookupField without circular imports.
2176
- import { setCellRendererResolver } from './widgets/_cell-renderer-bridge';
2177
- setCellRendererResolver(getCellRenderer);
2178
-
2179
-
2180
-
2181
- // `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
2182
- // FieldEditWidget can resolve spec aliases without importing this barrel.
2183
- export { mapFieldTypeToFormType } from './field-type-alias';
2184
- import { mapFieldTypeToFormType } from './field-type-alias';
2185
-
2186
- /**
2187
- * Formats file size in bytes to human-readable string
2188
- * @param bytes - File size in bytes (must be non-negative)
2189
- * @returns Formatted string (e.g., "5 MB", "1.5 GB")
2190
- */
2191
- export function formatFileSize(bytes: number): string {
2192
- if (bytes < 0 || !Number.isFinite(bytes)) {
2193
- return '0 B';
2194
- }
2195
-
2196
- if (bytes === 0) {
2197
- return '0 B';
2198
- }
2199
-
2200
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
2201
- let size = bytes;
2202
- let unitIndex = 0;
2203
-
2204
- while (size >= 1024 && unitIndex < units.length - 1) {
2205
- size /= 1024;
2206
- unitIndex++;
2207
- }
2208
-
2209
- return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
2210
- }
2211
-
2212
- /**
2213
- * Build validation rules from field metadata
2214
- * @param field - Field metadata from ObjectStack
2215
- * @returns Validation rule object compatible with react-hook-form
2216
- */
2217
- export function buildValidationRules(field: any): any {
2218
- const rules: any = {};
2219
-
2220
- // Required validation. Emit a bare `true` for the auto-generated case rather
2221
- // than baking an English message here: the form renderer localizes it via
2222
- // `t('validation.required', { field })`. A field-authored `required_message`
2223
- // still wins and is passed through verbatim.
2224
- if (field.required) {
2225
- rules.required = typeof field.required_message === 'string'
2226
- ? field.required_message
2227
- : true;
2228
- }
2229
-
2230
- // Standard validation messages are localized by the form renderer, which has
2231
- // an i18n `t` in scope. We therefore emit `message: undefined` for the
2232
- // auto-generated case and tag each rule with a `messageKey` (+ any interp
2233
- // vars); a field-authored `*_message` is a string and passes through as-is,
2234
- // still winning over the localized default. See form.tsx `localizeRule`.
2235
-
2236
- // Length validation for text fields. The spec-canonical keys are camelCase
2237
- // (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
2238
- // record-validator enforces); the snake_case pair is the legacy objectui
2239
- // spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
2240
- const minLength = (field as any).minLength ?? field.min_length;
2241
- if (minLength) {
2242
- rules.minLength = {
2243
- value: minLength,
2244
- message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
2245
- messageKey: 'validation.minLength',
2246
- };
2247
- }
2248
-
2249
- const maxLength = (field as any).maxLength ?? field.max_length;
2250
- if (maxLength) {
2251
- rules.maxLength = {
2252
- value: maxLength,
2253
- message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
2254
- messageKey: 'validation.maxLength',
2255
- };
2256
- }
2257
-
2258
- // Number range validation
2259
- if (field.min !== undefined) {
2260
- rules.min = {
2261
- value: field.min,
2262
- message: typeof field.min_message === 'string' ? field.min_message : undefined,
2263
- messageKey: 'validation.min',
2264
- };
2265
- }
2266
-
2267
- if (field.max !== undefined) {
2268
- rules.max = {
2269
- value: field.max,
2270
- message: typeof field.max_message === 'string' ? field.max_message : undefined,
2271
- messageKey: 'validation.max',
2272
- };
2273
- }
2274
-
2275
- // Pattern validation
2276
- if (field.pattern) {
2277
- rules.pattern = {
2278
- value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
2279
- message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
2280
- messageKey: 'validation.pattern',
2281
- };
2282
- }
2283
-
2284
- // Email validation
2285
- if (field.type === 'email') {
2286
- rules.pattern = {
2287
- value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
2288
- message: undefined,
2289
- messageKey: 'validation.email',
2290
- };
2291
- }
2292
-
2293
- // URL validation
2294
- if (field.type === 'url') {
2295
- rules.pattern = {
2296
- value: /^https?:\/\/.+/,
2297
- message: undefined,
2298
- messageKey: 'validation.url',
2299
- };
2300
- }
2301
-
2302
- // Custom validation function
2303
- if (field.validate) {
2304
- rules.validate = field.validate;
2305
- }
2306
-
2307
- return Object.keys(rules).length > 0 ? rules : undefined;
2308
- }
2309
-
2310
- /**
2311
- * Evaluate a conditional expression for field visibility
2312
- * @param condition - Condition object from field metadata
2313
- * @param formData - Current form values
2314
- * @returns Whether the condition is met
2315
- */
2316
- export function evaluateCondition(condition: any, formData: any): boolean {
2317
- if (!condition) return true;
2318
-
2319
- // Simple field equality check
2320
- if (condition.field && condition.value !== undefined) {
2321
- const fieldValue = formData[condition.field];
2322
- if (condition.operator === '=' || condition.operator === '==') {
2323
- return fieldValue === condition.value;
2324
- } else if (condition.operator === '!=') {
2325
- return fieldValue !== condition.value;
2326
- } else if (condition.operator === '>') {
2327
- return fieldValue > condition.value;
2328
- } else if (condition.operator === '>=') {
2329
- return fieldValue >= condition.value;
2330
- } else if (condition.operator === '<') {
2331
- return fieldValue < condition.value;
2332
- } else if (condition.operator === '<=') {
2333
- return fieldValue <= condition.value;
2334
- } else if (condition.operator === 'in') {
2335
- return Array.isArray(condition.value) && condition.value.includes(fieldValue);
2336
- }
2337
- }
2338
-
2339
- // AND/OR logic
2340
- if (condition.and && Array.isArray(condition.and)) {
2341
- return condition.and.every((c: any) => evaluateCondition(c, formData));
2342
- }
2343
-
2344
- if (condition.or && Array.isArray(condition.or)) {
2345
- return condition.or.some((c: any) => evaluateCondition(c, formData));
2346
- }
2347
-
2348
- // Default to true if condition format is unknown
2349
- return true;
2350
- }
2351
-
2352
- // TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `createFieldRenderer()`
2353
- // lived here. It wrapped a field widget in synthesized label/description chrome
2354
- // plus a local `useState` + `onChange`, which let a BARE field node render
2355
- // standalone under `SchemaRenderer`. Only `registerFields()` (below, also
2356
- // removed) ever called it, and only the docs site called that — so the chrome
2357
- // existed nowhere a real application could reach, and the docs demos showed a
2358
- // rendering no app produces. The catalog's field examples are form-hosted now
2359
- // (`{ type: 'form', fields: [...] }`), where the real form renderer owns label
2360
- // and value state. Do not re-add a second, demo-only registration path: one
2361
- // registration seam is the whole point (objectui#3308).
2362
-
2363
- /**
2364
- * Field widget map for lazy loading
2365
- * Maps field type to widget component
2366
- */
2367
- const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentType<any> }>> = {
2368
- // Basic fields
2369
- 'text': () => import('./widgets/TextField').then(m => ({ default: m.TextField })),
2370
- 'textarea': () => import('./widgets/TextAreaField').then(m => ({ default: m.TextAreaField })),
2371
- 'number': () => import('./widgets/NumberField').then(m => ({ default: m.NumberField })),
2372
- 'boolean': () => import('./widgets/BooleanField').then(m => ({ default: m.BooleanField })),
2373
- 'select': () => import('./widgets/SelectField').then(m => ({ default: m.SelectField })),
2374
- 'date': () => import('./widgets/DateField').then(m => ({ default: m.DateField })),
2375
- 'datetime': () => import('./widgets/DateTimeField').then(m => ({ default: m.DateTimeField })),
2376
- 'time': () => import('./widgets/TimeField').then(m => ({ default: m.TimeField })),
2377
-
2378
- // Contact fields
2379
- 'email': () => import('./widgets/EmailField').then(m => ({ default: m.EmailField })),
2380
- 'phone': () => import('./widgets/PhoneField').then(m => ({ default: m.PhoneField })),
2381
- 'url': () => import('./widgets/UrlField').then(m => ({ default: m.UrlField })),
2382
-
2383
- // Selection fields (multi-value / option groups)
2384
- 'multiselect': () => import('./widgets/MultiSelectField').then(m => ({ default: m.MultiSelectField })),
2385
- 'radio': () => import('./widgets/RadioField').then(m => ({ default: m.RadioField })),
2386
- 'checkboxes': () => import('./widgets/CheckboxesField').then(m => ({ default: m.CheckboxesField })),
2387
- 'tags': () => import('./widgets/TagsField').then(m => ({ default: m.TagsField })),
2388
-
2389
- // Specialized fields
2390
- 'currency': () => import('./widgets/CurrencyField').then(m => ({ default: m.CurrencyField })),
2391
- 'percent': () => import('./widgets/PercentField').then(m => ({ default: m.PercentField })),
2392
- 'password': () => import('./widgets/PasswordField').then(m => ({ default: m.PasswordField })),
2393
- 'markdown': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
2394
- 'html': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
2395
- 'richtext': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
2396
- 'lookup': () => import('./widgets/LookupField').then(m => ({ default: m.LookupField })),
2397
- // master_detail represents the child-side FK to its parent. In create/edit forms it
2398
- // must render as a single-value lookup picker (it is typically NOT NULL). The legacy
2399
- // MasterDetailField widget modelled this as a one-to-many list, which is incorrect
2400
- // for the child-side and prevented users from filling the required parent reference.
2401
- 'master_detail': () => import('./widgets/LookupField').then(m => ({ default: m.LookupField })),
2402
-
2403
- // File fields
2404
- 'file': () => import('./widgets/FileField').then(m => ({ default: m.FileField })),
2405
- 'image': () => import('./widgets/ImageField').then(m => ({ default: m.ImageField })),
2406
-
2407
- // Location field
2408
- 'location': () => import('./widgets/LocationField').then(m => ({ default: m.LocationField })),
2409
-
2410
- // Computed/Read-only fields
2411
- 'formula': () => import('./widgets/FormulaField').then(m => ({ default: m.FormulaField })),
2412
- 'summary': () => import('./widgets/SummaryField').then(m => ({ default: m.SummaryField })),
2413
- 'auto_number': () => import('./widgets/AutoNumberField').then(m => ({ default: m.AutoNumberField })),
2414
-
2415
- // User fields
2416
- 'user': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
2417
- 'owner': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
2418
-
2419
- // Complex data types
2420
- 'object': () => import('./widgets/ObjectField').then(m => ({ default: m.ObjectField })),
2421
- 'vector': () => import('./widgets/VectorField').then(m => ({ default: m.VectorField })),
2422
- 'grid': () => import('./widgets/GridField').then(m => ({ default: m.GridField })),
2423
-
2424
- // Additional field types from @objectstack/spec
2425
- 'color': () => import('./widgets/ColorField').then(m => ({ default: m.ColorField })),
2426
- 'slider': () => import('./widgets/SliderField').then(m => ({ default: m.SliderField })),
2427
- 'rating': () => import('./widgets/RatingField').then(m => ({ default: m.RatingField })),
2428
- 'code': () => import('./widgets/CodeField').then(m => ({ default: m.CodeField })),
2429
- 'avatar': () => import('./widgets/AvatarField').then(m => ({ default: m.AvatarField })),
2430
- 'address': () => import('./widgets/AddressField').then(m => ({ default: m.AddressField })),
2431
- 'geolocation': () => import('./widgets/GeolocationField').then(m => ({ default: m.GeolocationField })),
2432
- 'signature': () => import('./widgets/SignatureField').then(m => ({ default: m.SignatureField })),
2433
- 'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
2434
-
2435
- // Widget-hint-only pickers (reached via a field `widget:` override, never a
2436
- // bare field `type`). They render a *picker* over machine data an admin would
2437
- // otherwise have to type — used by sys_sharing_rule (ADR-0056 P2 pattern):
2438
- // object-ref → choose a registered object by name
2439
- // filter-condition → visual criteria builder scoped to the chosen object
2440
- // recipient-picker → record picker whose target follows a sibling type
2441
- 'object-ref': () => import('./widgets/ObjectRefField').then(m => ({ default: m.ObjectRefField })),
2442
- 'filter-condition': () => import('./widgets/FilterConditionField').then(m => ({ default: m.FilterConditionField })),
2443
- 'recipient-picker': () => import('./widgets/RecipientPickerField').then(m => ({ default: m.RecipientPickerField })),
2444
- };
2445
-
2446
- /**
2447
- * Every field type the form can render (the canonical list of supported types).
2448
- * Exported so inline editing can be checked against it — a form type must
2449
- * either have an inline editor or be explicitly excluded, see
2450
- * `INLINE_EXCLUDED_FIELD_TYPES` and its drift-guard test.
2451
- */
2452
- export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
2453
-
2454
- /**
2455
- * Resolve an arbitrary field-type spelling to the widget key the form uses.
2456
- * A key already present in the widget map resolves to itself; spec aliases
2457
- * (`toggle`, `json`, `repeater`, `secret`, …) resolve through
2458
- * {@link mapFieldTypeToFormType}; anything unknown falls back to `text` —
2459
- * the same fallback the form renderer applies.
2460
- *
2461
- * Consumers that render field widgets outside the form (e.g. the app-shell
2462
- * `ActionParamDialog`) use this + {@link getLazyFieldWidget} so their type
2463
- * support can never drift behind the form surface (ADR-0059).
2464
- */
2465
- export function resolveFormWidgetType(fieldType: string): string {
2466
- if (fieldWidgetMap[fieldType]) return fieldType;
2467
- const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
2468
- return fieldWidgetMap[mapped] ? mapped : 'text';
2469
- }
2470
-
2471
- /** Cache so each widget type creates one lazy component (and one chunk request). */
2472
- const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();
2473
-
2474
- /**
2475
- * Lazily-loaded form field widget for a field type. Shares the exact loaders
2476
- * of {@link fieldWidgetMap} (the same components `registerField` registers for
2477
- * forms), wrapped in `React.lazy` and cached per type — so a consumer can
2478
- * render any form-supported field type without eagerly bundling every widget.
2479
- * Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
2480
- * widget via {@link resolveFormWidgetType}.
2481
- */
2482
- export function getLazyFieldWidget(fieldType: string): React.ComponentType<any> {
2483
- const key = resolveFormWidgetType(fieldType);
2484
- let Widget = lazyFieldWidgets.get(key);
2485
- if (!Widget) {
2486
- Widget = React.lazy(fieldWidgetMap[key]);
2487
- lazyFieldWidgets.set(key, Widget);
2488
- }
2489
- return Widget;
2490
- }
2491
-
2492
- /**
2493
- * Register a specific field type lazily
2494
- * @param fieldType - The field type to register (e.g., 'text', 'number')
2495
- *
2496
- * @example
2497
- * // Register only the text field
2498
- * registerField('text');
2499
- */
2500
- // Field types whose short name collides with a display/ui/view/plugin component
2501
- // registered elsewhere (e.g. the display widgets and form-input primitives in
2502
- // @object-ui/components, or the markdown display plugin). These remain
2503
- // accessible via the namespaced `field:<type>` key — which is how forms resolve
2504
- // them (see form.tsx renderFieldComponent + mapFieldTypeToFormType) — but must
2505
- // not overwrite the bare `<type>` fallback, which the display/ui primitive owns
2506
- // and which page schemas expect (e.g. `{ type: 'markdown', content }` → the
2507
- // markdown renderer, not the RichText editor). Without skipFallback each of
2508
- // these logged a "bare-name fallback is being overwritten" warning at boot.
2509
- const FIELD_TYPES_SKIP_FALLBACK = new Set([
2510
- // Display widgets (text/html/image/avatar/grid live in @object-ui/components
2511
- // or @object-ui/layout as the bare-name owners).
2512
- 'text',
2513
- 'html',
2514
- 'image',
2515
- 'avatar',
2516
- 'grid',
2517
- // Form-input primitives owned by `ui:*` in @object-ui/components.
2518
- 'textarea',
2519
- 'select',
2520
- 'email',
2521
- 'password',
2522
- 'slider',
2523
- // Display renderer owned by `plugin-markdown:markdown`.
2524
- 'markdown',
2525
- // No other package owns the bare `time`/`address` key, but `registerField`
2526
- // wraps each call in a fresh `React.lazy(...)`, so re-registration (HMR,
2527
- // re-import) fails the registry's identity check every time and logs the
2528
- // same "bare-name fallback overwritten" warning at every boot regardless.
2529
- 'time',
2530
- 'address',
2531
- // Widget-hint-only pickers — resolved solely via `field:<widget>`, so the
2532
- // bare-key fallback is never wanted.
2533
- 'object-ref',
2534
- 'filter-condition',
2535
- 'recipient-picker',
2536
- ]);
2537
-
2538
- /**
2539
- * Widgets whose labelled surface is NOT a labelable HTML element, so a host's
2540
- * `<label for>` cannot reach it and the association has to go by IDREF instead
2541
- * (`ComponentMeta.labelling`, objectui#3961). Two shapes, one declaration:
2542
- *
2543
- * - real composites — `address` / `geolocation` render several inputs under one
2544
- * container, and `checkboxes` / `radio` / `rating` / `multiselect` a set of
2545
- * choice controls; the host's label names the GROUP, each sub-control keeps
2546
- * its own name (a sub-label, or the chip's own text content).
2547
- * - `file` is not composite at all: it has exactly ONE control, the dropzone,
2548
- * which is a `div[role="button"]` (it is the keyboard path to the hidden file
2549
- * input). It is here because a `div` cannot be `for`-labelled, not because it
2550
- * is a group, and it renders NO `role="group"` — the dropzone itself takes the
2551
- * `aria-labelledby`.
2552
- *
2553
- * Measured, not assumed: every entry was verified in a real form to be a widget
2554
- * whose host label either resolved to nothing (`address` / `geolocation`, whose
2555
- * sub-input ids overwrote the host id — objectui#3343) or resolved to an element
2556
- * that cannot carry it (`checkboxes` / `radio` / `rating` / `file` /
2557
- * `multiselect`). In both shapes the visible group label was, before this
2558
- * declaration, the accessible name of NOTHING.
2559
- *
2560
- * `multiselect` arrived one issue later (objectui#3975) for a coverage reason,
2561
- * not a mechanism one: #3961's probe covered the six above, and re-running it
2562
- * over the full widget map afterwards found `multiselect` on the byte-identical
2563
- * failure shape as `checkboxes` — the host id kept, on the chip row's wrapper
2564
- * `div`, where a `for` is inert. Same declaration, same container answer.
2565
- *
2566
- * A widget NOT listed here takes the single-control path unchanged. That is the
2567
- * safe default: the host keeps emitting `for`, and a composite that forgot to
2568
- * declare itself is caught by the label-association tests (objectui#3952) rather
2569
- * than silently emitting an `aria-labelledby` onto a container with no role.
2570
- */
2571
- const FIELD_TYPES_GROUP_LABELLED = new Set([
2572
- 'address',
2573
- 'geolocation',
2574
- 'checkboxes',
2575
- 'radio',
2576
- 'rating',
2577
- 'file',
2578
- // objectui#3975 — the residual after #3961's six, same shape as `checkboxes`.
2579
- 'multiselect',
2580
- ]);
2581
-
2582
- export function registerField(fieldType: string): void {
2583
- const loader = fieldWidgetMap[fieldType];
2584
- if (!loader) {
2585
- console.warn(`Unknown field type: ${fieldType}`);
2586
- return;
2587
- }
2588
-
2589
- // Create lazy component
2590
- const LazyFieldWidget = React.lazy(loader);
2591
-
2592
- // Register with field namespace. No LAYOUT wrapper — the form renderer owns
2593
- // label/description/spacing. The only thing wrapped is the metadata carrier:
2594
- // `withFieldCarrier` maps `SchemaRenderer`'s `schema` node onto `field`
2595
- // (objectui#3233) and renders nothing of its own.
2596
- ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
2597
- namespace: 'field',
2598
- skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
2599
- // Only the group-labelled widgets carry the key; everything else leaves it
2600
- // absent, which the form renderer reads as `'control'` (objectui#3961). One
2601
- // spelling of the default, in one place.
2602
- ...(FIELD_TYPES_GROUP_LABELLED.has(fieldType) ? { labelling: 'group' as const } : null),
2603
- });
2604
- }
2605
-
2606
- /**
2607
- * Register all field types (for backward compatibility)
2608
- * This function auto-registers all field widgets on import.
2609
- *
2610
- * For better tree-shaking, use registerField() to register only needed fields.
2611
- *
2612
- * @example
2613
- * // Register all fields at once
2614
- * registerAllFields();
2615
- */
2616
- export function registerAllFields(): void {
2617
- Object.keys(fieldWidgetMap).forEach(fieldType => {
2618
- registerField(fieldType);
2619
- });
2620
- }
2621
-
2622
- // TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `registerFields()` lived
2623
- // here. It registered every widget wrapped in `createFieldRenderer` (above) under
2624
- // the same `field:<type>` keys `registerAllFields()` owns, so whoever called it
2625
- // LAST won the registry. Its only caller was the docs site, which needed the
2626
- // synthesized label + `onChange` to render a bare field node standalone.
2627
- //
2628
- // objectui#3308 ruled "registration gets exactly one path"; the implementation
2629
- // (PR #3793) then disproved the assumption that this was a redundant duplicate —
2630
- // it was a demo host adapter, and switching the docs site to `registerAllFields()`
2631
- // alone would have left the demo inputs inert. Ruling B of objectui#3798 resolved
2632
- // that by moving the docs catalog's field examples to form hosting
2633
- // (`{ type: 'form', fields: [...] }`), which deletes the need for the adapter
2634
- // instead of relocating it: the form renderer already owns label and value state,
2635
- // so the docs now show what a real application actually renders.
2636
- //
2637
- // Do not re-add this or any second registration path. `registerAllFields()` (and
2638
- // `registerField()` for one type) is the seam; a docs- or test-only wrapper that
2639
- // re-registers `field:<type>` silently shadows the live entry for every consumer
2640
- // sharing the registry.
2641
- //
2642
- // TOMBSTONE (objectui#3308, ADR-0049 enforce-or-remove) — `field:capability-multiselect`
2643
- // was registered inside `registerFields()`, and ONLY there. Because the docs site
2644
- // was that function's sole caller, the key never existed on the live path, so a
2645
- // field authored with `widget: 'capability-multiselect'` resolved to nothing in
2646
- // every real app while a comment here advertised it as usable from a record form.
2647
- // Nothing stamped the hint either: ADR-0056 P1 stamps `permission-facet-link` on
2648
- // all six `sys_permission_set` facets (`data-objectstack/src/index.ts`
2649
- // applyFieldWidgetOverrides) and P2 put the capability editor in Studio. The
2650
- // widget NAME stays retired — do not add it to `fieldWidgetMap`.
2651
- // `CapabilityMultiSelectField` itself lives on as a plain component, imported and
2652
- // rendered directly by Studio's `PermissionMatrixEditor` (ADR-0056 P2's design).
2653
-
2654
- export * from './widgets/types';
2655
- // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
2656
- // arbiter of reference vs expanded vs legacy-blob form, shared by the upload
2657
- // widgets and by action-param serialization.
2658
- export * from './widgets/file-value';
2659
- export * from './FieldEditWidget';
2660
- export * from './widgets/TextField';
2661
- export * from './widgets/NumberField';
2662
- export * from './widgets/BooleanField';
2663
- export * from './widgets/SelectField';
2664
- export * from './widgets/DateField';
2665
- export * from './widgets/DateTimeField';
2666
- export * from './widgets/TimeField';
2667
- export * from './widgets/EmailField';
2668
- export * from './widgets/PhoneField';
2669
- export * from './widgets/UrlField';
2670
- export * from './widgets/CurrencyField';
2671
- export * from './widgets/PercentField';
2672
- export * from './widgets/PasswordField';
2673
- export * from './widgets/TextAreaField';
2674
- export * from './widgets/RichTextField';
2675
- export * from './widgets/LookupField';
2676
- export * from './widgets/CapabilityMultiSelectField';
2677
- export * from './widgets/ObjectRefField';
2678
- export * from './widgets/FilterConditionField';
2679
- export * from './widgets/RecipientPickerField';
2680
- export * from './widgets/RecordPickerDialog';
2681
- // Shared picker-column derivation (ADR-0085 highlightFields → displayFields →
2682
- // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a
2683
- // lookup picker and a related-list Add dialog of the same object agree on
2684
- // columns (#3365).
2685
- export * from './widgets/deriveLookupColumns';
2686
- export * from './widgets/FileField';
2687
- export * from './widgets/ImageField';
2688
- export { ImageCropperDialog } from './widgets/ImageCropperDialog';
2689
- export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
2690
- export * from './widgets/LocationField';
2691
- export * from './widgets/FormulaField';
2692
- export * from './widgets/SummaryField';
2693
- export * from './widgets/AutoNumberField';
2694
- export * from './widgets/UserField';
2695
- export * from './widgets/ObjectField';
2696
- export * from './widgets/VectorField';
2697
- export * from './widgets/GridField';
2698
- // New widgets according to @objectstack/spec
2699
- export * from './widgets/ColorField';
2700
- export * from './widgets/SliderField';
2701
- export * from './widgets/RatingField';
2702
- export * from './widgets/CodeField';
2703
- export * from './widgets/AvatarField';
2704
- export * from './widgets/AddressField';
2705
- export * from './widgets/GeolocationField';
2706
- export * from './widgets/SignatureField';
2707
- export * from './widgets/QRCodeField';
2708
- export * from './widgets/MasterDetailField';
2709
- export * from './widgets/MultiSelectField';
2710
- export * from './widgets/RadioField';
2711
- export * from './widgets/CheckboxesField';
2712
- export * from './widgets/TagsField';
2713
-
2714
- // The SDUI-node → `field` adapter every registration here goes through
2715
- // (objectui#3233). Exported so a widget registered OUTSIDE this repo can reach
2716
- // the same single-carrier guarantee instead of re-growing a `field || schema`
2717
- // read of its own.
2718
- export { withFieldCarrier } from './withFieldCarrier';
2719
-
2720
- // The whitelist that decides what a widget's `...props` spread may put on a
2721
- // DOM element (objectui#3291) — the runtime executor of the "DOM pass-through"
2722
- // block of `FieldWidgetComponentProps`. Exported for the same reason as
2723
- // `withFieldCarrier` above: a widget authored outside this repo needs to reach
2724
- // it, or it re-grows the bare spread this closed.
2725
- export { toDomProps } from './widgets/toDomProps';
2726
- export type { DomProps } from './widgets/toDomProps';
2727
-
2728
- // The native date/time control value adapters (objectui#3127). `DateTimeField`
2729
- // is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
2730
- // an ISO instant back, which is also the wire form the platform's `datetime`
2731
- // value contract requires (`InstantValueSchema`: an ISO-8601 instant with an
2732
- // explicit zone), so no consumer has to convert at its own serialization
2733
- // boundary. `ActionParamDialog` used to (objectstack#5061 removed it — the
2734
- // zone-less wall clock it produced was the one shape the validator rejects).
2735
- // Exported for the same reason as `toDomProps` above: a widget authored outside
2736
- // this repo needs the same pair, and a second copy of "what the local wall clock
2737
- // of an instant is" would drift from this one.
2738
- export {
2739
- toDateInputValue,
2740
- toDateTimeInputValue,
2741
- fromDateTimeInputValue,
2742
- } from './widgets/nativeDateValue';
2743
-
2744
- // Initialize registry
2745
- registerAllFields();