@object-ui/fields 17.5.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,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,196 +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 { toHostGroupProps } from './toHostGroupProps';
7
- import { OptionsEmptyState } from './OptionsEmptyState';
8
- import { useCascadingOptions } from './useCascadingOptions';
9
-
10
- interface Option extends OptionLike { color?: string }
11
-
12
- /**
13
- * MultiSelectField - select zero or more values from a fixed option set.
14
- *
15
- * Renders the configured options as toggleable chips. The stored value is a
16
- * string[] (the selected option values). Used for the `multiselect` field type
17
- * and for a `select` field declared `multiple: true`.
18
- *
19
- * Options support the same per-option `visibleWhen` cascading + `dependsOn`
20
- * gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
21
- * shared {@link useCascadingOptions} hook: the offered chips narrow against the
22
- * live form record + `current_user`, the control is gated behind a "select the
23
- * parent first" hint while a dependency is empty, and selections no longer
24
- * offered (parent changed / predicate flipped) are dropped from the array.
25
- */
26
- export function MultiSelectField({
27
- value,
28
- onChange,
29
- field,
30
- readonly,
31
- className,
32
- dependentValues,
33
- dependsOn: dependsOnProp,
34
- emptyHint,
35
- dataSource: _dataSource,
36
- error,
37
- ...props
38
- }: FieldWidgetComponentProps<string[]>) {
39
- const config = field as any;
40
- const rawOptions: Option[] = config?.options || [];
41
- const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
42
- const fieldName = props.name || config?.name || props.id || '';
43
-
44
- const dependsOn = config?.dependsOn ?? dependsOnProp;
45
- const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
46
- rawOptions,
47
- dependsOn,
48
- dependentValues,
49
- );
50
-
51
- // Cascade clear: drop selected values the offered set no longer includes
52
- // (parent changed / predicate flipped), keeping the ones still valid — unlike
53
- // the scalar case we prune per-element rather than clearing the whole field.
54
- useEffect(() => {
55
- if (readonly) return;
56
- // An empty offered set has THREE very different causes, and only one of them
57
- // is a decision to prune against. This is the canonical copy of the guards;
58
- // the other three option widgets carry the same pair.
59
- //
60
- // 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
61
- // `options` at all. No decision was made anywhere, and the widget renders
62
- // its "unfillable" state below. Pruning here is not a cascade, it is
63
- // deleting the stored value of a field the user was only ever shown a
64
- // hint for — measured on the detail page's inline editor, which stages
65
- // that empty array into the record draft the moment the row enters edit
66
- // mode, and on the grid's inline cell editor, which has always taken this
67
- // path.
68
- if (rawOptions.length === 0) return;
69
- // 2. The list is GATED (objectui#4247) — an authored list withheld because a
70
- // declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
71
- // returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
72
- // no information about which stored values are valid, which is exactly
73
- // what the `OptionsEmptyState` below tells the user ("select Country
74
- // first"). Clearing here fired on MOUNT with no interaction — a record
75
- // that merely ARRIVES with the parent empty (a later-cleared parent, an
76
- // import, a partially-migrated row) had its picklist silently staged for
77
- // deletion. Read from the resolver's own `gated` flag rather than
78
- // re-derived from `options.length === 0`, which would collide with case 1.
79
- if (gated) return;
80
- // 3. The list RESOLVED and genuinely excludes the value — a real cascade
81
- // (the parent changed, a predicate flipped). That is the ADR-0058
82
- // contract and it still prunes, below.
83
- if (selected.length === 0) return;
84
- const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
85
- if (stillOffered.length !== selected.length) onChange(stillOffered);
86
- // eslint-disable-next-line react-hooks/exhaustive-deps
87
- }, [options, gated]);
88
-
89
- // The host's group label has to be consumed on EVERY surface this widget can
90
- // render, not only the editable one (objectui#3990): both branches below
91
- // return before the editable container's `groupDomProps` spread, which is how
92
- // a field-level `readonly: true` and a zero-option list ended up with a
93
- // published label id and no consumer at all. See `toHostGroupProps`.
94
- const hostGroupProps = toHostGroupProps(props);
95
-
96
- if (readonly) {
97
- // A readonly set of values is still a set, so it takes the same `group`
98
- // answer the editable chip row gives. `EmptyValue` is that surface with
99
- // nothing in it: it publishes an `aria-label` ("No value") of its own, and
100
- // `aria-labelledby` outranks that per accname — correctly, because on the
101
- // `generic` role that placeholder span carries, an author name is prohibited
102
- // and never exposed, so the real choice is the field's name or no name.
103
- if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
104
- return (
105
- <div {...hostGroupProps} className="flex flex-wrap gap-1">
106
- {selected.map((v) => {
107
- // Label from the raw set so a stored value hidden by `visibleWhen`
108
- // still renders its label rather than a bare id.
109
- const opt = rawOptions.find((o) => o.value === v);
110
- return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
111
- })}
112
- </div>
113
- );
114
- }
115
-
116
- // No offered options is unfillable — surface a legible state instead of an
117
- // empty chip row: the host's `emptyHint` when it computed one, else this
118
- // widget's own translated copy. Shared with the single select / radio /
119
- // checkboxes so the four cannot drift again (objectui#3231).
120
- if (options.length === 0) {
121
- return (
122
- <OptionsEmptyState
123
- emptyHint={emptyHint}
124
- gated={gated}
125
- dependsOnFields={dependsOnFields}
126
- testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
127
- className="min-h-9"
128
- // This box IS the field in this state, so it is what the host label
129
- // names (objectui#3990).
130
- hostGroupProps={hostGroupProps}
131
- />
132
- );
133
- }
134
-
135
- const toggle = (v: string) => {
136
- const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
137
- onChange(next);
138
- };
139
-
140
- // DOM pass-through (objectui#3318): the container carries the form
141
- // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
142
- // wrapper div is not where assistive tech reads the invalid state. That
143
- // state goes onto every focusable chip button below, computed from the
144
- // published `error` slot (#3222), so whichever chip the user tabs to
145
- // announces the failure. `name` is withheld too: it is only DOM-legal on
146
- // form controls, and on this div it is exactly the leak #3291 sweeps for.
147
- const {
148
- 'aria-invalid': _hostAriaInvalid,
149
- name: _domName,
150
- ...groupDomProps
151
- } = toDomProps(props);
152
- // When the host associated its visible label with this container by IDREF
153
- // (`aria-labelledby`, objectui#3961 / #3975) the container IS the labelled
154
- // group, so it answers with the matching role — without one, the name sits on
155
- // a generic `div` and contributes nothing: `label for` pointing here was
156
- // already inert (`HTMLLabelElement.control` is null for a div), which is the
157
- // whole defect. Each chip keeps its own accessible name from its text content,
158
- // which the group name does not override. Absent (standalone: the inline grid
159
- // editor, a bare SDUI node) nothing is emitted and the markup is unchanged.
160
- const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
161
-
162
- return (
163
- <div
164
- {...groupDomProps}
165
- role={isLabelledGroup ? 'group' : undefined}
166
- className={cn('flex flex-wrap gap-1.5', className)}
167
- data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
168
- >
169
- {options.map((opt) => {
170
- // Multi-value fields store string arrays — stringify the (possibly
171
- // numeric, #3090-widened) authored value at this boundary.
172
- const value = String(opt.value);
173
- const active = selected.includes(value);
174
- return (
175
- <button
176
- type="button"
177
- key={value}
178
- onClick={() => toggle(value)}
179
- disabled={props.disabled}
180
- aria-pressed={active}
181
- aria-invalid={!!error}
182
- data-testid={`multiselect-option-${opt.value}`}
183
- className={cn(
184
- 'rounded-full border px-3 py-1 text-sm transition-colors',
185
- active
186
- ? 'border-primary bg-primary text-primary-foreground'
187
- : 'border-input bg-background text-foreground hover:bg-accent',
188
- )}
189
- >
190
- {opt.label}
191
- </button>
192
- );
193
- })}
194
- </div>
195
- );
196
- }
@@ -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
- }