@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,19 +0,0 @@
1
- import React from 'react';
2
- import ReactMarkdown from 'react-markdown';
3
- import remarkGfm from 'remark-gfm';
4
- import rehypeSanitize from 'rehype-sanitize';
5
-
6
- /**
7
- * Heavy markdown renderer, split into its own module so it can be lazy-loaded
8
- * by the cell/detail renderers (react-markdown pulls in ~100-200 KB). GitHub
9
- * Flavored Markdown + sanitization (defense against XSS in stored content).
10
- */
11
- export default function MarkdownContent({ value }: { value: string }) {
12
- return (
13
- <div className="prose prose-sm max-w-none dark:prose-invert break-words">
14
- <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
15
- {value}
16
- </ReactMarkdown>
17
- </div>
18
- );
19
- }
@@ -1,131 +0,0 @@
1
- import React from 'react';
2
- import { Button, Badge, cn } from '@object-ui/components';
3
- import { Plus, X, ExternalLink } from 'lucide-react';
4
- import { FieldWidgetComponentProps } from './types';
5
-
6
- /**
7
- * Master-Detail relationship data structure
8
- */
9
- export interface MasterDetailValue {
10
- id: string;
11
- label: string;
12
- [key: string]: any;
13
- }
14
-
15
- /**
16
- * Master-Detail field widget - manages one-to-many relationships
17
- * Displays related records with add/remove capabilities
18
- */
19
- export function MasterDetailField({
20
- value,
21
- onChange,
22
- field,
23
- readonly,
24
- className,
25
- ...props
26
- }: FieldWidgetComponentProps<MasterDetailValue[]>) {
27
- const items = value || [];
28
- const config = field;
29
-
30
- const handleAdd = () => {
31
- // This would typically open a dialog to select/create related records
32
- // For now, we'll just show a placeholder
33
- const newItem: MasterDetailValue = {
34
- id: `new-${Date.now()}`,
35
- label: 'New Related Record',
36
- };
37
- onChange([...items, newItem]);
38
- };
39
-
40
- const handleRemove = (id: string) => {
41
- onChange(items.filter(item => item.id !== id));
42
- };
43
-
44
- const handleView = (item: MasterDetailValue) => {
45
- // This would typically navigate to the detail view
46
- console.log('View detail:', item);
47
- };
48
-
49
- if (readonly) {
50
- return (
51
- <div className={cn("space-y-2", className)}>
52
- {items.length === 0 ? (
53
- <span className="text-sm text-muted-foreground">No related records</span>
54
- ) : (
55
- items.map((item) => (
56
- <div
57
- key={item.id}
58
- className="flex items-center justify-between p-2 border rounded hover:bg-muted/50"
59
- >
60
- <span className="text-sm">{item.label}</span>
61
- <Button
62
- type="button"
63
- variant="ghost"
64
- size="sm"
65
- onClick={() => handleView(item)}
66
- >
67
- <ExternalLink className="w-4 h-4" />
68
- </Button>
69
- </div>
70
- ))
71
- )}
72
- <p className="text-xs text-muted-foreground">
73
- {items.length} {items.length === 1 ? 'record' : 'records'}
74
- </p>
75
- </div>
76
- );
77
- }
78
-
79
- return (
80
- <div className={cn("space-y-3", className)}>
81
- <div className="space-y-2">
82
- {items.map((item) => (
83
- <div
84
- key={item.id}
85
- className="flex items-center justify-between gap-2 p-2 border rounded hover:bg-muted/50"
86
- >
87
- <div className="flex items-center gap-2 flex-1">
88
- <Badge variant="outline">{item.id}</Badge>
89
- <span className="text-sm flex-1">{item.label}</span>
90
- </div>
91
- <div className="flex items-center gap-1">
92
- <Button
93
- type="button"
94
- variant="ghost"
95
- size="sm"
96
- onClick={() => handleView(item)}
97
- >
98
- <ExternalLink className="w-4 h-4" />
99
- </Button>
100
- <Button
101
- type="button"
102
- variant="ghost"
103
- size="sm"
104
- onClick={() => handleRemove(item.id)}
105
- disabled={props.disabled}
106
- >
107
- <X className="w-4 h-4" />
108
- </Button>
109
- </div>
110
- </div>
111
- ))}
112
- {items.length === 0 && (
113
- <div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded bg-muted/20">
114
- No related records
115
- </div>
116
- )}
117
- </div>
118
-
119
- <Button
120
- type="button"
121
- variant="outline"
122
- className="w-full"
123
- onClick={handleAdd}
124
- disabled={props.disabled}
125
- >
126
- <Plus className="w-4 h-4 mr-2" />
127
- Add {config?.label || 'Record'}
128
- </Button>
129
- </div>
130
- );
131
- }
@@ -1,143 +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
- * Cascading / role-gated `multiselect` options (#2715) — parity with the single
11
- * `SelectField` (ADR-0058 / #2284).
12
- *
13
- * Exercises the observable widget behaviour: the dependency gate (a "select the
14
- * parent first" empty-state), and the cascade clear — but here per-element: a
15
- * selection dropped from the array when the offered set no longer includes it,
16
- * while still-valid selections are kept. The per-option filtering itself is
17
- * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
18
- * chip picker wires `dependentValues` + predicate scope into that resolver via
19
- * the shared `useCascadingOptions` hook.
20
- */
21
- import { describe, it, expect, vi, beforeEach } from 'vitest';
22
- import React from 'react';
23
- import { render, screen } from '@testing-library/react';
24
- import '@testing-library/jest-dom';
25
- import { PredicateScopeProvider } from '@object-ui/react';
26
- import { MultiSelectField } from './MultiSelectField';
27
-
28
- const provinceField = {
29
- name: 'province',
30
- type: 'multiselect',
31
- dependsOn: 'country',
32
- options: [
33
- { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
34
- { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
35
- ],
36
- } as any;
37
-
38
- beforeEach(() => {
39
- vi.clearAllMocks();
40
- });
41
-
42
- describe('MultiSelectField — dependency gating (#2715)', () => {
43
- it('gates with a "select parent first" hint while the controlling field is empty', () => {
44
- render(
45
- <MultiSelectField
46
- value={[]}
47
- onChange={vi.fn()}
48
- field={provinceField}
49
- {...({ name: 'province', dependentValues: {} } as any)}
50
- />,
51
- );
52
- const gate = screen.getByTestId('multiselect-empty-province');
53
- expect(gate).toHaveTextContent(/select country first/i);
54
- // Gated → no chips offered.
55
- expect(screen.queryAllByRole('button')).toHaveLength(0);
56
- });
57
-
58
- it('unlocks the chips once the controlling field is set', () => {
59
- render(
60
- <MultiSelectField
61
- value={[]}
62
- onChange={vi.fn()}
63
- field={provinceField}
64
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
65
- />,
66
- );
67
- expect(screen.queryByTestId('multiselect-empty-province')).not.toBeInTheDocument();
68
- // Only the `cn` option is offered.
69
- expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
70
- expect(screen.queryByTestId('multiselect-option-ca')).not.toBeInTheDocument();
71
- });
72
- });
73
-
74
- describe('MultiSelectField — cascade clear (#2715)', () => {
75
- it('drops only the selections the parent change no longer offers', () => {
76
- const onChange = vi.fn();
77
- render(
78
- <MultiSelectField
79
- value={['zj', 'ca']}
80
- onChange={onChange}
81
- field={provinceField}
82
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
83
- />,
84
- );
85
- // 'ca' is a US province — under country=cn it is not offered, so it is
86
- // pruned; the still-valid 'zj' is kept.
87
- expect(onChange).toHaveBeenCalledWith(['zj']);
88
- });
89
-
90
- it('keeps selections that are all still offered', () => {
91
- const onChange = vi.fn();
92
- render(
93
- <MultiSelectField
94
- value={['zj']}
95
- onChange={onChange}
96
- field={provinceField}
97
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
98
- />,
99
- );
100
- expect(onChange).not.toHaveBeenCalled();
101
- });
102
- });
103
-
104
- describe('MultiSelectField — role / context gating (#2715)', () => {
105
- const tierField = {
106
- name: 'tiers',
107
- type: 'multiselect',
108
- options: [
109
- { label: 'Standard', value: 'standard' },
110
- { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
111
- ],
112
- } as any;
113
-
114
- it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
115
- const onChange = vi.fn();
116
- render(
117
- <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
118
- <MultiSelectField
119
- value={['standard', 'admin_only']}
120
- onChange={onChange}
121
- field={tierField}
122
- {...({ name: 'tiers', dependentValues: {} } as any)}
123
- />
124
- </PredicateScopeProvider>,
125
- );
126
- expect(onChange).toHaveBeenCalledWith(['standard']);
127
- });
128
-
129
- it('keeps an admin-only value for an admin', () => {
130
- const onChange = vi.fn();
131
- render(
132
- <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
133
- <MultiSelectField
134
- value={['standard', 'admin_only']}
135
- onChange={onChange}
136
- field={tierField}
137
- {...({ name: 'tiers', dependentValues: {} } as any)}
138
- />
139
- </PredicateScopeProvider>,
140
- );
141
- expect(onChange).not.toHaveBeenCalled();
142
- });
143
- });
@@ -1,142 +0,0 @@
1
- import React, { useEffect } from 'react';
2
- import { Badge, EmptyValue, cn } from '@object-ui/components';
3
- import type { OptionLike } from '@object-ui/core';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
- import { OptionsEmptyState } from './OptionsEmptyState';
7
- import { useCascadingOptions } from './useCascadingOptions';
8
-
9
- interface Option extends OptionLike { color?: string }
10
-
11
- /**
12
- * MultiSelectField - select zero or more values from a fixed option set.
13
- *
14
- * Renders the configured options as toggleable chips. The stored value is a
15
- * string[] (the selected option values). Used for the `multiselect` field type
16
- * and for a `select` field declared `multiple: true`.
17
- *
18
- * Options support the same per-option `visibleWhen` cascading + `dependsOn`
19
- * gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
20
- * shared {@link useCascadingOptions} hook: the offered chips narrow against the
21
- * live form record + `current_user`, the control is gated behind a "select the
22
- * parent first" hint while a dependency is empty, and selections no longer
23
- * offered (parent changed / predicate flipped) are dropped from the array.
24
- */
25
- export function MultiSelectField({
26
- value,
27
- onChange,
28
- field,
29
- readonly,
30
- className,
31
- dependentValues,
32
- dependsOn: dependsOnProp,
33
- emptyHint,
34
- dataSource: _dataSource,
35
- error,
36
- ...props
37
- }: FieldWidgetComponentProps<string[]>) {
38
- const config = field as any;
39
- const rawOptions: Option[] = config?.options || [];
40
- const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
41
- const fieldName = props.name || config?.name || props.id || '';
42
-
43
- const dependsOn = config?.dependsOn ?? dependsOnProp;
44
- const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
45
- rawOptions,
46
- dependsOn,
47
- dependentValues,
48
- );
49
-
50
- // Cascade clear: drop selected values the offered set no longer includes
51
- // (parent changed / predicate flipped), keeping the ones still valid — unlike
52
- // the scalar case we prune per-element rather than clearing the whole field.
53
- useEffect(() => {
54
- if (readonly) return;
55
- if (selected.length === 0) return;
56
- const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
57
- if (stillOffered.length !== selected.length) onChange(stillOffered);
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [options, gated]);
60
-
61
- if (readonly) {
62
- if (selected.length === 0) return <EmptyValue />;
63
- return (
64
- <div className="flex flex-wrap gap-1">
65
- {selected.map((v) => {
66
- // Label from the raw set so a stored value hidden by `visibleWhen`
67
- // still renders its label rather than a bare id.
68
- const opt = rawOptions.find((o) => o.value === v);
69
- return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
70
- })}
71
- </div>
72
- );
73
- }
74
-
75
- // No offered options is unfillable — surface a legible state instead of an
76
- // empty chip row: the host's `emptyHint` when it computed one, else this
77
- // widget's own translated copy. Shared with the single select / radio /
78
- // checkboxes so the four cannot drift again (objectui#3231).
79
- if (options.length === 0) {
80
- return (
81
- <OptionsEmptyState
82
- emptyHint={emptyHint}
83
- gated={gated}
84
- dependsOnFields={dependsOnFields}
85
- testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
86
- className="min-h-9"
87
- />
88
- );
89
- }
90
-
91
- const toggle = (v: string) => {
92
- const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
93
- onChange(next);
94
- };
95
-
96
- // DOM pass-through (objectui#3318): the container carries the form
97
- // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
98
- // wrapper div is not where assistive tech reads the invalid state. That
99
- // state goes onto every focusable chip button below, computed from the
100
- // published `error` slot (#3222), so whichever chip the user tabs to
101
- // announces the failure. `name` is withheld too: it is only DOM-legal on
102
- // form controls, and on this div it is exactly the leak #3291 sweeps for.
103
- const {
104
- 'aria-invalid': _hostAriaInvalid,
105
- name: _domName,
106
- ...groupDomProps
107
- } = toDomProps(props);
108
-
109
- return (
110
- <div
111
- {...groupDomProps}
112
- className={cn('flex flex-wrap gap-1.5', className)}
113
- data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
114
- >
115
- {options.map((opt) => {
116
- // Multi-value fields store string arrays — stringify the (possibly
117
- // numeric, #3090-widened) authored value at this boundary.
118
- const value = String(opt.value);
119
- const active = selected.includes(value);
120
- return (
121
- <button
122
- type="button"
123
- key={value}
124
- onClick={() => toggle(value)}
125
- disabled={props.disabled}
126
- aria-pressed={active}
127
- aria-invalid={!!error}
128
- data-testid={`multiselect-option-${opt.value}`}
129
- className={cn(
130
- 'rounded-full border px-3 py-1 text-sm transition-colors',
131
- active
132
- ? 'border-primary bg-primary text-primary-foreground'
133
- : 'border-input bg-background text-foreground hover:bg-accent',
134
- )}
135
- >
136
- {opt.label}
137
- </button>
138
- );
139
- })}
140
- </div>
141
- );
142
- }
@@ -1,49 +0,0 @@
1
- import React from 'react';
2
- import { Input, EmptyValue } from '@object-ui/components';
3
- import { NumberFieldMetadata } from '@object-ui/types';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
-
7
- /**
8
- * NumberField - Numeric input with optional decimal precision
9
- * Supports min/max/step constraints and configurable decimal precision
10
- */
11
- export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
12
- if (readonly) {
13
- return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
14
- }
15
-
16
- const numberField = field as NumberFieldMetadata;
17
- // Step follows `scale` (decimal places), not `precision` (total digit count):
18
- // a decimal(10, 0) column has 0 decimal places, so the input should step by 1
19
- // (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
20
- // An explicit `step` in the metadata wins over the derived one.
21
- const scale = numberField?.scale;
22
- const step =
23
- typeof numberField?.step === 'number'
24
- ? numberField.step
25
- : typeof scale === 'number'
26
- ? Math.pow(10, -scale)
27
- : 'any';
28
-
29
- const domProps = toDomProps(props);
30
-
31
- return (
32
- <Input
33
- {...domProps}
34
- type="number"
35
- value={value ?? ''}
36
- onChange={(e) => {
37
- const val = e.target.value;
38
- onChange(val === '' ? (null as any) : Number(val));
39
- }}
40
- placeholder={numberField?.placeholder}
41
- disabled={readonly || domProps.disabled}
42
- // Surface the field's declared range so the browser's spinner/keyboard
43
- // affordances respect it (server-side validation still owns enforcement).
44
- min={typeof numberField?.min === 'number' ? numberField.min : undefined}
45
- max={typeof numberField?.max === 'number' ? numberField.max : undefined}
46
- step={step}
47
- />
48
- );
49
- }
@@ -1,94 +0,0 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * ObjectField - JSON object editor
8
- * Allows editing structured JSON data
9
- */
10
- export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
11
- const config = field;
12
-
13
- // Initialize string state based on value
14
- const getInitialJsonString = () => {
15
- if (value === undefined || value === null) return '';
16
- return JSON.stringify(value, null, 2);
17
- };
18
-
19
- const [jsonString, setJsonString] = useState(getInitialJsonString);
20
- // Named `parseError`, NOT `error`: `error` is the published validation slot
21
- // on the widget contract (#3222) and is destructured above.
22
- const [parseError, setParseError] = useState<string | null>(null);
23
-
24
- // Sync internal string state when value changes externally
25
- // This is a controlled component pattern where we need to sync external changes
26
- useEffect(() => {
27
- try {
28
- if (value === undefined || value === null) {
29
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
30
- setJsonString('');
31
- return;
32
- }
33
- // Only update if the parsed internal state doesn't match the new value
34
- // This prevents cursor jumping/reformatting while typing valid JSON
35
- const currentParsed = jsonString ? JSON.parse(jsonString) : null;
36
- if (JSON.stringify(currentParsed) !== JSON.stringify(value)) {
37
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
38
- setJsonString(JSON.stringify(value, null, 2));
39
- }
40
- } catch {
41
- // Fallback if internal state was invalid JSON
42
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
43
- setJsonString(JSON.stringify(value, null, 2));
44
- }
45
- }, [value, jsonString]);
46
-
47
- if (readonly) {
48
- if (!value) return <EmptyValue />;
49
- return (
50
- <pre className={cn("text-xs bg-gray-50 p-2 rounded border border-gray-200 overflow-auto max-h-40", props.className)}>
51
- {JSON.stringify(value, null, 2)}
52
- </pre>
53
- );
54
- }
55
-
56
- const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
57
- const str = e.target.value;
58
- setJsonString(str);
59
- setParseError(null);
60
-
61
- if (!str.trim()) {
62
- onChange(null);
63
- return;
64
- }
65
-
66
- try {
67
- const parsed = JSON.parse(str);
68
- onChange(parsed);
69
- } catch (e) {
70
- // Invalid JSON - don't propagate change to parent, but keep local state
71
- setParseError("Invalid JSON");
72
- }
73
- };
74
-
75
- return (
76
- <div className="space-y-1">
77
- <Textarea
78
- // DOM pass-through onto the real focusable control (objectui#3318).
79
- {...toDomProps(props)}
80
- value={jsonString}
81
- onChange={handleChange}
82
- placeholder={config?.placeholder || '{\n "key": "value"\n}'}
83
- disabled={readonly || props.disabled}
84
- className={cn("font-mono text-xs", parseError ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
85
- rows={6}
86
- // AFTER the spread so this widget's own computation wins (#3222).
87
- // Invalid = the form's validation slot OR this widget's own unparsable
88
- // draft (which already draws the red border above).
89
- aria-invalid={!!error || !!parseError}
90
- />
91
- {parseError && <p className="text-xs text-red-500">{parseError}</p>}
92
- </div>
93
- );
94
- }