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