@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
package/src/currency.ts DELETED
@@ -1,144 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * Licensed under the MIT license found in the LICENSE file.
6
- */
7
-
8
- /**
9
- * `resolveFieldCurrency` now lives in `@object-ui/i18n` (co-located with
10
- * `useLocalization`, which supplies the tenant default). This module re-exports
11
- * it so the long-standing `@object-ui/fields` import path keeps working for
12
- * every field/cell renderer that already depends on it.
13
- */
14
- export { resolveFieldCurrency } from '@object-ui/i18n';
15
-
16
- /**
17
- * Fallback width for a currency whose digit count cannot be resolved: the
18
- * literal both call sites used unconditionally before objectui#4361. It is
19
- * reached only when there is no ISO code to ask about (an invalid code, or a
20
- * runtime with no currency data at all), never as a policy.
21
- */
22
- const DEFAULT_FRACTION_DIGITS = 2;
23
-
24
- /**
25
- * ISO 4217 minor-unit width, memoized per currency code.
26
- *
27
- * The probe is deliberately made with NO locale. Measured across
28
- * en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime
29
- * default, every locale answers the same digit count for the same code (USD 2,
30
- * JPY 0, KWD 3, BHD 3, CLP 0, ISK 0) — the count comes from CLDR's
31
- * `currencyData`, which is keyed by the currency, not by who is reading it.
32
- * Dropping the locale from the probe therefore loses nothing and removes a
33
- * failure mode: a malformed locale tag makes `Intl.NumberFormat` throw
34
- * `RangeError: Incorrect locale information provided`, which would turn a bad
35
- * LOCALE into a wrong CURRENCY width. It also makes the code the whole cache
36
- * key.
37
- */
38
- const fractionDigitsByCurrency = new Map<string, number>();
39
-
40
- /**
41
- * The number of fraction digits ISO 4217 gives `currency` — 0 for JPY/KRW/CLP,
42
- * 2 for USD/EUR/CNY, 3 for KWD/BHD/OMR/TND — as ICU reports it.
43
- *
44
- * Returns {@link DEFAULT_FRACTION_DIGITS} when the code is not a currency code
45
- * at all: `Intl` throws `RangeError: Invalid currency code` for those, and the
46
- * throw must not escape, or a bad code would change the width used by the
47
- * callers' own bad-currency fallbacks (objectui#4332 pinned those strings). A
48
- * code that is well-formed but unknown to CLDR (e.g. `ZZZ`) does not throw —
49
- * ICU answers 2 for anything outside its table, which is the same default.
50
- *
51
- * Memoized because the currency cell renderer runs once per grid cell: measured
52
- * at 24.3us per uncached probe against 0.02us cached (200k iterations, node 22),
53
- * so a 500-row grid pays ~12ms per render pass for a value that cannot change.
54
- */
55
- export function currencyFractionDigits(currency: string): number {
56
- const cached = fractionDigitsByCurrency.get(currency);
57
- if (cached !== undefined) return cached;
58
- let digits = DEFAULT_FRACTION_DIGITS;
59
- try {
60
- // `maximumFractionDigits` is typed `number | undefined` on
61
- // `ResolvedNumberFormatOptions` — it is absent when a format resolves with
62
- // significant-digit rounding instead of fraction-digit rounding, which a
63
- // bare `style: 'currency'` never does. Coalesced rather than asserted so
64
- // the value is a number by construction and not by claim.
65
- digits =
66
- new Intl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions()
67
- .maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
68
- } catch {
69
- // Not a currency code — keep the historical width and let the caller's own
70
- // formatting attempt surface the bad code.
71
- }
72
- fractionDigitsByCurrency.set(currency, digits);
73
- return digits;
74
- }
75
-
76
- /**
77
- * Display symbols, memoized per **(locale, currency)** pair.
78
- *
79
- * The pair, not the code — and this is the one place this helper deliberately
80
- * parts company with {@link currencyFractionDigits} above. The digit count is
81
- * locale-independent (measured across eight locales, see that map's note), so
82
- * its probe drops the locale and keys the cache on the code alone. The SYMBOL
83
- * is genuinely locale-dependent, measured on the same runtime:
84
- *
85
- * - `USD` → `$` at `en` / `en-US` / `de-DE` / `ja-JP`, `US$` at `en-GB` and
86
- * `zh-CN`, `$US` at `fr-FR`;
87
- * - `JPY` → U+00A5 at `en`, the FULLWIDTH U+FFE5 at `ja-JP`, `JP¥` at
88
- * `en-GB`, and the bare code `JPY` at `fr-FR`.
89
- *
90
- * Copying the code-only cache key would therefore let whichever locale asked
91
- * first answer for every locale after it, for the life of the process — a
92
- * silent wrong answer rather than a crash, so it is pinned by test.
93
- *
94
- * `|` is safe as the key separator: it appears in neither a BCP-47 tag nor an
95
- * ISO 4217 code, so no two distinct pairs can collide on one key.
96
- */
97
- const symbolByLocaleAndCurrency = new Map<string, string>();
98
-
99
- /**
100
- * The string ICU renders in place of `currency` for a reader in `locale` — `$`
101
- * for USD at `en`, `€` for EUR, U+00A5 for JPY — taken from the `currency` part
102
- * of the very format the display path already uses.
103
- *
104
- * This is the ONE channel for the symbol (objectui#4414). It is not an
105
- * independent opinion about how a currency should look: it is the same
106
- * `Intl.NumberFormat(locale, { style: 'currency', currency })` that
107
- * `formatDisplayNumber` formats amounts with, read one part at a time. So a
108
- * widget that renders an amount in one mode and a bare symbol in another cannot
109
- * disagree with itself — which is exactly what the hand-written
110
- * `currency === 'USD' ? '$' : currency` ternary did, showing `JPY` beside a
111
- * readonly `¥1,235`.
112
- *
113
- * Falls back to `currency` itself — the code as given — in the three cases
114
- * where ICU cannot answer, because a bare code is a legitimate adornment (it is
115
- * what ICU itself returns for KWD/BHD/CHF/ISK, and what
116
- * `formatAmount`'s own bad-code branch renders beside it):
117
- *
118
- * 1. an invalid currency code — `Intl` throws `RangeError: Invalid currency
119
- * code`;
120
- * 2. a malformed locale tag (`en_US` with an underscore, say) — `Intl` throws
121
- * `RangeError: Incorrect locale information provided`. `currencyFractionDigits`
122
- * dodged this failure mode by dropping the locale from its probe; this
123
- * helper needs the locale, so it catches instead. Neither throw may escape:
124
- * this runs during render, so an escaped `RangeError` is a blank widget.
125
- * 3. no `currency` part in the output at all — not observed for
126
- * `style: 'currency'`, but coalesced rather than asserted.
127
- */
128
- export function currencySymbol(currency: string, locale: string): string {
129
- const key = `${locale}|${currency}`;
130
- const cached = symbolByLocaleAndCurrency.get(key);
131
- if (cached !== undefined) return cached;
132
- let symbol = currency;
133
- try {
134
- // The formatted value is irrelevant — only the `currency` part is read — so
135
- // the cheapest deterministic input is used.
136
- const parts = new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0);
137
- symbol = parts.find((part) => part.type === 'currency')?.value ?? currency;
138
- } catch {
139
- // Bad code or bad locale — keep the code, and let the caller's own
140
- // formatting attempt surface the real problem.
141
- }
142
- symbolByLocaleAndCurrency.set(key, symbol);
143
- return symbol;
144
- }
@@ -1,185 +0,0 @@
1
- import React from 'react';
2
- import { render, screen, fireEvent } from '@testing-library/react';
3
- import { describe, it, expect, vi } from 'vitest';
4
- import { DateField } from './widgets/DateField';
5
- import { DateTimeField } from './widgets/DateTimeField';
6
- import { TimeField } from './widgets/TimeField';
7
- import type { FieldWidgetComponentProps } from './widgets/types';
8
-
9
- const mockField = {
10
- name: 'test_field',
11
- label: 'Test Field',
12
- type: 'date',
13
- } as any;
14
-
15
- const baseProps: FieldWidgetComponentProps<string> = {
16
- field: mockField,
17
- value: '',
18
- onChange: vi.fn(),
19
- readonly: false,
20
- };
21
-
22
- describe('Date/Time Widgets', () => {
23
- describe('DateField', () => {
24
- it('renders date input in edit mode', () => {
25
- const { container } = render(<DateField {...baseProps} />);
26
- const dateInput = container.querySelector('input[type="date"]');
27
- expect(dateInput).toBeInTheDocument();
28
- });
29
-
30
- it('calls onChange when value changes', () => {
31
- const { container } = render(<DateField {...baseProps} />);
32
- const input = container.querySelector('input[type="date"]') as HTMLInputElement;
33
- fireEvent.change(input, { target: { value: '2023-01-01' } });
34
- expect(baseProps.onChange).toHaveBeenCalledWith('2023-01-01');
35
- });
36
-
37
- it('renders formatted date in readonly mode', () => {
38
- render(<DateField {...baseProps} readonly value="2023-01-01" />);
39
- const date = new Date('2023-01-01');
40
- expect(screen.getByText(date.toLocaleDateString())).toBeInTheDocument();
41
- expect(document.querySelector('input')).not.toBeInTheDocument();
42
- });
43
-
44
- it('renders dash for empty readonly value', () => {
45
- render(<DateField {...baseProps} readonly value={''} />);
46
- expect(screen.getByText('—')).toBeInTheDocument();
47
- });
48
- });
49
-
50
- describe('DateTimeField', () => {
51
- it('renders datetime-local input in edit mode', () => {
52
- const { container } = render(<DateTimeField {...baseProps} />);
53
- const input = container.querySelector('input[type="datetime-local"]');
54
- expect(input).toBeInTheDocument();
55
- });
56
-
57
- it('calls onChange when value changes', () => {
58
- const onChange = vi.fn();
59
- const { container } = render(<DateTimeField {...baseProps} onChange={onChange} />);
60
- const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
61
- fireEvent.change(input, { target: { value: '2023-01-01T12:00' } });
62
- // The control speaks zone-less local wall clock; form state holds
63
- // ISO, the shape the API hands back and `toDateTimeInputValue`
64
- // reads (objectui#3127). Storing the naive string instead would put
65
- // read and write on different timezone bases.
66
- expect(onChange).toHaveBeenCalledWith(new Date('2023-01-01T12:00').toISOString());
67
- });
68
-
69
- it('renders formatted datetime in readonly mode', () => {
70
- const val = '2023-01-01T12:00:00.000Z';
71
- render(<DateTimeField {...baseProps} readonly value={val} />);
72
- const date = new Date(val);
73
- const span = screen.getByText((content) => {
74
- return content.includes(date.toLocaleDateString());
75
- });
76
- expect(span).toBeInTheDocument();
77
- });
78
- });
79
-
80
- /**
81
- * objectui#3127 — the record's stored ISO value reached the native control
82
- * verbatim, and `<input type="date">` / `<input type="datetime-local">`
83
- * reject anything but their one exact shape SILENTLY: the attribute lands
84
- * in the DOM, `.value` reads back `''`, and the user sees an empty box on a
85
- * field that has a value. `.value` (not `getAttribute('value')`) is
86
- * therefore the only assertion that can see this bug at all.
87
- */
88
- describe('existing values round-trip through the native controls (#3127)', () => {
89
- // The seeded showcase value. Whole minutes and no sub-second part, so
90
- // the control (which has minute resolution) can represent it exactly
91
- // and the round trip below is lossless in every timezone.
92
- const storedIso = '2026-06-17T14:30:00.000Z';
93
-
94
- it('DateTimeField shows a stored ISO instant instead of an empty box', () => {
95
- const { container } = render(<DateTimeField {...baseProps} value={storedIso} />);
96
- const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
97
-
98
- expect(input.value).not.toBe('');
99
- expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
100
- // The shown wall clock denotes the SAME instant as the record —
101
- // asserted this way so the test holds in every timezone, which a
102
- // hardcoded "2026-06-17T22:30" would not.
103
- expect(new Date(input.value).getTime()).toBe(new Date(storedIso).getTime());
104
- });
105
-
106
- it('DateTimeField writes back on the same basis it reads', () => {
107
- // Read and write must share one timezone basis. Displaying local
108
- // while storing the naive local string is the worse bug: the value
109
- // would silently drift by the viewer's offset on every edit.
110
- const onChange = vi.fn();
111
- const { container } = render(
112
- <DateTimeField {...baseProps} onChange={onChange} value={storedIso} />
113
- );
114
- const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
115
-
116
- fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
117
-
118
- const written = onChange.mock.calls[0][0];
119
- // Zone-QUALIFIED, which the control's own output is not. Storing
120
- // the raw `2026-06-18T09:15` is what puts the write on a different
121
- // basis from the read: the server has no way to know whose 09:15
122
- // it is, and resolves it to a different instant than the one the
123
- // user picked.
124
- expect(written).toMatch(/(Z|[+-]\d{2}:\d{2})$/);
125
- expect(new Date(written).getTime()).toBe(new Date('2026-06-18T09:15').getTime());
126
- });
127
-
128
- it('DateTimeField returning to the shown minute restores the exact stored value', () => {
129
- // Guards the drift this fix could otherwise introduce: if the two
130
- // conversions disagreed by the viewer's UTC offset, navigating away
131
- // from a value and back would land somewhere else, and every open →
132
- // fiddle → undo would quietly move the record.
133
- // Held in real state, because a `vi.fn()` onChange would leave the
134
- // control pinned to its initial prop and React would swallow the
135
- // second edit as a no-op — the test would pass without exercising
136
- // anything.
137
- const seen: string[] = [];
138
- const Host = () => {
139
- const [v, setV] = React.useState<string>(storedIso);
140
- seen.push(v);
141
- return <DateTimeField {...baseProps} value={v} onChange={setV as any} />;
142
- };
143
- const { container } = render(<Host />);
144
- const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
145
- const shown = input.value;
146
-
147
- fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
148
- fireEvent.change(input, { target: { value: shown } });
149
-
150
- expect(seen[seen.length - 1]).toBe(storedIso);
151
- });
152
-
153
- it('DateField shows the stored calendar day, ISO-shaped or not', () => {
154
- // Both spellings the API may hand back for a `date` field. The day
155
- // must survive verbatim — re-parsing the bare form as UTC midnight
156
- // would move it to the 16th west of Greenwich.
157
- for (const stored of ['2026-06-17', '2026-06-17T00:00:00.000Z']) {
158
- const { container, unmount } = render(<DateField {...baseProps} value={stored} />);
159
- const input = container.querySelector('input[type="date"]') as HTMLInputElement;
160
- expect(input.value).toBe('2026-06-17');
161
- unmount();
162
- }
163
- });
164
- });
165
-
166
- describe('TimeField', () => {
167
- it('renders time input in edit mode', () => {
168
- const { container } = render(<TimeField {...baseProps} />);
169
- const input = container.querySelector('input[type="time"]');
170
- expect(input).toBeInTheDocument();
171
- });
172
-
173
- it('calls onChange when value changes', () => {
174
- const { container } = render(<TimeField {...baseProps} />);
175
- const input = container.querySelector('input[type="time"]') as HTMLInputElement;
176
- fireEvent.change(input, { target: { value: '14:30' } });
177
- expect(baseProps.onChange).toHaveBeenCalledWith('14:30');
178
- });
179
-
180
- it('renders value directly in readonly mode', () => {
181
- render(<TimeField {...baseProps} readonly value="14:30" />);
182
- expect(screen.getByText('14:30')).toBeInTheDocument();
183
- });
184
- });
185
- });
@@ -1,81 +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
- /**
10
- * The widget id is a function of the (type, `multiple`) PAIR, not of the type
11
- * string alone (objectui#3986).
12
- *
13
- * `mapFieldTypeToFormType` mapped `select` to `field:select` unconditionally,
14
- * while `SelectField` delegated to `MultiSelectField` whenever the metadata said
15
- * `multiple`. Three correct pieces, one gap between them: the component that
16
- * RENDERED was `MultiSelectField`, but everything keyed on the widget id — above
17
- * all the label-association declaration (`ComponentMeta.labelling`, resolved via
18
- * `getMeta('select')`) — was still answering for the single-value combobox. The
19
- * host label of a normal `{ type: 'select', multiple: true }` picklist therefore
20
- * emitted a `for` at the chip row's wrapper `div`, where a `for` is inert.
21
- *
22
- * `select` cannot simply be declared `labelling: 'group'` instead: the
23
- * single-value trigger IS labelable, and a bare `select` is a builtin the form
24
- * renderer resolves without the registry at all (pinned in
25
- * `form-group-label-association.test.ts`). The producer has to name the widget
26
- * that renders, which is what this file pins.
27
- *
28
- * Metadata only — nothing renders here, so no widget module is loaded.
29
- */
30
-
31
- import { describe, it, expect } from 'vitest';
32
- import { mapFieldTypeToFormType } from './field-type-alias';
33
-
34
- describe('select carries its arity in the widget id (objectui#3986)', () => {
35
- it('maps select + multiple to the multi-value widget', () => {
36
- // The one assertion that was `field:select` before this change.
37
- expect(mapFieldTypeToFormType('select', { multiple: true })).toBe('field:multiselect');
38
- });
39
-
40
- it('keeps the single-value widget without multiple, and for multiple: false', () => {
41
- // The guard direction: single-select must not move. Its trigger is a
42
- // labelable `button[role=combobox]` and its `for` association works — taking
43
- // that away would break a field that is fine to fix one that is not.
44
- expect(mapFieldTypeToFormType('select')).toBe('field:select');
45
- expect(mapFieldTypeToFormType('select', {})).toBe('field:select');
46
- expect(mapFieldTypeToFormType('select', { multiple: false })).toBe('field:select');
47
- expect(mapFieldTypeToFormType('select', { multiple: null })).toBe('field:select');
48
- });
49
-
50
- it('is idempotent for the type that is already inherently multi', () => {
51
- // `multiselect` is a type in its own right; the arity override must not turn
52
- // it into something else, whichever way the flag is set.
53
- expect(mapFieldTypeToFormType('multiselect')).toBe('field:multiselect');
54
- expect(mapFieldTypeToFormType('multiselect', { multiple: true })).toBe('field:multiselect');
55
- });
56
-
57
- /**
58
- * The spec's multi-capable set is larger than the widget-moving set, and the
59
- * difference is the whole reason this override is table-driven rather than a
60
- * `config.multiple` branch sprinkled per type:
61
- *
62
- * `MULTI_CAPABLE_TYPES` = select / lookup / file / image (+ `radio` on the
63
- * select branch, `user` storing like `lookup`)
64
- *
65
- * Every member except `select` renders BOTH arities inside one widget —
66
- * `LookupField`, `FileField`, `ImageField` each read `multiple` themselves — so
67
- * their widget id, and with it their `labelling` declaration, is correct for
68
- * either arity. Moving them would point at widgets that do not exist.
69
- */
70
- const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'radio'] as const;
71
-
72
- it.each(UNMOVED_MULTI_CAPABLE)('%s resolves identically with and without multiple', (type) => {
73
- expect(mapFieldTypeToFormType(type, { multiple: true })).toBe(mapFieldTypeToFormType(type));
74
- });
75
-
76
- it('does not invent a widget for an unknown type flagged multiple', () => {
77
- // The fallback is unchanged: an unknown type is `field:text` at either arity,
78
- // never `field:text-multiple`-anything.
79
- expect(mapFieldTypeToFormType('something-unknown', { multiple: true })).toBe('field:text');
80
- });
81
- });
@@ -1,156 +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
- /**
10
- * The slice of a field definition the widget decision reads beyond `type`.
11
- *
12
- * Structural (not the spec `Field`) for the same reason the spec's own
13
- * `ValueShapeFieldDef` is: every caller — object-schema metadata, a spec
14
- * `FormFieldSchema` override, an action param def — can pass its own trimmed
15
- * shape verbatim. `multiple` is the only key read; nothing else here is
16
- * consulted, so a caller that has the whole metadata object may hand it over
17
- * as-is.
18
- */
19
- export interface FieldTypeMappingConfig {
20
- /** `FieldSchema.multiple` — the field holds zero-or-more values. */
21
- multiple?: boolean | null;
22
- }
23
-
24
- /**
25
- * Field types whose `multiple: true` form is a DIFFERENT registered widget,
26
- * rather than the same widget in another mode (objectui#3986).
27
- *
28
- * `select` is the only member, and the narrowness is measured, not assumed. The
29
- * spec's `MULTI_CAPABLE_TYPES` is larger — select / lookup / file / image, with
30
- * `radio` on the select branch and `user` storing like `lookup` — but every
31
- * other member renders both arities INSIDE one widget: `LookupField`,
32
- * `FileField` and `ImageField` each branch on `multiple` themselves, so their
33
- * registry id, and with it their `labelling` declaration, is the same either
34
- * way. A `select` declared `multiple: true` renders `MultiSelectField` instead:
35
- * a different component, whose labelled surface is a chip row's container that
36
- * a `<label for>` cannot address (it must be named by IDREF, which is what
37
- * `field:multiselect`'s `labelling: 'group'` declares — objectui#3975/#3961).
38
- *
39
- * So the widget id has to carry the arity. When it did not, the host label was
40
- * associated by the declaration registered under `select` — a single-value
41
- * combobox that was NOT rendering — and the visible label of a normal
42
- * `{ type: 'select', multiple: true }` picklist named nothing at all. One place
43
- * decides which widget renders, so the declaration and the render cannot
44
- * disagree again.
45
- */
46
- const MULTI_VALUE_FORM_TYPES: Record<string, string> = {
47
- select: 'field:multiselect',
48
- };
49
-
50
- /**
51
- * Map field type to form component type
52
- *
53
- * @param fieldType - The ObjectQL field type identifier to convert
54
- * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
55
- * @param config - The rest of the field definition, for the types whose widget
56
- * identity depends on more than the type string. Only `multiple` is read; see
57
- * {@link MULTI_VALUE_FORM_TYPES}. Omitting it maps the single-value form, which
58
- * is what every non-multi-capable type resolves to anyway.
59
- * @returns The normalized form field type string used in the form schema
60
- * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
61
- */
62
- export function mapFieldTypeToFormType(
63
- fieldType: string,
64
- config?: FieldTypeMappingConfig,
65
- ): string {
66
- const typeMap: Record<string, string> = {
67
- // Text-based fields
68
- text: 'field:text',
69
- textarea: 'field:textarea',
70
- markdown: 'field:markdown', // Markdown editor (fallback to textarea)
71
- html: 'field:html', // Rich text editor (fallback to textarea)
72
- richtext: 'field:richtext', // WYSIWYG rich-text editor
73
- secret: 'field:password', // encrypted-at-rest value — mask input like a password
74
-
75
- // Numeric fields
76
- number: 'field:number',
77
- currency: 'field:currency',
78
- percent: 'field:percent',
79
- slider: 'field:slider',
80
- progress: 'field:slider', // bounded 0..100 progress — edit via slider
81
- rating: 'field:rating',
82
-
83
- // Date/Time fields
84
- date: 'field:date',
85
- datetime: 'field:datetime',
86
- time: 'field:time',
87
-
88
- // Boolean
89
- boolean: 'field:boolean',
90
- toggle: 'field:boolean', // toggle is a boolean rendered as a switch
91
-
92
- // Selection fields
93
- select: 'field:select',
94
- multiselect: 'field:multiselect',
95
- radio: 'field:radio',
96
- checkboxes: 'field:checkboxes',
97
- tags: 'field:tags',
98
- lookup: 'field:lookup',
99
- master_detail: 'field:master_detail',
100
- tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
101
- // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
102
- // ownership). Both render via the UserField person-picker (delegates to the
103
- // lookup picker). Without these they would fall through to `field:text`.
104
- user: 'field:user',
105
- owner: 'field:owner',
106
-
107
- // Contact fields
108
- email: 'field:email',
109
- phone: 'field:phone',
110
- url: 'field:url',
111
-
112
- // File / media fields
113
- file: 'field:file',
114
- image: 'field:image',
115
- avatar: 'field:avatar',
116
- video: 'field:file', // uploads as a file
117
- audio: 'field:file', // uploads as a file
118
- signature: 'field:signature',
119
-
120
- // Special / enhanced fields
121
- password: 'field:password',
122
- location: 'field:location', // Location/map field (fallback to input)
123
- geolocation: 'field:geolocation',
124
- address: 'field:address',
125
- color: 'field:color',
126
- code: 'field:code',
127
- json: 'field:code', // JSON edited in the code editor
128
- qrcode: 'field:qrcode',
129
- vector: 'field:vector',
130
-
131
- // Embedded structured values (stored as JSON on the row)
132
- object: 'field:object',
133
- composite: 'field:object', // embedded object
134
- record: 'field:object', // name-keyed map
135
- repeater: 'field:grid', // embedded array of rows
136
-
137
- // Auto-generated/computed fields (typically read-only)
138
- formula: 'field:formula',
139
- summary: 'field:summary',
140
- // `@objectstack/spec` spells this `autonumber`; the widget map key is
141
- // `auto_number`. Map both spellings so a spec-typed field/param doesn't
142
- // fall through to the plain text input.
143
- autonumber: 'field:auto_number',
144
- auto_number: 'field:auto_number',
145
- };
146
-
147
- // The arity override comes FIRST and is table-driven: only a type listed in
148
- // `MULTI_VALUE_FORM_TYPES` has a second widget to move to, so `multiple` on
149
- // any other type (a multi `lookup`, a multi `file`) resolves exactly as
150
- // before and keeps reaching the widget that handles both arities itself.
151
- if (config?.multiple && MULTI_VALUE_FORM_TYPES[fieldType]) {
152
- return MULTI_VALUE_FORM_TYPES[fieldType];
153
- }
154
-
155
- return typeMap[fieldType] || 'field:text';
156
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * Regression guard for the field-type → renderer registries. These mappings
6
- * regressed once already: ~25 specialized field types silently fell back to a
7
- * plain text input (form) and the raw text cell renderer (read), because the
8
- * mapping tables were incomplete. This test locks them in so a dropped entry
9
- * fails CI instead of shipping a broken widget.
10
- */
11
- import { describe, it, expect } from 'vitest';
12
- import { mapFieldTypeToFormType, getCellRenderer, TextCellRenderer } from './index';
13
-
14
- /**
15
- * Field types that MUST resolve to a dedicated form widget — never the
16
- * `field:text` fallback. (Plain text-ish types like text/textarea are
17
- * intentionally excluded.)
18
- */
19
- const FORM_WIDGET_TYPES = [
20
- 'textarea', 'email', 'url', 'phone', 'password', 'secret',
21
- 'markdown', 'html', 'richtext',
22
- 'number', 'currency', 'percent', 'slider', 'progress', 'rating',
23
- 'date', 'datetime', 'time',
24
- 'boolean', 'toggle',
25
- 'select', 'multiselect', 'radio', 'checkboxes', 'tags',
26
- 'lookup', 'master_detail', 'tree',
27
- 'file', 'image', 'avatar', 'video', 'audio', 'signature',
28
- 'location', 'geolocation', 'address', 'color', 'code', 'json', 'qrcode', 'vector',
29
- 'object', 'composite', 'record', 'repeater',
30
- // `autonumber` is the spec `FieldType` spelling; `auto_number` is the widget
31
- // map key. Both must resolve to the AutoNumber widget, not text.
32
- 'formula', 'summary', 'auto_number', 'autonumber',
33
- ];
34
-
35
- /**
36
- * Field types that MUST resolve to a dedicated read/cell renderer — never the
37
- * generic text cell renderer. (markdown/html/richtext render formatted;
38
- * code/qrcode/time legitimately stay textual and are excluded.)
39
- */
40
- const CELL_RENDERER_TYPES = [
41
- 'email', 'url', 'phone', 'number', 'currency', 'percent', 'progress',
42
- 'boolean', 'toggle', 'date', 'datetime',
43
- 'select', 'multiselect', 'radio', 'checkboxes', 'tags',
44
- 'lookup', 'master_detail', 'tree',
45
- 'file', 'video', 'audio', 'image', 'avatar', 'signature',
46
- 'markdown', 'html', 'richtext',
47
- 'location', 'geolocation', 'address', 'color', 'json',
48
- 'formula', 'summary', 'user', 'owner',
49
- ];
50
-
51
- describe('field-type renderer coverage (regression guard)', () => {
52
- it.each(FORM_WIDGET_TYPES)('maps "%s" to a dedicated form widget (not field:text)', (type) => {
53
- const widget = mapFieldTypeToFormType(type);
54
- expect(widget).toMatch(/^field:/);
55
- expect(widget).not.toBe('field:text');
56
- });
57
-
58
- it.each(CELL_RENDERER_TYPES)('resolves "%s" to a dedicated cell renderer (not TextCellRenderer)', (type) => {
59
- const renderer = getCellRenderer(type);
60
- expect(renderer).toBeTypeOf('function');
61
- expect(renderer).not.toBe(TextCellRenderer);
62
- });
63
-
64
- it('falls back to field:text for unknown types', () => {
65
- expect(mapFieldTypeToFormType('something-unknown')).toBe('field:text');
66
- });
67
- });