@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,117 +0,0 @@
1
- import React from 'react';
2
- import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
- import { Upload, X } from 'lucide-react';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
-
7
- /**
8
- * Avatar field widget - provides an avatar/profile picture uploader
9
- * Supports image URLs or file uploads
10
- */
11
- export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
12
- const [isHovered, setIsHovered] = React.useState(false);
13
- const fileInputRef = React.useRef<HTMLInputElement>(null);
14
-
15
- const avatarField = field as any;
16
-
17
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
18
- const file = event.target.files?.[0];
19
- if (!file) return;
20
-
21
- // Check file type
22
- if (!file.type.startsWith('image/')) {
23
- console.error('Please select an image file');
24
- return;
25
- }
26
-
27
- // Check file size (max 5MB)
28
- if (file.size > 5 * 1024 * 1024) {
29
- console.error('File size must be less than 5MB');
30
- return;
31
- }
32
-
33
- // Convert to base64 or upload to server
34
- const reader = new FileReader();
35
- reader.onloadend = () => {
36
- onChange(reader.result as string);
37
- };
38
- reader.readAsDataURL(file);
39
- };
40
-
41
- const handleRemove = () => {
42
- onChange('');
43
- };
44
-
45
- // Extract initials for fallback
46
- const getInitials = (): string => {
47
- const name = avatarField?.defaultName || avatarField?.label || 'User';
48
- return name
49
- .split(' ')
50
- .map((word: string) => word[0])
51
- .join('')
52
- .toUpperCase()
53
- .slice(0, 2);
54
- };
55
-
56
- if (readonly) {
57
- return (
58
- <Avatar className="w-16 h-16">
59
- {value && <AvatarImage src={value} alt={avatarField?.label} />}
60
- <AvatarFallback>{getInitials()}</AvatarFallback>
61
- </Avatar>
62
- );
63
- }
64
-
65
- return (
66
- <div className="flex items-center gap-4">
67
- <div
68
- className="relative"
69
- onMouseEnter={() => setIsHovered(true)}
70
- onMouseLeave={() => setIsHovered(false)}
71
- >
72
- <Avatar className="w-16 h-16">
73
- {value && <AvatarImage src={value} alt={avatarField?.label} />}
74
- <AvatarFallback>{getInitials()}</AvatarFallback>
75
- </Avatar>
76
- {!readonly && isHovered && value && (
77
- <button
78
- type="button"
79
- onClick={handleRemove}
80
- className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-1 hover:bg-destructive/90"
81
- >
82
- <X className="w-3 h-3" />
83
- </button>
84
- )}
85
- </div>
86
-
87
- <div className="flex flex-col gap-2">
88
- <input
89
- ref={fileInputRef}
90
- type="file"
91
- accept="image/*"
92
- onChange={handleFileSelect}
93
- className="hidden"
94
- />
95
- <Button
96
- // DOM pass-through onto the widget's real focusable control — the
97
- // upload button is the keyboard path to the hidden file input
98
- // (objectui#3318).
99
- {...toDomProps(props)}
100
- type="button"
101
- variant="outline"
102
- size="sm"
103
- onClick={() => fileInputRef.current?.click()}
104
- disabled={readonly || props.disabled}
105
- // AFTER the spread so this widget's own computation wins (#3222).
106
- aria-invalid={!!error}
107
- >
108
- <Upload className="w-4 h-4 mr-2" />
109
- {value ? 'Change' : 'Upload'} Avatar
110
- </Button>
111
- <p className="text-xs text-muted-foreground">
112
- PNG, JPG up to 5MB
113
- </p>
114
- </div>
115
- </div>
116
- );
117
- }
@@ -1,52 +0,0 @@
1
- import React, { useId } from 'react';
2
- import { Switch, Checkbox, Label } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * BooleanField - Toggle input supporting switch and checkbox variants
8
- * Renders as Switch or Checkbox based on field widget configuration
9
- */
10
- export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
11
- const config = field as any;
12
- // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
13
- const widget = config?.widget;
14
- // Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
15
- const generatedId = useId();
16
- const id = config?.name || generatedId;
17
- const label = config?.label || 'Checkbox';
18
-
19
- if (readonly) {
20
- return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
21
- }
22
-
23
- const domProps = toDomProps(props);
24
-
25
- if (widget === 'checkbox') {
26
- return (
27
- <div className="flex items-center space-x-2">
28
- <Checkbox
29
- {...domProps}
30
- id={id}
31
- checked={!!value}
32
- onCheckedChange={(checked) => onChange(!!checked)}
33
- disabled={readonly || domProps.disabled}
34
- />
35
- <Label htmlFor={id} className="sr-only">{label}</Label>
36
- </div>
37
- )
38
- }
39
-
40
- return (
41
- <div className="flex items-center space-x-2">
42
- <Switch
43
- {...domProps}
44
- id={id}
45
- checked={!!value}
46
- onCheckedChange={onChange}
47
- disabled={readonly || domProps.disabled}
48
- />
49
- <Label htmlFor={id} className="sr-only">{label}</Label>
50
- </div>
51
- );
52
- }
@@ -1,168 +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
- * CapabilityMultiSelectField (ADR-0056 P2, epic #2398).
11
- *
12
- * Proves the two properties P2 depends on:
13
- * 1. the stored value parses tolerantly into a capability-name list, and
14
- * 2. every edit emits a JSON-**string** array of names — byte-equivalent to
15
- * the `sys_permission_set.system_permissions` textarea storage, so the
16
- * round-trip through the picker never changes the on-disk shape.
17
- */
18
- import { describe, it, expect, vi } from 'vitest';
19
- import React from 'react';
20
- import { render, screen, fireEvent } from '@testing-library/react';
21
- import '@testing-library/jest-dom';
22
- import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField';
23
-
24
- describe('parseCapabilityNames', () => {
25
- it('parses the canonical JSON-string array', () => {
26
- expect(parseCapabilityNames('["setup.access","studio.access"]')).toEqual([
27
- 'setup.access',
28
- 'studio.access',
29
- ]);
30
- });
31
-
32
- it('treats empty / null / undefined as no selection', () => {
33
- expect(parseCapabilityNames('')).toEqual([]);
34
- expect(parseCapabilityNames(' ')).toEqual([]);
35
- expect(parseCapabilityNames(null)).toEqual([]);
36
- expect(parseCapabilityNames(undefined)).toEqual([]);
37
- expect(parseCapabilityNames('[]')).toEqual([]);
38
- });
39
-
40
- it('tolerates an already-parsed array', () => {
41
- expect(parseCapabilityNames(['a', 'b'])).toEqual(['a', 'b']);
42
- });
43
-
44
- it('falls back to comma-splitting a non-JSON legacy string', () => {
45
- expect(parseCapabilityNames('setup.access, studio.access')).toEqual([
46
- 'setup.access',
47
- 'studio.access',
48
- ]);
49
- });
50
- });
51
-
52
- const CAPS = [
53
- { name: 'manage_users', label: 'Manage Users', description: 'Manage users', scope: 'platform', active: true },
54
- { name: 'studio.access', label: 'Studio Access', description: 'Enter Studio', scope: 'platform', active: true },
55
- { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
56
- ];
57
-
58
- function mockDataSource(rows = CAPS) {
59
- return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
60
- }
61
-
62
- describe('CapabilityMultiSelectField', () => {
63
- it('renders capabilities grouped by scope with human labels', async () => {
64
- render(
65
- <CapabilityMultiSelectField
66
- value={'["studio.access"]'}
67
- onChange={vi.fn()}
68
- field={{ name: 'system_permissions' } as any}
69
- dataSource={mockDataSource()}
70
- />,
71
- );
72
- // group headers
73
- expect(await screen.findByText('Platform')).toBeInTheDocument();
74
- expect(screen.getByText('Organization')).toBeInTheDocument();
75
- // labels, not raw names
76
- expect(screen.getByRole('button', { name: 'Studio Access' })).toHaveAttribute('aria-pressed', 'true');
77
- expect(screen.getByRole('button', { name: 'Manage Users' })).toHaveAttribute('aria-pressed', 'false');
78
- });
79
-
80
- it('emits a JSON-string array when a capability is toggled on', async () => {
81
- const onChange = vi.fn();
82
- render(
83
- <CapabilityMultiSelectField
84
- value={'["studio.access"]'}
85
- onChange={onChange}
86
- field={{ name: 'system_permissions' } as any}
87
- dataSource={mockDataSource()}
88
- />,
89
- );
90
- fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' }));
91
- expect(onChange).toHaveBeenCalledTimes(1);
92
- const emitted = onChange.mock.calls[0][0];
93
- // The emitted value must be a JSON *string* (not a raw array), byte-equal
94
- // to the textarea storage contract.
95
- expect(typeof emitted).toBe('string');
96
- expect(JSON.parse(emitted)).toEqual(['studio.access', 'manage_users']);
97
- });
98
-
99
- it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => {
100
- const onChange = vi.fn();
101
- render(
102
- <CapabilityMultiSelectField
103
- value={'["studio.access","manage_users"]'}
104
- onChange={onChange}
105
- field={{ name: 'system_permissions' } as any}
106
- dataSource={mockDataSource()}
107
- />,
108
- );
109
- // Chip labels now resolve through the i18n bundle (objectui#2600 B5), so an
110
- // async translation-settle can re-render the chips between findBy and the
111
- // click — re-query at click time so we act on the live node, not a detached one.
112
- await screen.findByRole('button', { name: 'Studio Access' });
113
- fireEvent.click(screen.getByRole('button', { name: 'Studio Access' }));
114
- const emitted = onChange.mock.calls[0][0];
115
- expect(typeof emitted).toBe('string');
116
- expect(JSON.parse(emitted)).toEqual(['manage_users']);
117
- });
118
-
119
- it('preserves an unknown/legacy selected name not in the registry', async () => {
120
- render(
121
- <CapabilityMultiSelectField
122
- value={'["studio.access","legacy.custom"]'}
123
- onChange={vi.fn()}
124
- field={{ name: 'system_permissions' } as any}
125
- dataSource={mockDataSource()}
126
- />,
127
- );
128
- // The unknown name still renders as a (selected) chip so it is not dropped.
129
- const legacy = await screen.findByRole('button', { name: 'legacy.custom' });
130
- expect(legacy).toHaveAttribute('aria-pressed', 'true');
131
- });
132
-
133
- it('renders selected labels as read-only badges when readonly', async () => {
134
- render(
135
- <CapabilityMultiSelectField
136
- value={'["studio.access"]'}
137
- onChange={vi.fn()}
138
- field={{ name: 'system_permissions' } as any}
139
- dataSource={mockDataSource()}
140
- readonly
141
- />,
142
- );
143
- // No toggle buttons in readonly mode.
144
- expect(await screen.findByText('Studio Access')).toBeInTheDocument();
145
- expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
146
- });
147
-
148
- // objectui#2600 B5 — curated platform caps get a localized label; package/
149
- // admin-authored caps keep whatever label the registry served.
150
- it('localizes curated capability labels but preserves registry labels for others', async () => {
151
- render(
152
- <CapabilityMultiSelectField
153
- value={'[]'}
154
- onChange={vi.fn()}
155
- field={{ name: 'system_permissions' } as any}
156
- dataSource={mockDataSource([
157
- // Registry sends a shorter label; the curated client map wins.
158
- { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
159
- // Not a curated platform capability — its registry label is kept.
160
- { name: 'export_data', label: 'Export Data', description: 'Export', scope: 'org', active: true },
161
- ])}
162
- />,
163
- );
164
- expect(await screen.findByRole('button', { name: 'Manage Organization Users' })).toBeInTheDocument();
165
- expect(screen.queryByRole('button', { name: 'Manage Org Users' })).not.toBeInTheDocument();
166
- expect(screen.getByRole('button', { name: 'Export Data' })).toBeInTheDocument();
167
- });
168
- });
@@ -1,229 +0,0 @@
1
- import React from 'react';
2
- import { Badge, EmptyValue, cn } from '@object-ui/components';
3
- import { SchemaRendererContext } from '@object-ui/react';
4
- import type { DataSource, QueryParams } from '@object-ui/types';
5
- import { FieldWidgetComponentProps } from './types';
6
- import { useFieldTranslation } from './useFieldTranslation';
7
-
8
- /**
9
- * CapabilityMultiSelectField — structured picker for a permission set's
10
- * `system_permissions` (ADR-0056 / epic #2398, phase P2).
11
- *
12
- * `sys_permission_set.system_permissions` is framework-declared as a
13
- * `Field.textarea` that stores a **JSON-serialized array of capability names**
14
- * (e.g. `["setup.access","studio.access"]`; `security-plugin.ts` writes
15
- * `JSON.stringify(...)` and reads `parseJson(...)`). Editing that as raw JSON is
16
- * the anti-pattern ADR-0056 removes. This widget renders the same value as a
17
- * multi-select over the live `sys_capability` registry — grouped by scope,
18
- * labelled, with the capability description on hover — while round-tripping the
19
- * stored value **byte-for-byte** as a JSON string of names.
20
- *
21
- * Reached via the field `widget: 'capability-multiselect'` hint (stamped onto
22
- * the object metadata in MetadataProvider), registered in the field registry as
23
- * `field:capability-multiselect`.
24
- */
25
-
26
- interface Capability {
27
- name: string;
28
- label?: string;
29
- description?: string;
30
- scope?: string;
31
- active?: boolean;
32
- }
33
-
34
- /**
35
- * Parse the stored value into the selected capability-name list. The canonical
36
- * storage is a JSON-string array; we also tolerate an already-parsed array (in
37
- * case a caller hands us the parsed value) and a bare comma-separated string
38
- * (legacy/hand-authored) so no selection is silently dropped.
39
- */
40
- export function parseCapabilityNames(value: unknown): string[] {
41
- if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
42
- if (typeof value === 'string') {
43
- const s = value.trim();
44
- if (!s) return [];
45
- try {
46
- const parsed = JSON.parse(s);
47
- if (Array.isArray(parsed)) return parsed.map((v) => String(v)).filter(Boolean);
48
- // A JSON scalar (unexpected) — treat as a single name.
49
- return [String(parsed)].filter(Boolean);
50
- } catch {
51
- // Not JSON — fall back to comma-splitting so legacy values survive.
52
- return s.split(',').map((x) => x.trim()).filter(Boolean);
53
- }
54
- }
55
- return [];
56
- }
57
-
58
- /**
59
- * Scope → group header order (platform powers first). The visible header is
60
- * localized via `capability.group.<scope>` (objectui#2600 B5) — the capability
61
- * *labels themselves* still come from the sys_capability registry, whose
62
- * per-locale localization is tracked separately (framework).
63
- */
64
- const SCOPE_ORDER = ['platform', 'org', 'other'] as const;
65
-
66
- /**
67
- * objectui#2600 B5 — the curated platform capabilities are a FIXED, known set
68
- * whose labels the sys_capability registry serves in English. Localize just
69
- * these client-side via `capability.label.<name>` (dots → underscores);
70
- * package- and admin-authored capabilities keep their authored registry label.
71
- * (Mirrors @objectstack/spec/security `PLATFORM_CAPABILITIES`.)
72
- */
73
- const CURATED_CAPABILITY_LABELS = new Set([
74
- 'manage_users',
75
- 'manage_org_users',
76
- 'manage_metadata',
77
- 'manage_platform_settings',
78
- 'setup_access',
79
- 'setup_write',
80
- 'studio_access',
81
- ]);
82
-
83
- export function CapabilityMultiSelectField({
84
- value,
85
- onChange,
86
- readonly,
87
- className,
88
- ...props
89
- }: FieldWidgetComponentProps<string | string[]>) {
90
- const { t } = useFieldTranslation();
91
- const ctx = React.useContext(SchemaRendererContext);
92
- const dataSource: DataSource | null =
93
- (props.dataSource as any) ?? (ctx as any)?.dataSource ?? null;
94
- const disabled = props.disabled;
95
-
96
- const [caps, setCaps] = React.useState<Capability[] | null>(null);
97
-
98
- const selected = React.useMemo(() => parseCapabilityNames(value), [value]);
99
-
100
- // Load the live capability registry (active only). Mirrors LookupField's
101
- // context-dataSource access — createFieldRenderer does not forward the
102
- // dataSource prop, so the SchemaRenderer context is the reliable source.
103
- React.useEffect(() => {
104
- if (!dataSource || typeof (dataSource as any).find !== 'function') return;
105
- let cancelled = false;
106
- (async () => {
107
- try {
108
- const res = await dataSource.find('sys_capability', {
109
- $filter: { active: true },
110
- $top: 500,
111
- } as QueryParams);
112
- const rows: Capability[] =
113
- (res as any)?.data ?? (res as any)?.records ?? (Array.isArray(res) ? res : []);
114
- if (!cancelled) setCaps(Array.isArray(rows) ? rows : []);
115
- } catch {
116
- if (!cancelled) setCaps([]);
117
- }
118
- })();
119
- return () => {
120
- cancelled = true;
121
- };
122
- }, [dataSource]);
123
-
124
- // Emit as a JSON string so the value round-trips byte-equivalent to the
125
- // `system_permissions` textarea's `JSON.stringify(string[])` storage.
126
- const emit = React.useCallback(
127
- (names: string[]) => onChange(JSON.stringify(names) as any),
128
- [onChange],
129
- );
130
-
131
- // Union of the fetched registry and any *selected* names not present in it
132
- // (unknown / legacy / package-owned-inactive), so no current grant is hidden
133
- // or dropped. Unknown names land in the "other" group.
134
- const byName = React.useMemo(() => {
135
- const map = new Map<string, Capability>();
136
- for (const c of caps ?? []) if (c?.name) map.set(c.name, c);
137
- for (const n of selected) if (!map.has(n)) map.set(n, { name: n, label: n, scope: 'other' });
138
- return map;
139
- }, [caps, selected]);
140
-
141
- // Curated platform caps get a localized label (objectui#2600 B5); everything
142
- // else keeps the registry-served label.
143
- const labelFor = (name: string) => {
144
- const norm = name.replace(/\./g, '_');
145
- if (CURATED_CAPABILITY_LABELS.has(norm)) return t(`capability.label.${norm}`);
146
- return byName.get(name)?.label || name;
147
- };
148
-
149
- // Group options by scope for the editable grid. Computed BEFORE the readonly
150
- // early-return so the hook order stays stable regardless of `readonly`.
151
- const groups = React.useMemo(() => {
152
- const buckets = new Map<string, Capability[]>();
153
- for (const c of byName.values()) {
154
- const key = (SCOPE_ORDER as readonly string[]).includes(c.scope as string)
155
- ? (c.scope as string)
156
- : 'other';
157
- const list = buckets.get(key) ?? [];
158
- list.push(c);
159
- buckets.set(key, list);
160
- }
161
- return SCOPE_ORDER.filter((s) => buckets.has(s)).map((s) => ({
162
- scope: s,
163
- label: t(`capability.group.${s}`),
164
- items: (buckets.get(s) ?? []).sort((a, b) =>
165
- (a.label || a.name).localeCompare(b.label || b.name),
166
- ),
167
- }));
168
- }, [byName, t]);
169
-
170
- if (readonly) {
171
- if (selected.length === 0) return <EmptyValue />;
172
- return (
173
- <div className="flex flex-wrap gap-1">
174
- {selected.map((name) => (
175
- <Badge key={name} variant="outline" title={byName.get(name)?.description}>
176
- {labelFor(name)}
177
- </Badge>
178
- ))}
179
- </div>
180
- );
181
- }
182
-
183
- const toggle = (name: string) => {
184
- const next = selected.includes(name)
185
- ? selected.filter((x) => x !== name)
186
- : [...selected, name];
187
- emit(next);
188
- };
189
-
190
- return (
191
- <div className={cn('flex flex-col gap-3', className)}>
192
- {caps === null && groups.length === 0 && (
193
- <span className="text-sm text-muted-foreground">Loading capabilities…</span>
194
- )}
195
- {groups.map((group) => (
196
- <div key={group.scope} className="flex flex-col gap-1.5">
197
- <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
198
- {group.label}
199
- </div>
200
- <div className="flex flex-wrap gap-1.5">
201
- {group.items.map((cap) => {
202
- const active = selected.includes(cap.name);
203
- return (
204
- <button
205
- type="button"
206
- key={cap.name}
207
- onClick={() => toggle(cap.name)}
208
- disabled={disabled}
209
- aria-pressed={active}
210
- title={cap.description || undefined}
211
- className={cn(
212
- 'rounded-full border px-3 py-1 text-sm transition-colors disabled:opacity-50',
213
- active
214
- ? 'border-primary bg-primary text-primary-foreground'
215
- : 'border-input bg-background text-foreground hover:bg-accent',
216
- )}
217
- >
218
- {labelFor(cap.name)}
219
- </button>
220
- );
221
- })}
222
- </div>
223
- </div>
224
- ))}
225
- </div>
226
- );
227
- }
228
-
229
- export default CapabilityMultiSelectField;