@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,94 +0,0 @@
1
- import React from 'react';
2
- import { Star } from 'lucide-react';
3
- import { cn } from '@object-ui/components';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
- import { toHostGroupProps } from './toHostGroupProps';
7
-
8
- /**
9
- * Rating field widget - provides a star rating input
10
- * Supports numeric values from 0 to max (default 5)
11
- */
12
- export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<number>) {
13
- // Get rating-specific configuration from field metadata
14
- const ratingField = field as any;
15
- const max = ratingField?.max ?? 5;
16
- const currentValue = value ?? 0;
17
-
18
- const [hoverValue, setHoverValue] = React.useState<number | null>(null);
19
-
20
- const displayValue = hoverValue !== null ? hoverValue : currentValue;
21
-
22
- if (readonly) {
23
- // The readonly star row is the field's whole rendered surface, so it — not
24
- // only the editable row below — consumes the host's group label
25
- // (objectui#3990). See `toHostGroupProps`.
26
- return (
27
- <div {...toHostGroupProps(props)} className={cn("flex items-center gap-1", className)}>
28
- {Array.from({ length: max }, (_, i) => (
29
- <Star
30
- key={i}
31
- className={`w-5 h-5 ${
32
- i < currentValue
33
- ? 'fill-yellow-400 text-yellow-400'
34
- : 'text-muted-foreground'
35
- }`}
36
- />
37
- ))}
38
- <span className="ml-2 text-sm text-muted-foreground">
39
- {currentValue} / {max}
40
- </span>
41
- </div>
42
- );
43
- }
44
-
45
- // DOM pass-through (objectui#3318): the container carries the form
46
- // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
47
- // wrapper div is not where assistive tech reads the invalid state. That
48
- // state goes onto every focusable star button below, computed from the
49
- // published `error` slot (#3222). `name` is withheld too: it is only
50
- // DOM-legal on form controls, and on this div it is exactly the leak
51
- // #3291 sweeps for.
52
- const {
53
- 'aria-invalid': _hostAriaInvalid,
54
- name: _domName,
55
- ...groupDomProps
56
- } = toDomProps(props);
57
- // When the host named this container by IDREF (`aria-labelledby`,
58
- // objectui#3961) it IS the labelled group of stars, so it answers with the
59
- // matching role; a `label for` pointing at this `div` was inert. Standalone
60
- // (no host label) the markup is unchanged.
61
- const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
62
-
63
- return (
64
- <div
65
- {...groupDomProps}
66
- role={isLabelledGroup ? 'group' : undefined}
67
- className={cn("flex items-center gap-1", className)}
68
- >
69
- {Array.from({ length: max }, (_, i) => (
70
- <button
71
- key={i}
72
- type="button"
73
- onClick={() => onChange(i + 1)}
74
- onMouseEnter={() => setHoverValue(i + 1)}
75
- onMouseLeave={() => setHoverValue(null)}
76
- className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
77
- disabled={readonly || props.disabled}
78
- aria-invalid={!!error}
79
- >
80
- <Star
81
- className={`w-5 h-5 transition-colors ${
82
- i < displayValue
83
- ? 'fill-yellow-400 text-yellow-400 hover:fill-yellow-500 hover:text-yellow-500'
84
- : 'text-muted-foreground hover:text-yellow-400'
85
- }`}
86
- />
87
- </button>
88
- ))}
89
- <span className="ml-2 text-sm text-muted-foreground">
90
- {currentValue} / {max}
91
- </span>
92
- </div>
93
- );
94
- }
@@ -1,157 +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
- * RecipientPickerField — the sharing-rule recipient picker (objectstack#3821).
11
- *
12
- * Two regressions are pinned here:
13
- * 1. the candidate query must use the STRUCTURED `$orderby` form. It used to
14
- * pass the clause string `'name asc'`, which ApiDataSource walked
15
- * character by character into `0 n,1 a,2 m,…`; the server then sorted by
16
- * columns that don't exist and every recipient list came back empty, so
17
- * the control read "No matches" forever and no rule could be saved.
18
- * 2. the placeholder must come from a per-type i18n key, not from
19
- * interpolating the enum value into an English sentence.
20
- */
21
- import { describe, it, expect, vi } from 'vitest';
22
- import React from 'react';
23
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
24
- import '@testing-library/jest-dom';
25
- import { RecipientPickerField } from './RecipientPickerField';
26
-
27
- const USERS = [
28
- { id: 'usr_1', name: 'Ada Auditor' },
29
- { id: 'usr_2', name: 'Bob Builder' },
30
- ];
31
-
32
- function mockDataSource(rows: any[] = USERS) {
33
- return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
34
- }
35
-
36
- function renderPicker(recipientType: string, dataSource: any, value = '') {
37
- return render(
38
- <RecipientPickerField
39
- value={value}
40
- onChange={vi.fn()}
41
- field={{ name: 'recipient_id' } as any}
42
- dataSource={dataSource}
43
- dependentValues={{ recipient_type: recipientType }}
44
- />,
45
- );
46
- }
47
-
48
- describe('RecipientPickerField — candidate query', () => {
49
- it('orders candidates with the structured $orderby form, never a clause string', async () => {
50
- const ds = mockDataSource();
51
- renderPicker('user', ds);
52
-
53
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
54
- const [object, params] = ds.find.mock.calls[0];
55
- expect(object).toBe('sys_user');
56
- expect(params.$orderby).toEqual({ name: 'asc' });
57
- // A bare string is what regressed; assert the shape explicitly.
58
- expect(typeof params.$orderby).not.toBe('string');
59
- });
60
-
61
- it('queries the object mapped to each recipient type', async () => {
62
- for (const [type, object] of [
63
- ['user', 'sys_user'],
64
- ['team', 'sys_team'],
65
- ['business_unit', 'sys_business_unit'],
66
- ['unit_and_subordinates', 'sys_business_unit'],
67
- ['position', 'sys_position'],
68
- ] as const) {
69
- const ds = mockDataSource([]);
70
- const { unmount } = renderPicker(type, ds);
71
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
72
- expect(ds.find.mock.calls[0][0]).toBe(object);
73
- unmount();
74
- }
75
- });
76
-
77
- it('shows the loaded records as options once the list is opened', async () => {
78
- const ds = mockDataSource();
79
- renderPicker('user', ds);
80
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
81
-
82
- fireEvent.click(screen.getByRole('combobox'));
83
- expect(await screen.findByText('Ada Auditor')).toBeInTheDocument();
84
- expect(screen.getByText('Bob Builder')).toBeInTheDocument();
85
- });
86
- });
87
-
88
- describe('RecipientPickerField — keeping the stored recipient', () => {
89
- function renderControlled(initialType: string, value: string, onChange: any) {
90
- const ds = mockDataSource();
91
- const view = render(
92
- <RecipientPickerField
93
- value={value}
94
- onChange={onChange}
95
- field={{ name: 'recipient_id' } as any}
96
- dataSource={ds}
97
- dependentValues={{ recipient_type: initialType }}
98
- />,
99
- );
100
- const rerenderWith = (type: string) =>
101
- view.rerender(
102
- <RecipientPickerField
103
- value={value}
104
- onChange={onChange}
105
- field={{ name: 'recipient_id' } as any}
106
- dataSource={ds}
107
- dependentValues={{ recipient_type: type }}
108
- />,
109
- );
110
- return { ...view, rerenderWith };
111
- }
112
-
113
- it('keeps the saved recipient when the type arrives late (edit form hydrating)', async () => {
114
- // The edit dialog mounts the widget before the record lands, so
115
- // recipient_type is '' on the first render and 'user' on the next. That is
116
- // hydration, not the admin changing the type — clearing there wiped the
117
- // saved recipient and the blank got persisted on save.
118
- const onChange = vi.fn();
119
- const { rerenderWith } = renderControlled('', 'usr_1', onChange);
120
- rerenderWith('user');
121
- await waitFor(() => expect(onChange).not.toHaveBeenCalled());
122
- });
123
-
124
- it('clears the recipient when the admin actually switches type', async () => {
125
- const onChange = vi.fn();
126
- const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
127
- rerenderWith('team');
128
- await waitFor(() => expect(onChange).toHaveBeenCalledWith(''));
129
- });
130
-
131
- it('does not clear when the type is unset back to empty', async () => {
132
- const onChange = vi.fn();
133
- const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
134
- rerenderWith('');
135
- await waitFor(() => expect(onChange).not.toHaveBeenCalled());
136
- });
137
- });
138
-
139
- describe('RecipientPickerField — localizable copy', () => {
140
- it('takes the placeholder from a per-type key rather than the raw enum value', async () => {
141
- // With no I18nProvider the widget falls back to the English defaults, so
142
- // the assertion is on the *wording*: 'Select a business unit' can only come
143
- // from the keyed string — interpolating the enum produced
144
- // 'Select a business unit' only by accident and 'Select a unit and
145
- // subordinates' for the next type over.
146
- const ds = mockDataSource([]);
147
- renderPicker('unit_and_subordinates', ds);
148
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
149
- expect(await screen.findByText('Select a business unit')).toBeInTheDocument();
150
- expect(screen.queryByText(/unit and subordinates/i)).not.toBeInTheDocument();
151
- });
152
-
153
- it('prompts for a recipient type before one is chosen', () => {
154
- renderPicker('', mockDataSource([]));
155
- expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument();
156
- });
157
- });
@@ -1,188 +0,0 @@
1
- import React from 'react';
2
- import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
- import { SchemaRendererContext } from '@object-ui/react';
4
- import type { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
- import { useFieldTranslation } from './useFieldTranslation';
7
-
8
- /**
9
- * RecipientPickerField — dependent record picker for a polymorphic recipient
10
- * reference (e.g. `sys_sharing_rule.recipient_id`), whose target object is
11
- * decided by a sibling `recipient_type` select.
12
- *
13
- * Reached via the field `widget: 'recipient-picker'` hint (resolves as
14
- * `field:recipient-picker`). Reads the live `recipient_type` from
15
- * `dependentValues` (the form record), loads candidate records of the mapped
16
- * object via `dataSource.find(...)`, and stores the value the sharing evaluator
17
- * expects for that type:
18
- *
19
- * user → sys_user, store `id`
20
- * team → sys_team, store `id`
21
- * business_unit → sys_business_unit, store `id`
22
- * unit_and_subordinates → sys_business_unit, store `id`
23
- * position → sys_position, store `name` (matched against
24
- * sys_user_position.position at evaluation time)
25
- *
26
- * When `recipient_type` changes after mount the stored id is reset (an id valid
27
- * for one type is meaningless for another). Unknown types degrade to a plain
28
- * text input so nothing breaks.
29
- */
30
- interface RecipientMapping {
31
- object: string;
32
- /** Which record field to persist into recipient_id. */
33
- storeField: 'id' | 'name';
34
- /** Candidate display-label fields, in preference order. */
35
- labelFields: string[];
36
- /**
37
- * i18n key for the "choose one" placeholder. Keyed per type rather than
38
- * interpolating the enum value into an English sentence — "Select a
39
- * business unit" and "选择业务单元" share no structure (objectstack#3821).
40
- */
41
- placeholderKey: string;
42
- }
43
-
44
- const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
45
- user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' },
46
- team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
47
- business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
48
- unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
49
- position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
50
- };
51
-
52
- export function RecipientPickerField({
53
- value,
54
- onChange,
55
- readonly,
56
- className,
57
- error,
58
- ...props
59
- }: FieldWidgetComponentProps<string>) {
60
- const ctx = React.useContext(SchemaRendererContext);
61
- const { t } = useFieldTranslation();
62
- const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
63
- const disabled = props.disabled;
64
- const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
65
- const recipientType = String(dependentValues.recipient_type ?? '');
66
- const mapping = TYPE_TO_OBJECT[recipientType];
67
-
68
- const [records, setRecords] = React.useState<any[] | null>(null);
69
-
70
- // Reset the stored recipient when the admin PICKS a different type (an id for
71
- // a user is not a valid team/business-unit id).
72
- //
73
- // "Different type" means one non-empty type replacing another. The empty
74
- // string is not a type — it is the edit form before the record has hydrated,
75
- // and treating `'' → 'user'` as a change wiped the saved recipient the moment
76
- // an existing rule was opened for editing (objectstack#3821): the picker went
77
- // blank, and saving persisted the blank. Only a non-empty predecessor can
78
- // invalidate the stored id.
79
- const prevType = React.useRef<string>('');
80
- React.useEffect(() => {
81
- if (prevType.current && recipientType && prevType.current !== recipientType && value) {
82
- onChange('' as any);
83
- }
84
- prevType.current = recipientType;
85
- // eslint-disable-next-line react-hooks/exhaustive-deps
86
- }, [recipientType]);
87
-
88
- React.useEffect(() => {
89
- setRecords(null);
90
- if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
91
- let cancelled = false;
92
- (async () => {
93
- try {
94
- // Object form, not the 'name asc' clause string: the clause string is
95
- // supported again (objectstack#3821 fixed ApiDataSource walking it
96
- // character by character), but the structured form can't regress that
97
- // way for any data source.
98
- const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
99
- const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
100
- if (!cancelled) setRecords(Array.isArray(list) ? list : []);
101
- } catch {
102
- if (!cancelled) setRecords([]);
103
- }
104
- })();
105
- return () => {
106
- cancelled = true;
107
- };
108
- }, [dataSource, mapping?.object]);
109
-
110
- const labelOf = (r: any): string => {
111
- for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
112
- return String(r?.id ?? '');
113
- };
114
- const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? '');
115
-
116
- const options = React.useMemo(() => {
117
- const opts = (records ?? [])
118
- .map((r) => ({ value: valueOf(r), label: labelOf(r) }))
119
- .filter((o) => o.value);
120
- if (value && !opts.some((o) => o.value === value)) opts.unshift({ value, label: value });
121
- return opts;
122
- // eslint-disable-next-line react-hooks/exhaustive-deps
123
- }, [records, value, mapping]);
124
-
125
- if (!recipientType) {
126
- return (
127
- <p className={cn('text-sm text-muted-foreground', className)}>
128
- {t('fields.recipient.selectTypeFirst')}
129
- </p>
130
- );
131
- }
132
-
133
- if (!mapping) {
134
- // Unknown / unsupported recipient type — keep a plain text input so the
135
- // field is never un-editable.
136
- return (
137
- <input
138
- // DOM pass-through onto the real focusable control (objectui#3318).
139
- {...toDomProps(props)}
140
- className={cn(
141
- 'w-full rounded border bg-background px-2 py-1 text-sm',
142
- className,
143
- )}
144
- value={value ?? ''}
145
- disabled={disabled || readonly}
146
- onChange={(e) => onChange(e.target.value as any)}
147
- aria-invalid={!!error}
148
- />
149
- );
150
- }
151
-
152
- if (readonly) {
153
- if (!value) return <EmptyValue />;
154
- return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
155
- }
156
-
157
- // DOM pass-through onto the combobox trigger — the widget's real focusable
158
- // control (objectui#3318). `name` is withheld: the trigger is a button, not
159
- // a submission control (same reasoning as #3306's SelectTrigger).
160
- //
161
- // NOTE this widget stays on the #3318 ledger regardless: its dependency-
162
- // gated state (no `recipient_type` chosen yet — the state a fresh form and
163
- // the registry sweep render) is a plain hint paragraph with no focusable
164
- // control, so there is nothing there to carry the attribute.
165
- const { name: _domName, ...triggerDomProps } = toDomProps(props);
166
-
167
- return (
168
- <Combobox
169
- {...triggerDomProps}
170
- options={options}
171
- value={value ?? ''}
172
- onValueChange={(v) => onChange(v as any)}
173
- placeholder={
174
- records === null
175
- ? t('fields.recipient.loading')
176
- : t(mapping.placeholderKey ?? 'fields.recipient.select')
177
- }
178
- searchPlaceholder={t('fields.recipient.search')}
179
- emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
180
- disabled={disabled}
181
- className={cn('w-full', className)}
182
- // AFTER the spread so this widget's own computation wins (#3222).
183
- aria-invalid={!!error}
184
- />
185
- );
186
- }
187
-
188
- export default RecipientPickerField;
@@ -1,191 +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
- * `RecordPickerDialog.baseFilter` accepts TWO shapes — objectui#3831.
11
- *
12
- * The slot was declared `Record< string, any >` and merged by object spread,
13
- * which serves the dependent-lookup chain (#2215) exactly right and cannot
14
- * carry a spec `ViewFilterRule[]` at all: TypeScript accepts an array there
15
- * (an array satisfies a string index of `any`), the spread flattens it to
16
- * `{"0": rule, "1": rule}`, and the query then filters on columns literally
17
- * named `0`/`1` — green types, wrong query, no diagnostic. That is the slot
18
- * `record:related_list.add.picker.filter` has to reach.
19
- *
20
- * So the merge now discriminates on shape, and both directions are pinned here:
21
- *
22
- * - **record form** keeps KEY-OVERWRITE precedence, byte for byte. A cascaded
23
- * parent value must REPLACE a stale same-field `lookupFilters` entry, not
24
- * intersect with it — `account = 'stale' AND account = 'a1'` returns nothing.
25
- * (`LookupField.dependsOn.test.tsx` owns the #2215 behaviour end-to-end and
26
- * is deliberately left untouched; this file pins the same precedence at the
27
- * dialog's own boundary, so a future merge change cannot pass by only
28
- * satisfying the array path.)
29
- * - **rule array** lowers through `mergeFilterNodes`, the repo's single filter
30
- * sink, so all 19 `VIEW_FILTER_OPERATORS` survive — including the four
31
- * (`before`, `after`, `is_empty`, `is_not_empty`) the MongoDB-style record
32
- * form has no `$op` for and which a hand-rolled second lowering would have
33
- * had to drop or throw on.
34
- */
35
-
36
- import { render, waitFor } from '@testing-library/react';
37
- import { describe, it, expect, vi } from 'vitest';
38
- import { RecordPickerDialog } from './RecordPickerDialog';
39
-
40
- const records = [{ id: 'r1', name: 'One' }];
41
-
42
- function makeDataSource() {
43
- const find = vi.fn(async () => ({ data: records, total: records.length }));
44
- return { find } as any;
45
- }
46
-
47
- /** The `$filter` of the most recent query. */
48
- function lastFilter(ds: any) {
49
- const calls = ds.find.mock.calls;
50
- return calls[calls.length - 1][1]?.$filter;
51
- }
52
-
53
- describe('RecordPickerDialog baseFilter — record form (#2215 precedence)', () => {
54
- it('spreads a record baseFilter last, overwriting lookupFilters on the same field', async () => {
55
- const ds = makeDataSource();
56
- render(
57
- <RecordPickerDialog
58
- open
59
- onOpenChange={vi.fn()}
60
- dataSource={ds}
61
- objectName="contacts"
62
- onSelect={vi.fn()}
63
- lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
64
- baseFilter={{ account: 'a1' }}
65
- />,
66
- );
67
-
68
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
69
- // Still the plain record form — NOT lowered to an AST, and emphatically not
70
- // an `and` of the stale value with the cascaded one.
71
- expect(lastFilter(ds)).toEqual({ account: 'a1' });
72
- expect(Array.isArray(lastFilter(ds))).toBe(false);
73
- });
74
-
75
- it('sends no $filter at all when nothing is constrained', async () => {
76
- const ds = makeDataSource();
77
- render(
78
- <RecordPickerDialog
79
- open
80
- onOpenChange={vi.fn()}
81
- dataSource={ds}
82
- objectName="contacts"
83
- onSelect={vi.fn()}
84
- />,
85
- );
86
-
87
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
88
- expect(lastFilter(ds)).toBeUndefined();
89
- });
90
- });
91
-
92
- describe('RecordPickerDialog baseFilter — spec ViewFilterRule[] (#3831)', () => {
93
- it('lowers a rule array to AST nodes instead of spreading it into index keys', async () => {
94
- const ds = makeDataSource();
95
- render(
96
- <RecordPickerDialog
97
- open
98
- onOpenChange={vi.fn()}
99
- dataSource={ds}
100
- objectName="sys_position"
101
- onSelect={vi.fn()}
102
- baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
103
- />,
104
- );
105
-
106
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
107
- expect(lastFilter(ds)).toEqual([['is_active', 'equals', true]]);
108
- // The old object-spread failure mode, pinned by structure rather than by
109
- // key: the spread produced a PLAIN OBJECT whose values were still rule
110
- // objects (`{"0": {field, operator, value}}`), so the query asked for a
111
- // column named `0`. Asserting on `Object.keys` cannot tell the two apart —
112
- // an array's keys are its indices too — so what is pinned is that each
113
- // element is an AST TRIPLE and no longer carries a `field` member.
114
- const filter = lastFilter(ds) as unknown[];
115
- expect(Array.isArray(filter)).toBe(true);
116
- expect(Array.isArray(filter[0])).toBe(true);
117
- expect(filter[0]).not.toHaveProperty('field');
118
- });
119
-
120
- it('preserves the four operators the record form cannot spell', async () => {
121
- const ds = makeDataSource();
122
- render(
123
- <RecordPickerDialog
124
- open
125
- onOpenChange={vi.fn()}
126
- dataSource={ds}
127
- objectName="sys_license"
128
- onSelect={vi.fn()}
129
- baseFilter={[
130
- { field: 'starts_at', operator: 'before', value: '2026-01-01' },
131
- { field: 'expires_at', operator: 'after', value: '2026-01-01' },
132
- { field: 'revoked_at', operator: 'is_empty' },
133
- { field: 'assigned_to', operator: 'is_not_empty' },
134
- ]}
135
- />,
136
- );
137
-
138
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
139
- expect(lastFilter(ds)).toEqual([
140
- ['starts_at', 'before', '2026-01-01'],
141
- ['expires_at', 'after', '2026-01-01'],
142
- // A rule with no `value` stays two-element: inventing `null` here would be
143
- // a real `{revoked_at: null}` predicate, i.e. a different question.
144
- ['revoked_at', 'is_empty'],
145
- ['assigned_to', 'is_not_empty'],
146
- ]);
147
- });
148
-
149
- it('keeps a record baseFilter and a rule array as separate `and` children', async () => {
150
- const ds = makeDataSource();
151
- render(
152
- <RecordPickerDialog
153
- open
154
- onOpenChange={vi.fn()}
155
- dataSource={ds}
156
- objectName="sys_position"
157
- onSelect={vi.fn()}
158
- // A rule array is the author's restriction; the record-form base still
159
- // arrives via `lookupFilters` here, and the two must BOTH apply.
160
- lookupFilters={[{ field: 'company', operator: 'eq', value: 'c1' }]}
161
- baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
162
- />,
163
- );
164
-
165
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
166
- expect(lastFilter(ds)).toEqual([
167
- 'and',
168
- ['company', '=', 'c1'],
169
- [['is_active', 'equals', true]],
170
- ]);
171
- });
172
-
173
- it('sends no $filter for an empty rule array', async () => {
174
- const ds = makeDataSource();
175
- render(
176
- <RecordPickerDialog
177
- open
178
- onOpenChange={vi.fn()}
179
- dataSource={ds}
180
- objectName="sys_position"
181
- onSelect={vi.fn()}
182
- baseFilter={[]}
183
- />,
184
- );
185
-
186
- await waitFor(() => expect(ds.find).toHaveBeenCalled());
187
- // An empty restriction is no restriction — not an empty `$filter` the wire
188
- // has to interpret.
189
- expect(lastFilter(ds)).toBeUndefined();
190
- });
191
- });