@object-ui/fields 16.1.0 → 17.2.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 (129) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/FieldEditWidget.d.ts +9 -2
  3. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  4. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  5. package/dist/field-type-alias.d.ts +16 -0
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.d.ts +14 -11
  8. package/dist/index.js +2426 -2100
  9. package/dist/widgets/AddressField.d.ts +2 -2
  10. package/dist/widgets/AutoNumberField.d.ts +2 -2
  11. package/dist/widgets/AvatarField.d.ts +2 -2
  12. package/dist/widgets/BooleanField.d.ts +2 -2
  13. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  14. package/dist/widgets/CheckboxesField.d.ts +2 -2
  15. package/dist/widgets/CodeField.d.ts +2 -2
  16. package/dist/widgets/ColorField.d.ts +2 -2
  17. package/dist/widgets/CurrencyField.d.ts +2 -2
  18. package/dist/widgets/DateField.d.ts +2 -2
  19. package/dist/widgets/DateTimeField.d.ts +2 -2
  20. package/dist/widgets/EmailField.d.ts +2 -2
  21. package/dist/widgets/FileField.d.ts +2 -2
  22. package/dist/widgets/FilterConditionField.d.ts +34 -2
  23. package/dist/widgets/FormulaField.d.ts +2 -2
  24. package/dist/widgets/GeolocationField.d.ts +2 -2
  25. package/dist/widgets/GridField.d.ts +2 -2
  26. package/dist/widgets/ImageField.d.ts +2 -2
  27. package/dist/widgets/ImageLightbox.d.ts +30 -0
  28. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  29. package/dist/widgets/LocationField.d.ts +2 -2
  30. package/dist/widgets/LookupField.d.ts +2 -2
  31. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  32. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  33. package/dist/widgets/MasterDetailField.d.ts +2 -2
  34. package/dist/widgets/MultiSelectField.d.ts +2 -2
  35. package/dist/widgets/NumberField.d.ts +2 -2
  36. package/dist/widgets/ObjectField.d.ts +2 -2
  37. package/dist/widgets/ObjectRefField.d.ts +2 -2
  38. package/dist/widgets/PasswordField.d.ts +2 -2
  39. package/dist/widgets/PercentField.d.ts +2 -2
  40. package/dist/widgets/PhoneField.d.ts +2 -2
  41. package/dist/widgets/QRCodeField.d.ts +2 -2
  42. package/dist/widgets/RadioField.d.ts +2 -2
  43. package/dist/widgets/RatingField.d.ts +2 -2
  44. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  45. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  46. package/dist/widgets/RichTextField.d.ts +2 -2
  47. package/dist/widgets/SelectField.d.ts +2 -2
  48. package/dist/widgets/SignatureField.d.ts +2 -2
  49. package/dist/widgets/SliderField.d.ts +2 -2
  50. package/dist/widgets/SummaryField.d.ts +2 -2
  51. package/dist/widgets/TagsField.d.ts +2 -2
  52. package/dist/widgets/TextAreaField.d.ts +2 -2
  53. package/dist/widgets/TextField.d.ts +2 -2
  54. package/dist/widgets/TimeField.d.ts +2 -2
  55. package/dist/widgets/UrlField.d.ts +2 -2
  56. package/dist/widgets/UserField.d.ts +2 -2
  57. package/dist/widgets/VectorField.d.ts +2 -2
  58. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  59. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  60. package/dist/widgets/file-value.d.ts +99 -0
  61. package/dist/widgets/file-value.test.d.ts +1 -0
  62. package/dist/widgets/types.d.ts +119 -3
  63. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  64. package/package.json +9 -8
  65. package/src/FieldEditWidget.test.ts +74 -2
  66. package/src/FieldEditWidget.tsx +50 -8
  67. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  68. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  69. package/src/complex-widgets.test.tsx +3 -3
  70. package/src/datetime-widgets.test.tsx +2 -2
  71. package/src/field-type-alias.ts +100 -0
  72. package/src/index.tsx +97 -134
  73. package/src/widgets/AddressField.tsx +2 -2
  74. package/src/widgets/AutoNumberField.tsx +2 -2
  75. package/src/widgets/AvatarField.tsx +2 -2
  76. package/src/widgets/BooleanField.tsx +2 -2
  77. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  78. package/src/widgets/CheckboxesField.tsx +12 -9
  79. package/src/widgets/CodeField.tsx +2 -2
  80. package/src/widgets/ColorField.tsx +2 -2
  81. package/src/widgets/CurrencyField.tsx +2 -2
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +2 -2
  85. package/src/widgets/FileField.tsx +90 -59
  86. package/src/widgets/FilterConditionField.tsx +112 -12
  87. package/src/widgets/FormulaField.tsx +2 -2
  88. package/src/widgets/GeolocationField.tsx +2 -2
  89. package/src/widgets/GridField.tsx +3 -3
  90. package/src/widgets/ImageField.tsx +73 -35
  91. package/src/widgets/ImageLightbox.test.tsx +52 -0
  92. package/src/widgets/ImageLightbox.tsx +108 -0
  93. package/src/widgets/LocationField.tsx +2 -2
  94. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  95. package/src/widgets/LookupField.idField.test.tsx +61 -0
  96. package/src/widgets/LookupField.tsx +116 -19
  97. package/src/widgets/MasterDetailField.tsx +2 -2
  98. package/src/widgets/MultiSelectField.tsx +10 -7
  99. package/src/widgets/NumberField.tsx +2 -2
  100. package/src/widgets/ObjectField.tsx +2 -2
  101. package/src/widgets/ObjectRefField.tsx +13 -8
  102. package/src/widgets/PasswordField.tsx +2 -2
  103. package/src/widgets/PeoplePicker.test.tsx +20 -0
  104. package/src/widgets/PeoplePicker.tsx +15 -3
  105. package/src/widgets/PercentField.tsx +2 -2
  106. package/src/widgets/PhoneField.tsx +2 -2
  107. package/src/widgets/QRCodeField.tsx +2 -2
  108. package/src/widgets/RadioField.tsx +10 -7
  109. package/src/widgets/RatingField.tsx +2 -2
  110. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  111. package/src/widgets/RecipientPickerField.tsx +40 -20
  112. package/src/widgets/RichTextField.tsx +2 -2
  113. package/src/widgets/SelectField.tsx +5 -5
  114. package/src/widgets/SignatureField.tsx +2 -2
  115. package/src/widgets/SliderField.tsx +2 -2
  116. package/src/widgets/SummaryField.tsx +2 -2
  117. package/src/widgets/TagsField.tsx +3 -3
  118. package/src/widgets/TextAreaField.tsx +6 -3
  119. package/src/widgets/TextField.tsx +2 -2
  120. package/src/widgets/TimeField.tsx +2 -2
  121. package/src/widgets/UrlField.tsx +2 -2
  122. package/src/widgets/UserField.tsx +2 -2
  123. package/src/widgets/VectorField.tsx +2 -2
  124. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  125. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  126. package/src/widgets/file-value.test.ts +230 -0
  127. package/src/widgets/file-value.ts +246 -0
  128. package/src/widgets/types.ts +129 -6
  129. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, Slider, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * PercentField - Percentage input with configurable decimal precision
7
7
  * Stores values as decimals (0-1) and displays as percentages (0-100%)
8
8
  * Includes a slider for interactive control.
9
9
  */
10
- export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
10
+ export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetComponentProps<number>) {
11
11
  const percentField = (field || (props as any).schema) as any;
12
12
  const precision = percentField?.precision ?? 2;
13
13
 
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * PhoneField - Telephone number input with tel link in readonly mode
7
7
  * Renders as a clickable tel link when readonly, standard phone input otherwise
8
8
  */
9
- export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
9
+ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
10
  const config = field || (props as any).schema;
11
11
  if (readonly) {
12
12
  if (!value) return <EmptyValue />;
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, Button, EmptyValue } from '@object-ui/components';
3
3
  import { QrCode, Copy } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * QR Code field widget - generates QR codes from text
8
8
  * Uses a simple SVG-based QR code generator
9
9
  */
10
- export function QRCodeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function QRCodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const [showQR, setShowQR] = React.useState(false);
12
12
  const config = field || (props as any).schema;
13
13
 
@@ -1,7 +1,7 @@
1
1
  import React, { useId, useEffect } from 'react';
2
2
  import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
3
  import { isValueStillOffered, type OptionLike } from '@object-ui/core';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
  import { useCascadingOptions } from './useCascadingOptions';
6
6
 
7
7
  type Option = OptionLike;
@@ -30,11 +30,11 @@ export function RadioField({
30
30
  emptyHint: _emptyHint,
31
31
  dataSource: _dataSource,
32
32
  ...props
33
- }: FieldWidgetProps<string>) {
33
+ }: FieldWidgetComponentProps<string>) {
34
34
  const config = (field || schema) as any;
35
35
  const rawOptions: Option[] = config?.options || [];
36
36
  const groupId = useId();
37
- const fieldName = (props as any).name || config?.name || (props as any).id || '';
37
+ const fieldName = props.name || config?.name || props.id || '';
38
38
 
39
39
  const dependsOn = config?.dependsOn ?? dependsOnProp;
40
40
  const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
@@ -81,14 +81,17 @@ export function RadioField({
81
81
  <RadioGroup
82
82
  value={value ?? ''}
83
83
  onValueChange={onChange}
84
- disabled={(props as any).disabled}
84
+ disabled={props.disabled}
85
85
  className={className}
86
86
  >
87
87
  {options.map((opt) => {
88
- const id = `${groupId}-${opt.value}`;
88
+ // Radix speaks strings — stringify the (possibly numeric,
89
+ // #3090-widened) authored value at this boundary.
90
+ const value = String(opt.value);
91
+ const id = `${groupId}-${value}`;
89
92
  return (
90
- <div key={opt.value} className="flex items-center space-x-2">
91
- <RadioGroupItem value={opt.value} id={id} data-testid={`radio-option-${opt.value}`} />
93
+ <div key={value} className="flex items-center space-x-2">
94
+ <RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
92
95
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
93
96
  </div>
94
97
  );
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Star } from 'lucide-react';
3
3
  import { cn } from '@object-ui/components';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Rating field widget - provides a star rating input
8
8
  * Supports numeric values from 0 to max (default 5)
9
9
  */
10
- export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<number>) {
10
+ export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<number>) {
11
11
  // Get rating-specific configuration from field metadata
12
12
  const ratingField = (field || (props as any).schema) as any;
13
13
  const max = ratingField?.max ?? 5;
@@ -0,0 +1,157 @@
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,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
- import type { FieldWidgetProps } from './types';
4
+ import type { FieldWidgetComponentProps } from './types';
5
+ import { useFieldTranslation } from './useFieldTranslation';
5
6
 
6
7
  /**
7
8
  * RecipientPickerField — dependent record picker for a polymorphic recipient
@@ -31,14 +32,20 @@ interface RecipientMapping {
31
32
  storeField: 'id' | 'name';
32
33
  /** Candidate display-label fields, in preference order. */
33
34
  labelFields: string[];
35
+ /**
36
+ * i18n key for the "choose one" placeholder. Keyed per type rather than
37
+ * interpolating the enum value into an English sentence — "Select a
38
+ * business unit" and "选择业务单元" share no structure (objectstack#3821).
39
+ */
40
+ placeholderKey: string;
34
41
  }
35
42
 
36
43
  const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
37
- user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
38
- team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
39
- business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
40
- unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
41
- position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
44
+ user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' },
45
+ team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
46
+ business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
47
+ unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
48
+ position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
42
49
  };
43
50
 
44
51
  export function RecipientPickerField({
@@ -47,22 +54,29 @@ export function RecipientPickerField({
47
54
  readonly,
48
55
  className,
49
56
  ...props
50
- }: FieldWidgetProps<string>) {
57
+ }: FieldWidgetComponentProps<string>) {
51
58
  const ctx = React.useContext(SchemaRendererContext);
52
- const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
53
- const disabled = (props as any).disabled as boolean | undefined;
59
+ const { t } = useFieldTranslation();
60
+ const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
61
+ const disabled = props.disabled;
54
62
  const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
55
63
  const recipientType = String(dependentValues.recipient_type ?? '');
56
64
  const mapping = TYPE_TO_OBJECT[recipientType];
57
65
 
58
66
  const [records, setRecords] = React.useState<any[] | null>(null);
59
67
 
60
- // Reset the stored recipient when the type changes AFTER mount (an id for a
61
- // user is not a valid team/business-unit id). The ref starts null so the
62
- // initial render of an existing rule never clears its value.
63
- const prevType = React.useRef<string | null>(null);
68
+ // Reset the stored recipient when the admin PICKS a different type (an id for
69
+ // a user is not a valid team/business-unit id).
70
+ //
71
+ // "Different type" means one non-empty type replacing another. The empty
72
+ // string is not a type — it is the edit form before the record has hydrated,
73
+ // and treating `'' → 'user'` as a change wiped the saved recipient the moment
74
+ // an existing rule was opened for editing (objectstack#3821): the picker went
75
+ // blank, and saving persisted the blank. Only a non-empty predecessor can
76
+ // invalidate the stored id.
77
+ const prevType = React.useRef<string>('');
64
78
  React.useEffect(() => {
65
- if (prevType.current !== null && prevType.current !== recipientType && value) {
79
+ if (prevType.current && recipientType && prevType.current !== recipientType && value) {
66
80
  onChange('' as any);
67
81
  }
68
82
  prevType.current = recipientType;
@@ -75,7 +89,11 @@ export function RecipientPickerField({
75
89
  let cancelled = false;
76
90
  (async () => {
77
91
  try {
78
- const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' });
92
+ // Object form, not the 'name asc' clause string: the clause string is
93
+ // supported again (objectstack#3821 fixed ApiDataSource walking it
94
+ // character by character), but the structured form can't regress that
95
+ // way for any data source.
96
+ const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
79
97
  const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
80
98
  if (!cancelled) setRecords(Array.isArray(list) ? list : []);
81
99
  } catch {
@@ -105,7 +123,7 @@ export function RecipientPickerField({
105
123
  if (!recipientType) {
106
124
  return (
107
125
  <p className={cn('text-sm text-muted-foreground', className)}>
108
- Select a recipient type first.
126
+ {t('fields.recipient.selectTypeFirst')}
109
127
  </p>
110
128
  );
111
129
  }
@@ -137,12 +155,14 @@ export function RecipientPickerField({
137
155
  value={value ?? ''}
138
156
  onValueChange={(v) => onChange(v as any)}
139
157
  placeholder={
140
- records === null ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}`
158
+ records === null
159
+ ? t('fields.recipient.loading')
160
+ : t(mapping.placeholderKey ?? 'fields.recipient.select')
141
161
  }
142
- searchPlaceholder="Search…"
143
- emptyText={records === null ? 'Loading…' : 'No matches'}
162
+ searchPlaceholder={t('fields.recipient.search')}
163
+ emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
144
164
  disabled={disabled}
145
- className={className}
165
+ className={cn('w-full', className)}
146
166
  />
147
167
  );
148
168
  }
@@ -1,14 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Textarea, EmptyValue } from '@object-ui/components';
3
3
  import { useObjectTranslation } from '@object-ui/react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Rich text field with markdown/HTML support
8
8
  * For now, this is a simple textarea. A full implementation would use
9
9
  * a rich text editor like TipTap, Lexical, or Slate.
10
10
  */
11
- export function RichTextField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
11
+ export function RichTextField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
12
12
  const { t } = useObjectTranslation();
13
13
  if (readonly) {
14
14
  return (
@@ -10,7 +10,7 @@ import {
10
10
  import { isValueStillOffered } from '@object-ui/core';
11
11
  import { SelectFieldMetadata } from '@object-ui/types';
12
12
  import { useFieldTranslation } from './useFieldTranslation';
13
- import { FieldWidgetProps } from './types';
13
+ import { FieldWidgetComponentProps } from './types';
14
14
  import { MultiSelectField } from './MultiSelectField';
15
15
  import { useCascadingOptions } from './useCascadingOptions';
16
16
 
@@ -29,12 +29,12 @@ import { useCascadingOptions } from './useCascadingOptions';
29
29
  * the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
30
30
  * in lockstep.
31
31
  */
32
- export function SelectField(props: FieldWidgetProps<any>) {
32
+ export function SelectField(props: FieldWidgetComponentProps<any>) {
33
33
  const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
34
34
  if ((config as any)?.multiple) {
35
35
  return <MultiSelectField {...props} />;
36
36
  }
37
- return <SingleSelectField {...(props as FieldWidgetProps<string>)} />;
37
+ return <SingleSelectField {...(props as FieldWidgetComponentProps<string>)} />;
38
38
  }
39
39
 
40
40
  /**
@@ -59,7 +59,7 @@ function SingleSelectField({
59
59
  emptyHint: _emptyHint,
60
60
  dataSource: _dataSource,
61
61
  ...props
62
- }: FieldWidgetProps<string>) {
62
+ }: FieldWidgetComponentProps<string>) {
63
63
  const config = (field || schema) as SelectFieldMetadata;
64
64
  const rawOptions = config?.options || [];
65
65
  const { t } = useFieldTranslation();
@@ -67,7 +67,7 @@ function SingleSelectField({
67
67
  // driven by synthetic DOM events, so e2e must target the trigger/options by a
68
68
  // deterministic testid keyed on the field name. `props.name` is the
69
69
  // react-hook-form field name spread in by the form renderer (FormField).
70
- const fieldName = (props as any).name || (config as any)?.name || props.id || '';
70
+ const fieldName = props.name || (config as any)?.name || props.id || '';
71
71
 
72
72
  const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
73
73
  const { options, gated, dependsOnFields } = useCascadingOptions(
@@ -1,13 +1,13 @@
1
1
  import React, { useRef, useEffect } from 'react';
2
2
  import { Button } from '@object-ui/components';
3
3
  import { Eraser } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Signature field widget - provides a signature pad for capturing signatures
8
8
  * Outputs signature as a base64 PNG image
9
9
  */
10
- export function SignatureField({ value, onChange, readonly }: FieldWidgetProps<string>) {
10
+ export function SignatureField({ value, onChange, readonly }: FieldWidgetComponentProps<string>) {
11
11
  const canvasRef = useRef<HTMLCanvasElement>(null);
12
12
  const [isDrawing, setIsDrawing] = React.useState(false);
13
13
  const [isEmpty, setIsEmpty] = React.useState(!value);
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { Slider } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Slider field widget - provides a range slider input
7
7
  * Supports numeric values with configurable min, max, and step
8
8
  */
9
- export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<number>) {
9
+ export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
10
10
  // Get slider-specific configuration from field metadata
11
11
  const sliderField = (field || (props as any).schema) as any;
12
12
  const min = sliderField?.min ?? 0;
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * SummaryField - Read-only aggregation field
7
7
  * Values are aggregated from related records and cannot be edited
8
8
  */
9
- export function SummaryField({ value, field, ...props }: FieldWidgetProps<any>) {
9
+ export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
10
10
  const summaryField = (field || (props as any).schema) as any;
11
11
  const summaryType = summaryField?.summary_type || 'count';
12
12
 
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * TagsField - free-form list of string tags. Type a value and press Enter (or
7
7
  * comma) to add it; click a tag's × to remove it. The stored value is a
8
8
  * string[]. Used for the `tags` field type.
9
9
  */
10
- export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
10
+ export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string[]>) {
11
11
  const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
12
12
  const [draft, setDraft] = React.useState('');
13
13
 
@@ -58,7 +58,7 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
58
58
  onChange={(e) => setDraft(e.target.value)}
59
59
  onKeyDown={onKeyDown}
60
60
  onBlur={() => addTag(draft)}
61
- disabled={(props as any).disabled}
61
+ disabled={props.disabled}
62
62
  placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
63
63
  className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
64
64
  />
@@ -10,7 +10,7 @@ import {
10
10
  Button,
11
11
  } from '@object-ui/components';
12
12
  import { Maximize2, Check, X } from 'lucide-react';
13
- import { FieldWidgetProps } from './types';
13
+ import { FieldWidgetComponentProps } from './types';
14
14
 
15
15
  /**
16
16
  * TextAreaField - Multi-line text input widget
@@ -21,7 +21,7 @@ import { FieldWidgetProps } from './types';
21
21
  * a fullscreen edit dialog — much easier on phones than tapping a 4-row
22
22
  * textarea trapped between other fields.
23
23
  */
24
- export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
24
+ export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
25
25
  // Hooks must run before any early return (readonly) to keep hook order stable.
26
26
  const [fullscreenOpen, setFullscreenOpen] = useState(false);
27
27
  const [draft, setDraft] = useState(value ?? '');
@@ -36,7 +36,10 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
36
36
 
37
37
  const textareaField = (field || (props as any).schema) as any;
38
38
  const rows = textareaField?.rows || 4;
39
- const maxLength = textareaField?.max_length;
39
+ // Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
40
+ // objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
41
+ // spec-authored maxLength gave neither the textarea cap nor the counter.
42
+ const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
40
43
  // Mobile fullscreen flag may arrive on the field metadata, on the form-field
41
44
  // schema (when called via the form renderer where `field` is the ObjectQL
42
45
  // metadata sub-object), or as an explicit widget prop.
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, Textarea, EmptyValue } from '@object-ui/components';
3
3
  import { TextareaFieldMetadata } from '@object-ui/types';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * TextField - Standard single-line or multi-line text input
8
8
  * Automatically renders as a textarea when rows are configured in field metadata
9
9
  */
10
- export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const fieldData = field || (props as any).schema;
12
12
 
13
13
  if (readonly) {
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
  import { openNativePicker } from './openNativePicker';
5
5
 
6
6
  /**
7
7
  * TimeField - Time picker input widget
8
8
  * Uses native time input for hour and minute selection
9
9
  */
10
- export function TimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function TimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  if (readonly) {
12
12
  return <span className="text-sm">{value || <EmptyValue />}</span>;
13
13
  }
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * UrlField - URL input with clickable link in readonly mode
7
7
  * Validates URLs to only render http/https links for security
8
8
  */
9
- export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
9
+ export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
10
  const config = field || (props as any).schema;
11
11
  if (readonly) {
12
12
  if (!value) return <EmptyValue />;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { FieldWidgetProps } from './types';
2
+ import { FieldWidgetComponentProps } from './types';
3
3
  import { LookupField } from './LookupField';
4
4
 
5
5
  /**
@@ -29,7 +29,7 @@ function withBannedFilter(filters?: any[]): any[] {
29
29
  : [...base, { field: 'banned', operator: 'ne', value: true }];
30
30
  }
31
31
 
32
- export function UserField(props: FieldWidgetProps<any>) {
32
+ export function UserField(props: FieldWidgetComponentProps<any>) {
33
33
  const raw = (props.field || (props as any).schema) as any;
34
34
 
35
35
  // The objectSchema field metadata may live directly on `field`, or nested at
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * VectorField - Vector/embedding data display
7
7
  * Shows vector embeddings in a read-only format
8
8
  */
9
- export function VectorField({ value, field, ...props }: FieldWidgetProps<number[]>) {
9
+ export function VectorField({ value, field, ...props }: FieldWidgetComponentProps<number[]>) {
10
10
  const vectorField = (field || (props as any).schema) as any;
11
11
  const dimensions = vectorField?.dimensions || (Array.isArray(value) ? value.length : 0);
12
12