@object-ui/fields 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. package/src/withFieldCarrier.tsx +0 -75
@@ -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,18 +0,0 @@
1
- /**
2
- * Open the browser's native picker (date / datetime-local / time) for an input.
3
- *
4
- * Native date/time inputs only open their picker when the user clicks the small
5
- * calendar/clock icon. Calling `showPicker()` on click makes the whole field
6
- * behave like other widgets — clicking anywhere in the box opens the picker.
7
- *
8
- * `showPicker()` can throw (unsupported browser, not user-activated, or the input
9
- * is disabled/hidden), so failures are swallowed and the native fallback stands.
10
- */
11
- export function openNativePicker(input: HTMLInputElement): void {
12
- if (input.disabled || input.readOnly) return;
13
- try {
14
- (input as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
15
- } catch {
16
- // Ignore — browsers throw when the picker can't be shown; the icon still works.
17
- }
18
- }
@@ -1,113 +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 {
11
- resolvePath,
12
- getPersonName,
13
- getPersonInitials,
14
- getPersonSubtitle,
15
- getPersonAvatarUrl,
16
- getPersonId,
17
- matchRanges,
18
- } from './personDisplay';
19
-
20
- describe('personDisplay helpers', () => {
21
- describe('resolvePath', () => {
22
- it('reads flat and dotted paths', () => {
23
- const rec = { name: 'Amy', primary_business_unit_id: { name: 'Sales' } };
24
- expect(resolvePath(rec, 'name')).toBe('Amy');
25
- expect(resolvePath(rec, 'primary_business_unit_id.name')).toBe('Sales');
26
- });
27
- it('returns undefined for missing paths or nullish records', () => {
28
- expect(resolvePath({ a: {} }, 'a.b.c')).toBeUndefined();
29
- expect(resolvePath(null, 'x')).toBeUndefined();
30
- expect(resolvePath({ a: 1 }, '')).toBeUndefined();
31
- });
32
- });
33
-
34
- describe('getPersonName', () => {
35
- it('prefers the display field, then common fallbacks', () => {
36
- expect(getPersonName({ name: 'Amy' })).toBe('Amy');
37
- expect(getPersonName({ full_name: 'Amy Lin' }, 'full_name')).toBe('Amy Lin');
38
- expect(getPersonName({ username: 'amy' })).toBe('amy');
39
- expect(getPersonName('raw-id')).toBe('raw-id');
40
- expect(getPersonName(null)).toBe('');
41
- });
42
- });
43
-
44
- describe('getPersonInitials', () => {
45
- it('takes first+last initial for multi-word latin names', () => {
46
- expect(getPersonInitials('John Doe')).toBe('JD');
47
- expect(getPersonInitials('mary jane watson')).toBe('MW');
48
- });
49
- it('takes first two letters for a single latin token', () => {
50
- expect(getPersonInitials('John')).toBe('JO');
51
- });
52
- it('takes the given-name chars for CJK names', () => {
53
- expect(getPersonInitials('张三')).toBe('张三');
54
- expect(getPersonInitials('王小明')).toBe('小明');
55
- });
56
- it('falls back to "?" for empty', () => {
57
- expect(getPersonInitials('')).toBe('?');
58
- expect(getPersonInitials(' ')).toBe('?');
59
- });
60
- });
61
-
62
- describe('getPersonSubtitle', () => {
63
- it('joins non-empty resolved fields with a middot', () => {
64
- const rec = { email: 'amy@x.io', primary_business_unit_id: { name: 'Sales' } };
65
- expect(getPersonSubtitle(rec, ['primary_business_unit_id.name', 'email'])).toBe(
66
- 'Sales · amy@x.io',
67
- );
68
- });
69
- it('drops empty segments and returns "" when nothing resolves', () => {
70
- expect(getPersonSubtitle({ email: 'amy@x.io' }, ['primary_business_unit_id.name', 'email'])).toBe(
71
- 'amy@x.io',
72
- );
73
- expect(getPersonSubtitle({}, ['a', 'b'])).toBe('');
74
- expect(getPersonSubtitle({ a: 1 }, undefined)).toBe('');
75
- });
76
- });
77
-
78
- describe('getPersonAvatarUrl', () => {
79
- it('reads the avatar field, undefined when absent', () => {
80
- expect(getPersonAvatarUrl({ image: 'http://x/y.png' })).toBe('http://x/y.png');
81
- expect(getPersonAvatarUrl({})).toBeUndefined();
82
- expect(getPersonAvatarUrl({ photo: 'p' }, 'photo')).toBe('p');
83
- });
84
- });
85
-
86
- describe('getPersonId', () => {
87
- it('tolerates id / _id / custom / primitive', () => {
88
- expect(getPersonId({ id: 1 })).toBe(1);
89
- expect(getPersonId({ _id: 'a' })).toBe('a');
90
- expect(getPersonId({ user_id: 9 }, 'user_id')).toBe(9);
91
- expect(getPersonId('plain')).toBe('plain');
92
- });
93
- });
94
- });
95
-
96
- describe('matchRanges', () => {
97
- it('returns no ranges for an empty query or empty text', () => {
98
- expect(matchRanges('Amy Lin', '')).toEqual([]);
99
- expect(matchRanges('', 'amy')).toEqual([]);
100
- });
101
-
102
- it('finds case-insensitive substring ranges', () => {
103
- expect(matchRanges('Amy Lin', 'am')).toEqual([[0, 2]]);
104
- expect(matchRanges('Sales · amy@x.io', 'amy')).toEqual([[8, 11]]);
105
- });
106
-
107
- it('finds every non-overlapping occurrence', () => {
108
- expect(matchRanges('banana', 'an')).toEqual([
109
- [1, 3],
110
- [3, 5],
111
- ]);
112
- });
113
- });
@@ -1,92 +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
- * Pure display helpers for person (sys_user) records, shared by the
11
- * search-first PeoplePicker's rich rows, the SelectionTray chips, and the
12
- * read-only user cell renderer. Kept framework-free so they're trivially
13
- * unit-testable and reusable by a future org-tree tier.
14
- */
15
-
16
- /** Resolve a possibly-dotted path (e.g. `primary_business_unit_id.name`) against a record. */
17
- export function resolvePath(record: any, path: string): any {
18
- if (!record || !path) return undefined;
19
- if (!path.includes('.')) return record[path];
20
- return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), record);
21
- }
22
-
23
- /** Best display name for a person record, tolerating expanded / raw shapes. */
24
- export function getPersonName(record: any, nameField = 'name'): string {
25
- if (record == null) return '';
26
- if (typeof record !== 'object') return String(record);
27
- return String(record[nameField] ?? record.name ?? record.username ?? record.label ?? '');
28
- }
29
-
30
- /**
31
- * Up-to-2-char initials for the avatar fallback.
32
- * - "John Doe" → "JD"; "John" → "JO"
33
- * - CJK single token → trailing 1–2 chars (given name): "张三" → "张三", "王小明" → "小明"
34
- */
35
- export function getPersonInitials(name: string): string {
36
- const trimmed = (name || '').trim();
37
- if (!trimmed) return '?';
38
- const parts = trimmed.split(/\s+/).filter(Boolean);
39
- if (parts.length >= 2) {
40
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
41
- }
42
- const token = parts[0];
43
- if (/[㐀-鿿]/.test(token)) {
44
- return token.length <= 2 ? token : token.slice(-2);
45
- }
46
- return token.slice(0, 2).toUpperCase();
47
- }
48
-
49
- /** Secondary line: resolve each field path, drop empties, join with " · ". */
50
- export function getPersonSubtitle(record: any, fields?: string[]): string {
51
- if (!record || !fields || fields.length === 0) return '';
52
- return fields
53
- .map(f => resolvePath(record, f))
54
- .filter(v => v != null && v !== '')
55
- .map(v => String(v))
56
- .join(' · ');
57
- }
58
-
59
- /** Avatar image URL (defaults to `sys_user.image`); undefined when absent. */
60
- export function getPersonAvatarUrl(record: any, avatarField = 'image'): string | undefined {
61
- const v = record ? resolvePath(record, avatarField) : undefined;
62
- return v ? String(v) : undefined;
63
- }
64
-
65
- /** Record id, tolerating `id` / `_id` / a custom id field. */
66
- export function getPersonId(record: any, idField = 'id'): any {
67
- if (!record || typeof record !== 'object') return record;
68
- return record[idField] ?? record.id ?? record._id;
69
- }
70
-
71
- /**
72
- * Case-insensitive literal-substring match ranges of `query` in `text`, as
73
- * `[start, end)` index pairs. Used to highlight the typed term in candidate
74
- * rows — a strong disambiguation aid for same-named people. Pinyin matches are
75
- * resolved server-side and won't have client ranges; that's fine, the row still
76
- * shows, just without a highlight.
77
- */
78
- export function matchRanges(text: string, query: string): Array<[number, number]> {
79
- const t = text ?? '';
80
- const q = (query ?? '').trim().toLowerCase();
81
- if (!q || !t) return [];
82
- const lower = t.toLowerCase();
83
- const ranges: Array<[number, number]> = [];
84
- let from = 0;
85
- for (;;) {
86
- const idx = lower.indexOf(q, from);
87
- if (idx === -1) break;
88
- ranges.push([idx, idx + q.length]);
89
- from = idx + q.length;
90
- }
91
- return ranges;
92
- }
@@ -1,45 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
3
-
4
- describe('recentLookups', () => {
5
- beforeEach(() => localStorage.clear());
6
-
7
- it('returns [] when nothing stored', () => {
8
- expect(getRecentLookupIds('acc')).toEqual([]);
9
- });
10
-
11
- it('stores and returns ids most-recent-first', () => {
12
- pushRecentLookupId('acc', 'a');
13
- pushRecentLookupId('acc', 'b');
14
- expect(getRecentLookupIds('acc')).toEqual(['b', 'a']);
15
- });
16
-
17
- it('de-dupes and moves a re-picked id to the front', () => {
18
- pushRecentLookupId('acc', 'a');
19
- pushRecentLookupId('acc', 'b');
20
- pushRecentLookupId('acc', 'a');
21
- expect(getRecentLookupIds('acc')).toEqual(['a', 'b']);
22
- });
23
-
24
- it('caps at 5 entries', () => {
25
- for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) pushRecentLookupId('acc', id);
26
- const ids = getRecentLookupIds('acc');
27
- expect(ids).toHaveLength(5);
28
- expect(ids[0]).toBe('g');
29
- expect(ids).not.toContain('a');
30
- });
31
-
32
- it('scopes per object name', () => {
33
- pushRecentLookupId('acc', 'a');
34
- pushRecentLookupId('prod', 'x');
35
- expect(getRecentLookupIds('acc')).toEqual(['a']);
36
- expect(getRecentLookupIds('prod')).toEqual(['x']);
37
- });
38
-
39
- it('ignores empty/nullish ids and object names', () => {
40
- pushRecentLookupId('acc', '');
41
- pushRecentLookupId('acc', null);
42
- pushRecentLookupId('', 'a');
43
- expect(getRecentLookupIds('acc')).toEqual([]);
44
- });
45
- });
@@ -1,47 +0,0 @@
1
- /**
2
- * Recently-used lookup memory.
3
- *
4
- * Mainstream record pickers (Salesforce, Dataverse, SAP, ServiceNow) surface
5
- * the user's recently-picked records the moment a lookup is focused — before
6
- * any typing. We approximate that with a small per-object ring of the last few
7
- * selected record ids, kept in localStorage (no backend dependency). When a
8
- * recent-items API later exists, this module is the single seam to swap.
9
- */
10
-
11
- const KEY_PREFIX = 'objectui:lookup:recent:';
12
- const MAX_RECENT = 5;
13
-
14
- function storage(): Storage | null {
15
- try {
16
- return typeof localStorage !== 'undefined' ? localStorage : null;
17
- } catch {
18
- // Accessing localStorage can throw in sandboxed iframes / SSR.
19
- return null;
20
- }
21
- }
22
-
23
- /** Most-recent-first list of record ids previously chosen for `objectName`. */
24
- export function getRecentLookupIds(objectName: string): Array<string | number> {
25
- const s = storage();
26
- if (!objectName || !s) return [];
27
- try {
28
- const raw = s.getItem(KEY_PREFIX + objectName);
29
- if (!raw) return [];
30
- const arr = JSON.parse(raw);
31
- return Array.isArray(arr) ? arr.slice(0, MAX_RECENT) : [];
32
- } catch {
33
- return [];
34
- }
35
- }
36
-
37
- /** Record that `id` was just chosen for `objectName` (moves it to the front). */
38
- export function pushRecentLookupId(objectName: string, id: string | number | null | undefined): void {
39
- const s = storage();
40
- if (!objectName || id === null || id === undefined || id === '' || !s) return;
41
- try {
42
- const next = [id, ...getRecentLookupIds(objectName).filter((x) => x !== id)].slice(0, MAX_RECENT);
43
- s.setItem(KEY_PREFIX + objectName, JSON.stringify(next));
44
- } catch {
45
- // Ignore quota / serialization failures — recents are best-effort.
46
- }
47
- }