@object-ui/fields 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
package/src/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
- });