@object-ui/fields 17.5.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. 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
- });