@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
@@ -1,230 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import {
3
- isFileIdToken,
4
- fileIdOf,
5
- readFileValue,
6
- readFileValues,
7
- isImageValue,
8
- fileValueForSubmit,
9
- uploadResultView,
10
- withRecentUploads,
11
- } from './file-value';
12
-
13
- const uploadResult = (over: Record<string, unknown> = {}) => ({
14
- url: 'https://app.example.com/api/v1/storage/files/file_a',
15
- name: 'a.png',
16
- size: 1024,
17
- mimeType: 'image/png',
18
- ...over,
19
- });
20
-
21
- describe('isFileIdToken', () => {
22
- it.each(['file_a', 'abc123', '0e2f4c1a-9b7d-4e3f-8a1b-2c3d4e5f6a7b', 'A-_9'])(
23
- 'accepts the minted id %s',
24
- (v) => expect(isFileIdToken(v)).toBe(true),
25
- );
26
-
27
- it.each([
28
- 'https://cdn.example.com/a.png',
29
- '/api/v1/storage/files/file_a',
30
- 'data:image/png;base64,aGk=',
31
- 'blob:http://localhost/abc',
32
- 'a.png',
33
- '',
34
- ])('rejects the URL-shaped or dotted value %s', (v) => expect(isFileIdToken(v)).toBe(false));
35
-
36
- it('rejects non-strings and over-long values', () => {
37
- expect(isFileIdToken(null)).toBe(false);
38
- expect(isFileIdToken(42)).toBe(false);
39
- expect(isFileIdToken('a'.repeat(65))).toBe(false);
40
- });
41
- });
42
-
43
- describe('fileIdOf', () => {
44
- it('reads a bare reference', () => {
45
- expect(fileIdOf('file_a')).toBe('file_a');
46
- });
47
-
48
- it('prefers file_id, then id', () => {
49
- expect(fileIdOf({ file_id: 'legacy', id: 'expanded' })).toBe('legacy');
50
- expect(fileIdOf({ id: 'expanded' })).toBe('expanded');
51
- });
52
-
53
- it('returns undefined when there is no id to read', () => {
54
- expect(fileIdOf({ url: 'https://cdn.example.com/a.png', name: 'a.png' })).toBeUndefined();
55
- expect(fileIdOf('https://cdn.example.com/a.png')).toBeUndefined();
56
- expect(fileIdOf(null)).toBeUndefined();
57
- });
58
- });
59
-
60
- describe('readFileValue', () => {
61
- /**
62
- * The casing split is the reason this module exists: the platform's expanded
63
- * form is camelCase `mimeType`, the legacy inline blob is snake_case
64
- * `mime_type`. A widget reading only one silently stops recognising images
65
- * the moment the backend switches form.
66
- */
67
- it('reads the expanded form (camelCase mimeType)', () => {
68
- const view = readFileValue({
69
- id: 'file_a',
70
- name: 'a.png',
71
- size: 1024,
72
- mimeType: 'image/png',
73
- url: '/api/v1/storage/files/file_a',
74
- });
75
-
76
- expect(view).toMatchObject({
77
- id: 'file_a',
78
- name: 'a.png',
79
- size: 1024,
80
- mimeType: 'image/png',
81
- url: '/api/v1/storage/files/file_a',
82
- });
83
- expect(isImageValue(view)).toBe(true);
84
- });
85
-
86
- it('reads the legacy inline blob (snake_case mime_type)', () => {
87
- const view = readFileValue({
88
- file_id: 'file_a',
89
- name: 'a.png',
90
- original_name: 'original.png',
91
- size: 1024,
92
- mime_type: 'image/png',
93
- url: 'https://cdn.example.com/a.png',
94
- });
95
-
96
- expect(view).toMatchObject({ id: 'file_a', name: 'a.png', mimeType: 'image/png' });
97
- expect(isImageValue(view)).toBe(true);
98
- });
99
-
100
- it('falls back to original_name when name is absent', () => {
101
- expect(readFileValue({ original_name: 'orig.pdf' }).name).toBe('orig.pdf');
102
- });
103
-
104
- it('derives a name from the URL when the value carries none', () => {
105
- expect(readFileValue({ url: 'https://cdn.example.com/docs/report%20v2.pdf' }).name).toBe(
106
- 'report v2.pdf',
107
- );
108
- expect(readFileValue('https://cdn.example.com/a.png').name).toBe('a.png');
109
- });
110
-
111
- it('resolves a bare reference to the stable download URL', () => {
112
- const view = readFileValue('file_a', 'File');
113
-
114
- // The read path may leave a value in its bare-reference form (seen on the
115
- // edit-form data path); the stable endpoint is derivable from the id, so a
116
- // thumbnail still renders instead of a broken `<img src="">`.
117
- expect(view).toMatchObject({ id: 'file_a', name: 'File' });
118
- expect(view.url).toBe('/api/v1/storage/files/file_a');
119
- });
120
-
121
- it('derives the URL of an object that carries an id but no url', () => {
122
- const view = readFileValue({ id: 'file_b', name: 'b.png' });
123
- expect(view.url).toBe('/api/v1/storage/files/file_b');
124
- });
125
-
126
- it('treats a URL string as a URL, never as a reference', () => {
127
- const view = readFileValue('/api/v1/storage/files/file_a');
128
- expect(view.id).toBeUndefined();
129
- expect(view.url).toBe('/api/v1/storage/files/file_a');
130
- });
131
-
132
- it('uses the supplied fallback name so widgets stay localised', () => {
133
- expect(readFileValue({}, '文件').name).toBe('文件');
134
- expect(readFileValue(null, '文件').name).toBe('文件');
135
- });
136
-
137
- it('is not an image when the MIME type says otherwise or is missing', () => {
138
- expect(isImageValue(readFileValue({ mimeType: 'application/pdf' }))).toBe(false);
139
- expect(isImageValue(readFileValue({ name: 'a.png' }))).toBe(false);
140
- });
141
- });
142
-
143
- describe('readFileValues', () => {
144
- it('normalises single, array and empty values', () => {
145
- expect(readFileValues(null)).toEqual([]);
146
- expect(readFileValues([])).toEqual([]);
147
- expect(readFileValues('file_a')).toHaveLength(1);
148
- expect(readFileValues(['file_a', 'file_b'])).toHaveLength(2);
149
- });
150
-
151
- it('drops null entries inside an array', () => {
152
- expect(readFileValues(['file_a', null, 'file_b'])).toHaveLength(2);
153
- });
154
-
155
- it('handles a mixed-form array during the transition', () => {
156
- const views = readFileValues([
157
- 'file_a',
158
- { id: 'file_b', name: 'b.png', mimeType: 'image/png' },
159
- { name: 'c.png', mime_type: 'image/png', url: 'https://cdn.example.com/c.png' },
160
- ]);
161
-
162
- expect(views.map((v) => v.id)).toEqual(['file_a', 'file_b', undefined]);
163
- expect(views.map(isImageValue)).toEqual([false, true, true]);
164
- });
165
- });
166
-
167
- describe('fileValueForSubmit', () => {
168
- it('submits the bare reference when the adapter surfaced a fileId', () => {
169
- expect(fileValueForSubmit(uploadResult({ meta: { fileId: 'file_a' } }), 'orig.png')).toBe(
170
- 'file_a',
171
- );
172
- });
173
-
174
- /**
175
- * Back-compat: the object-URL fallback adapter, or a backend predating
176
- * file-as-reference, surfaces no fileId. The same build must keep working
177
- * there, so it submits the legacy blob unchanged.
178
- */
179
- it('falls back to the legacy inline blob when there is no fileId', () => {
180
- expect(fileValueForSubmit(uploadResult(), 'orig.png')).toEqual({
181
- name: 'a.png',
182
- original_name: 'orig.png',
183
- size: 1024,
184
- mime_type: 'image/png',
185
- url: 'https://app.example.com/api/v1/storage/files/file_a',
186
- });
187
- });
188
-
189
- it('ignores a meta.fileId that is not id-shaped', () => {
190
- const v = fileValueForSubmit(uploadResult({ meta: { fileId: 'https://evil.example/x' } }));
191
- expect(typeof v).toBe('object');
192
- });
193
-
194
- it('round-trips through readFileValue in both modes', () => {
195
- const asRef = fileValueForSubmit(uploadResult({ meta: { fileId: 'file_a' } }));
196
- const asBlob = fileValueForSubmit(uploadResult(), 'orig.png');
197
-
198
- expect(readFileValue(asRef).id).toBe('file_a');
199
- expect(readFileValue(asBlob).mimeType).toBe('image/png');
200
- });
201
- });
202
-
203
- describe('withRecentUploads', () => {
204
- it('fills in display details for a bare reference just uploaded', () => {
205
- const view = uploadResultView(uploadResult({ meta: { fileId: 'file_a' } }), 'orig.png');
206
- const merged = withRecentUploads(readFileValues('file_a'), { file_a: view });
207
-
208
- expect(merged[0]).toMatchObject({
209
- id: 'file_a',
210
- name: 'a.png',
211
- mimeType: 'image/png',
212
- url: 'https://app.example.com/api/v1/storage/files/file_a',
213
- });
214
- // The raw value stays the reference — display enrichment must not change
215
- // what the field submits.
216
- expect(merged[0].raw).toBe('file_a');
217
- });
218
-
219
- it('leaves a value the backend already expanded alone', () => {
220
- const stale = uploadResultView(uploadResult({ name: 'stale.png', meta: { fileId: 'file_a' } }));
221
- const expanded = readFileValues([{ id: 'file_a', name: 'fresh.png', url: '/u', mimeType: 'image/png' }]);
222
-
223
- expect(withRecentUploads(expanded, { file_a: stale })[0].name).toBe('fresh.png');
224
- });
225
-
226
- it('is a no-op with nothing remembered', () => {
227
- const views = readFileValues('file_a');
228
- expect(withRecentUploads(views, {})).toBe(views);
229
- });
230
- });
@@ -1,246 +0,0 @@
1
- /**
2
- * File field value shapes (ObjectStack ADR-0104 D3 wave 2).
3
- *
4
- * A `file`/`image`/`avatar`/`video`/`audio` field value reaches the UI in one
5
- * of three forms, and every widget that renders one has to cope with all
6
- * three — so the rules live here once rather than being re-derived per widget.
7
- *
8
- * 1. **Reference** — a bare `sys_file` id string. The form the backend stores
9
- * once file-as-reference is adopted.
10
- * 2. **Expanded** — `{ id, name, size, mimeType, url }`, what the read path
11
- * returns after resolving a stored reference. Note `mimeType`: this comes
12
- * from the platform's spec-owned `FileValueSchema` and is **camelCase**.
13
- * 3. **Legacy inline blob** — `{ file_id?, name, original_name, size,
14
- * mime_type, url }`, the pre-reference shape this package used to build
15
- * itself, with **snake_case** `mime_type`.
16
- *
17
- * That casing split is the whole reason this module exists: a widget reading
18
- * only `mime_type` silently stops recognising images the moment the backend
19
- * starts returning the expanded form, and the failure looks like "thumbnails
20
- * disappeared" rather than anything pointing at a value shape.
21
- *
22
- * ## Submitting
23
- *
24
- * When the upload adapter surfaced a `fileId`, the widget submits the **bare
25
- * id** — the reference form. When it did not (the object-URL fallback adapter,
26
- * an older backend), it submits the legacy blob unchanged. So the same build
27
- * works against a backend that has adopted references and one that has not.
28
- * Action params already POST a bare fileId; this brings record field values
29
- * onto the same contract.
30
- */
31
-
32
- import { isFileIdToken } from '@objectstack/spec/data';
33
-
34
- /** A file value normalised for rendering, whatever form it arrived in. */
35
- export interface FileValueView {
36
- /** `sys_file` id, when the value carries one. */
37
- id?: string;
38
- /** Best available display name. Never empty. */
39
- name: string;
40
- /** Resolvable URL, when the value carries one. A bare reference does not. */
41
- url?: string;
42
- size?: number;
43
- mimeType?: string;
44
- /** The value as it arrived, so callers can pass it through untouched. */
45
- raw: unknown;
46
- }
47
-
48
- /**
49
- * A minted file id: uuid/nanoid-shaped, and crucially not a URL — a URL always
50
- * carries `:`, `/` or `.`, so it can never match.
51
- *
52
- * This IS the platform's arbiter now, not a mirror of it (objectui#3161,
53
- * objectstack#4115 ledger batch 7). The declaration that stood here said it
54
- * "mirrors the platform's `isFileIdToken`" while being a character-for-character
55
- * copy of that function's body under that function's own name — the shape
56
- * objectui#3003 argued about and objectui#3169 caught again in
57
- * `isAggregatedViewContainer`: every value test and every behaviour test passes
58
- * against a faithful copy, so reference identity is the only check that can tell
59
- * a re-export from a fork. It is asserted in
60
- * `__tests__/spec-symbol-batch7.test.ts`.
61
- *
62
- * Why it matters that this one is shared rather than duplicated: the regex is a
63
- * WIRE decision. Widening it server-side (say, ids grow past 64 chars) while a
64
- * copy here keeps the old bound turns every new id into "not a reference", and
65
- * the widget then submits the legacy inline blob against a backend that expects
66
- * a reference. That failure surfaces as a broken thumbnail, nowhere near a
67
- * regex.
68
- */
69
- export { isFileIdToken };
70
-
71
- /**
72
- * The `sys_file` id a value refers to, or `undefined`.
73
- *
74
- * `file_id ?? id` matches the rule `serializeParamValues` already applies to
75
- * action params — one extraction rule for both surfaces.
76
- */
77
- export function fileIdOf(value: unknown): string | undefined {
78
- if (isFileIdToken(value)) return value;
79
- if (value && typeof value === 'object') {
80
- const o = value as Record<string, unknown>;
81
- const id = o.file_id ?? o.id;
82
- if (typeof id === 'string' && id) return id;
83
- }
84
- return undefined;
85
- }
86
-
87
- /**
88
- * The stable download endpoint a bare `sys_file` id resolves to.
89
- *
90
- * `createObjectStackUploadAdapter` stores exactly this URL (`${basePath}/files/
91
- * :id`, default base `/api/v1/storage`) as a completed upload's value, and the
92
- * endpoint 302-redirects to a freshly-signed short-lived URL on every request —
93
- * so it can be used directly as an `<img src>`. Building it here means a value
94
- * still in its bare-reference form (the backend read path didn't expand it —
95
- * seen on the edit-form data path, objectui image thumbnails rendering broken)
96
- * resolves to a real URL instead of an empty `src`.
97
- */
98
- export const FILE_STORAGE_BASE_PATH = '/api/v1/storage';
99
-
100
- /** The stable download URL for a `sys_file` id. */
101
- export function fileUrlFromId(id: string): string {
102
- return `${FILE_STORAGE_BASE_PATH}/files/${encodeURIComponent(id)}`;
103
- }
104
-
105
- /** Last path segment of a URL, used as a display name of last resort. */
106
- function nameFromUrl(url: string): string {
107
- const path = url.split(/[?#]/)[0] ?? url;
108
- const seg = path.split('/').filter(Boolean).pop();
109
- return seg ? decodeURIComponent(seg) : url;
110
- }
111
-
112
- /**
113
- * Normalise one file value for display.
114
- *
115
- * @param fallbackName shown when the value carries no usable name — pass a
116
- * translated string so the widget stays localised.
117
- */
118
- export function readFileValue(value: unknown, fallbackName = 'File'): FileValueView {
119
- if (value == null) return { name: fallbackName, raw: value };
120
-
121
- if (typeof value === 'string') {
122
- // A bare id carries no name of its own (the backend expands it on read),
123
- // but its URL is derivable from the stable download endpoint — so a value
124
- // the read path left as a bare reference still renders a thumbnail instead
125
- // of a broken `<img src="">`.
126
- if (isFileIdToken(value)) return { id: value, name: fallbackName, url: fileUrlFromId(value), raw: value };
127
- // Otherwise it is a URL (legacy external link, data:, blob:).
128
- return { url: value, name: nameFromUrl(value), raw: value };
129
- }
130
-
131
- if (typeof value === 'object') {
132
- const o = value as Record<string, unknown>;
133
- const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
134
- const id = fileIdOf(o);
135
- // An object shape that carries an id but not its URL (a partially-expanded
136
- // reference) still resolves to the stable endpoint, same as a bare id.
137
- const url = str(o.url) ?? (id ? fileUrlFromId(id) : undefined);
138
- const name =
139
- str(o.name) ??
140
- str(o.original_name) ??
141
- (str(o.url) ? nameFromUrl(str(o.url) as string) : undefined) ??
142
- fallbackName;
143
- return {
144
- id,
145
- name,
146
- url,
147
- size: typeof o.size === 'number' ? o.size : undefined,
148
- // Expanded form is camelCase, legacy blob is snake_case — accept both.
149
- mimeType: str(o.mimeType) ?? str(o.mime_type),
150
- raw: value,
151
- };
152
- }
153
-
154
- return { name: fallbackName, raw: value };
155
- }
156
-
157
- /** Normalise a field value (single or `multiple`) to an array of views. */
158
- export function readFileValues(value: unknown, fallbackName = 'File'): FileValueView[] {
159
- if (value == null) return [];
160
- const items = Array.isArray(value) ? value : [value];
161
- return items.filter((v) => v != null).map((v) => readFileValue(v, fallbackName));
162
- }
163
-
164
- /** Does this value look like an image, by MIME type? */
165
- export function isImageValue(view: FileValueView): boolean {
166
- return (view.mimeType ?? '').startsWith('image/');
167
- }
168
-
169
- /** The subset of an upload result these helpers read. */
170
- export interface UploadResultLike {
171
- url: string;
172
- name: string;
173
- size: number;
174
- mimeType: string;
175
- meta?: Record<string, unknown>;
176
- }
177
-
178
- /**
179
- * What to store in the field for a completed upload.
180
- *
181
- * Returns the bare `sys_file` id when the adapter surfaced one — the reference
182
- * form — and the legacy inline blob when it did not, so a deployment whose
183
- * upload adapter or backend predates file-as-reference keeps working unchanged.
184
- */
185
- export function fileValueForSubmit(
186
- result: UploadResultLike,
187
- originalName?: string,
188
- ): string | Record<string, unknown> {
189
- const fileId = (result.meta as { fileId?: unknown } | undefined)?.fileId;
190
- if (isFileIdToken(fileId)) return fileId;
191
- return {
192
- name: result.name,
193
- original_name: originalName ?? result.name,
194
- size: result.size,
195
- mime_type: result.mimeType,
196
- url: result.url,
197
- };
198
- }
199
-
200
- /**
201
- * The display view of a just-completed upload.
202
- *
203
- * Submitting a bare id means the field value no longer carries the name, size
204
- * or URL needed to render it, and the enriched form only comes back on the next
205
- * read. Widgets keep these views keyed by id so an upload appears immediately
206
- * instead of showing a bare token until a refetch.
207
- */
208
- export function uploadResultView(result: UploadResultLike, originalName?: string): FileValueView {
209
- const id = isFileIdToken((result.meta as { fileId?: unknown } | undefined)?.fileId)
210
- ? ((result.meta as { fileId?: string }).fileId as string)
211
- : undefined;
212
- return {
213
- id,
214
- name: result.name || originalName || 'File',
215
- url: result.url,
216
- size: result.size,
217
- mimeType: result.mimeType,
218
- raw: result,
219
- };
220
- }
221
-
222
- /**
223
- * Merge locally-known upload views over a field value's own views, matched by
224
- * id. A value that is still a bare reference picks up the name/URL captured at
225
- * upload time; anything already enriched by the backend is left alone.
226
- */
227
- export function withRecentUploads(
228
- views: FileValueView[],
229
- recent: Record<string, FileValueView>,
230
- ): FileValueView[] {
231
- if (Object.keys(recent).length === 0) return views;
232
- return views.map((v) => {
233
- const known = v.id ? recent[v.id] : undefined;
234
- if (!known) return v;
235
- // A value the backend already expanded carries its own URL on the raw
236
- // object — leave that alone. A bare reference (string id, or an id-only
237
- // object) has only the synthetic download URL `readFileValue` derived, so
238
- // the just-uploaded name/mimeType still fills in.
239
- const rawHasOwnUrl =
240
- !!v.raw &&
241
- typeof v.raw === 'object' &&
242
- typeof (v.raw as Record<string, unknown>).url === 'string' &&
243
- !!(v.raw as Record<string, unknown>).url;
244
- return rawHasOwnUrl ? v : { ...known, raw: v.raw };
245
- });
246
- }
@@ -1,115 +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 { describe, it, expect } from 'vitest';
10
- import { InstantValueSchema } from '@objectstack/spec/data';
11
- import {
12
- toDateInputValue,
13
- toDateTimeInputValue,
14
- fromDateTimeInputValue,
15
- } from './nativeDateValue';
16
-
17
- /**
18
- * These assertions are deliberately written to hold in EVERY timezone, because
19
- * the bug they pin (objectui#3127) is a timezone-shaped one and a test that
20
- * only passes in UTC would go green on CI while the defect it guards is live
21
- * for every user east or west of it.
22
- *
23
- * The trick: never hardcode the expected wall clock. Assert the SHAPE the
24
- * native control demands, and assert that reading that shape back (as local
25
- * time, which is how both the browser and `new Date` read a zone-less
26
- * datetime) lands on the same INSTANT we started from.
27
- */
28
-
29
- const localInstantOf = (bare: string) => new Date(bare).getTime();
30
-
31
- describe('toDateInputValue', () => {
32
- it('keeps a leading YYYY-MM-DD verbatim, in any timezone', () => {
33
- // Re-parsing would read this as UTC midnight and hand back the 16th
34
- // anywhere west of Greenwich. The calendar day is the value here.
35
- expect(toDateInputValue('2026-06-17')).toBe('2026-06-17');
36
- expect(toDateInputValue('2026-06-17T14:30:00.000Z')).toBe('2026-06-17');
37
- });
38
-
39
- it('formats a Date to the local calendar day', () => {
40
- const d = new Date(2026, 5, 17, 23, 59);
41
- expect(toDateInputValue(d)).toBe('2026-06-17');
42
- });
43
-
44
- it('maps empty and unparseable input to an empty control', () => {
45
- expect(toDateInputValue(null)).toBe('');
46
- expect(toDateInputValue(undefined)).toBe('');
47
- expect(toDateInputValue('')).toBe('');
48
- expect(toDateInputValue('not a date')).toBe('');
49
- });
50
- });
51
-
52
- describe('toDateTimeInputValue', () => {
53
- it('renders a UTC ISO instant as a local wall clock the control accepts', () => {
54
- const iso = '2026-06-17T14:30:00.000Z';
55
- const shown = toDateTimeInputValue(iso);
56
-
57
- // 1. The shape `datetime-local` demands — the whole point of #3127.
58
- expect(shown).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
59
- // 2. …denoting the SAME instant the record holds.
60
- expect(localInstantOf(shown)).toBe(new Date(iso).getTime());
61
- });
62
-
63
- it('truncates an already-local datetime to minutes without re-parsing it', () => {
64
- expect(toDateTimeInputValue('2026-06-17T14:30')).toBe('2026-06-17T14:30');
65
- expect(toDateTimeInputValue('2026-06-17T14:30:59')).toBe('2026-06-17T14:30');
66
- expect(toDateTimeInputValue('2026-06-17T14:30:59.123')).toBe('2026-06-17T14:30');
67
- });
68
-
69
- it('maps empty and unparseable input to an empty control', () => {
70
- expect(toDateTimeInputValue(null)).toBe('');
71
- expect(toDateTimeInputValue(undefined)).toBe('');
72
- expect(toDateTimeInputValue('')).toBe('');
73
- expect(toDateTimeInputValue('not a date')).toBe('');
74
- });
75
- });
76
-
77
- describe('fromDateTimeInputValue', () => {
78
- it('reads the control back on the same basis it was written', () => {
79
- const local = '2026-06-17T14:30';
80
- const stored = fromDateTimeInputValue(local);
81
-
82
- expect(stored).toMatch(/Z$/);
83
- expect(new Date(stored).getTime()).toBe(localInstantOf(local));
84
- });
85
-
86
- it('round-trips an ISO instant through the control unchanged', () => {
87
- // The invariant that makes the read/write pair safe: opening an editor and
88
- // re-picking the same minute must not move the record.
89
- const iso = '2026-06-17T14:30:00.000Z';
90
- expect(fromDateTimeInputValue(toDateTimeInputValue(iso))).toBe(iso);
91
- });
92
-
93
- it('returns an unparseable value untouched rather than blanking it', () => {
94
- expect(fromDateTimeInputValue('garbage')).toBe('garbage');
95
- expect(fromDateTimeInputValue('')).toBe('');
96
- });
97
-
98
- /**
99
- * The producer side of objectstack#5061. This function's output is not just
100
- * "an ISO string" by convention — it IS the platform's `datetime` value
101
- * contract, and `ActionParamDialog` now relies on that: it POSTs the widget's
102
- * value with no conversion of its own, so a change here that dropped the zone
103
- * (or emitted the control's naive wall clock) would make every datetime action
104
- * param 400 again. Pinned against the real schema rather than a regex so the
105
- * two cannot drift.
106
- */
107
- it('emits a value the platform datetime contract accepts (InstantValueSchema)', () => {
108
- expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30')).success).toBe(true);
109
- // The control may include seconds; those must survive into the instant.
110
- expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30:45')).success).toBe(true);
111
- // The shape the contract rejects — what the control itself hands us, and so
112
- // exactly what must NOT come back out of this function.
113
- expect(InstantValueSchema.safeParse('2026-06-17T14:30').success).toBe(false);
114
- });
115
- });