@object-ui/fields 16.0.0 → 17.1.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 (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. package/src/widgets/useUploadingSignal.ts +20 -0
@@ -1,25 +1,82 @@
1
- import React, { useId } from 'react';
1
+ import React, { useId, useEffect } from 'react';
2
2
  import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
+ import { isValueStillOffered, type OptionLike } from '@object-ui/core';
3
4
  import { FieldWidgetProps } from './types';
5
+ import { useCascadingOptions } from './useCascadingOptions';
4
6
 
5
- interface Option { label: string; value: string }
7
+ type Option = OptionLike;
6
8
 
7
9
  /**
8
10
  * RadioField - choose exactly one value from a fixed option set, rendered as a
9
11
  * radio group. The stored value is the selected option value (string). Used for
10
12
  * the `radio` field type.
13
+ *
14
+ * Like the single `SelectField`, options support per-option `visibleWhen`
15
+ * cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
16
+ * {@link useCascadingOptions} hook: the offered radios narrow against the live
17
+ * form record + `current_user`, the control is gated behind a "select the parent
18
+ * first" hint while a dependency is empty, and a value no longer offered (parent
19
+ * changed / predicate flipped) is cleared.
11
20
  */
12
- export function RadioField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
13
- const config = (field || (props as any).schema) as any;
14
- const options: Option[] = config?.options || [];
21
+ export function RadioField({
22
+ value,
23
+ onChange,
24
+ field,
25
+ readonly,
26
+ className,
27
+ schema,
28
+ dependentValues,
29
+ dependsOn: dependsOnProp,
30
+ emptyHint: _emptyHint,
31
+ dataSource: _dataSource,
32
+ ...props
33
+ }: FieldWidgetProps<string>) {
34
+ const config = (field || schema) as any;
35
+ const rawOptions: Option[] = config?.options || [];
15
36
  const groupId = useId();
37
+ const fieldName = (props as any).name || config?.name || (props as any).id || '';
38
+
39
+ const dependsOn = config?.dependsOn ?? dependsOnProp;
40
+ const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
41
+ rawOptions,
42
+ dependsOn,
43
+ dependentValues,
44
+ );
45
+
46
+ // Cascade clear: once the offered set no longer includes the current value
47
+ // (parent changed / predicate flipped), drop it so no stale pair persists.
48
+ useEffect(() => {
49
+ if (readonly) return;
50
+ if (value === undefined || value === null || (value as unknown) === '') return;
51
+ if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [options, gated]);
16
54
 
17
55
  if (readonly) {
18
56
  if (value == null || value === '') return <EmptyValue />;
19
- const opt = options.find((o) => o.value === value);
57
+ // Label from the raw set so a stored value hidden by `visibleWhen` still
58
+ // renders its label rather than a bare id.
59
+ const opt = rawOptions.find((o) => o.value === value);
20
60
  return <span className="text-sm">{opt?.label || String(value)}</span>;
21
61
  }
22
62
 
63
+ // No offered options is unfillable — surface a legible state instead of an
64
+ // empty radio group: a dependency-gated list prompts for its controlling
65
+ // field; an unconfigured / fully-filtered list says so. Mirrors the select.
66
+ if (options.length === 0) {
67
+ const hint = gated
68
+ ? `Select ${dependsOnFields.join(' / ')} first`
69
+ : 'No options available';
70
+ return (
71
+ <div
72
+ data-testid={fieldName ? `radio-empty-${fieldName}` : undefined}
73
+ className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
74
+ >
75
+ {hint}
76
+ </div>
77
+ );
78
+ }
79
+
23
80
  return (
24
81
  <RadioGroup
25
82
  value={value ?? ''}
@@ -28,10 +85,13 @@ export function RadioField({ value, onChange, field, readonly, className, ...pro
28
85
  className={className}
29
86
  >
30
87
  {options.map((opt) => {
31
- 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}`;
32
92
  return (
33
- <div key={opt.value} className="flex items-center space-x-2">
34
- <RadioGroupItem value={opt.value} id={id} />
93
+ <div key={value} className="flex items-center space-x-2">
94
+ <RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
35
95
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
36
96
  </div>
37
97
  );
@@ -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
+ });
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { FieldWidgetProps } 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({
@@ -49,6 +56,7 @@ export function RecipientPickerField({
49
56
  ...props
50
57
  }: FieldWidgetProps<string>) {
51
58
  const ctx = React.useContext(SchemaRendererContext);
59
+ const { t } = useFieldTranslation();
52
60
  const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
53
61
  const disabled = (props as any).disabled as boolean | undefined;
54
62
  const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
@@ -57,12 +65,18 @@ export function RecipientPickerField({
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,4 +1,4 @@
1
- import React, { useContext, useEffect, useMemo } from 'react';
1
+ import React, { useEffect } from 'react';
2
2
  import {
3
3
  Select,
4
4
  SelectContent,
@@ -7,19 +7,38 @@ import {
7
7
  SelectValue,
8
8
  EmptyValue,
9
9
  } from '@object-ui/components';
10
- import {
11
- resolveVisibleOptions,
12
- isOptionGroupGated,
13
- resolveDependsOnFields,
14
- isValueStillOffered,
15
- } from '@object-ui/core';
16
- import { SchemaRendererContext, usePredicateScope } from '@object-ui/react';
10
+ import { isValueStillOffered } from '@object-ui/core';
17
11
  import { SelectFieldMetadata } from '@object-ui/types';
18
12
  import { useFieldTranslation } from './useFieldTranslation';
19
13
  import { FieldWidgetProps } from './types';
14
+ import { MultiSelectField } from './MultiSelectField';
15
+ import { useCascadingOptions } from './useCascadingOptions';
20
16
 
21
17
  /**
22
- * SelectField - Dropdown selection widget with configurable options.
18
+ * SelectField - dropdown selection widget.
19
+ *
20
+ * A field declared `multiple: true` selects zero-or-more values (spec:
21
+ * `multiple` is valid on `select`), so it renders the multi-value chip picker
22
+ * — the same widget the `multiselect` type uses. Delegating here (rather than
23
+ * only at a type-resolution layer) means every surface that renders the
24
+ * `select` widget — the object form, the inline grid editor, and
25
+ * `ActionParamDialog` — inherits multi-select identically, with no drift
26
+ * between them. Single-value selects keep the cascading dropdown below.
27
+ *
28
+ * Both branches resolve per-option `visibleWhen` cascading / role-gating through
29
+ * the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
30
+ * in lockstep.
31
+ */
32
+ export function SelectField(props: FieldWidgetProps<any>) {
33
+ const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
34
+ if ((config as any)?.multiple) {
35
+ return <MultiSelectField {...props} />;
36
+ }
37
+ return <SingleSelectField {...(props as FieldWidgetProps<string>)} />;
38
+ }
39
+
40
+ /**
41
+ * SingleSelectField - single-value dropdown with configurable options.
23
42
  *
24
43
  * Supports cascading / role-gated options (#2284): each option may carry a
25
44
  * `visibleWhen` CEL predicate, evaluated against the live form record +
@@ -29,7 +48,7 @@ import { FieldWidgetProps } from './types';
29
48
  * of those is empty the control is gated with a "select the parent first" hint,
30
49
  * mirroring the dependent-lookup UX.
31
50
  */
32
- export function SelectField({
51
+ function SingleSelectField({
33
52
  value,
34
53
  onChange,
35
54
  field,
@@ -50,28 +69,11 @@ export function SelectField({
50
69
  // react-hook-form field name spread in by the form renderer (FormField).
51
70
  const fieldName = (props as any).name || (config as any)?.name || props.id || '';
52
71
 
53
- // Live form values for cascading options — injected by the form renderer as
54
- // `dependentValues` (same channel dependent lookups use), falling back to the
55
- // record on SchemaRendererContext. `current_user` etc. come from the global
56
- // predicate scope so role/context predicates resolve too.
57
- const ctx = useContext(SchemaRendererContext) as any;
58
- const record = useMemo<Record<string, unknown>>(() => {
59
- return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
60
- }, [dependentValues, ctx?.formValues, ctx?.data]);
61
- const predicateScope = usePredicateScope();
62
-
63
72
  const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
64
- const dependsOnFields = useMemo(() => resolveDependsOnFields(dependsOn), [dependsOn]);
65
- const gated = useMemo(
66
- () => dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record),
67
- [dependsOnFields, dependsOn, record],
68
- );
69
-
70
- // Effective (offered) options after per-option `visibleWhen` filtering. Empty
71
- // while gated so we never present an unfiltered set before the parent is set.
72
- const options = useMemo(
73
- () => (gated ? [] : resolveVisibleOptions(rawOptions, record, predicateScope)),
74
- [gated, rawOptions, record, predicateScope],
73
+ const { options, gated, dependsOnFields } = useCascadingOptions(
74
+ rawOptions,
75
+ dependsOn,
76
+ dependentValues,
75
77
  );
76
78
 
77
79
  // Cascade clear: once the offered set no longer includes the current value
@@ -22,6 +22,10 @@ import { FieldWidgetProps } from './types';
22
22
  * textarea trapped between other fields.
23
23
  */
24
24
  export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
25
+ // Hooks must run before any early return (readonly) to keep hook order stable.
26
+ const [fullscreenOpen, setFullscreenOpen] = useState(false);
27
+ const [draft, setDraft] = useState(value ?? '');
28
+
25
29
  if (readonly) {
26
30
  return (
27
31
  <div className="text-sm whitespace-pre-wrap">
@@ -32,7 +36,10 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
32
36
 
33
37
  const textareaField = (field || (props as any).schema) as any;
34
38
  const rows = textareaField?.rows || 4;
35
- 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;
36
43
  // Mobile fullscreen flag may arrive on the field metadata, on the form-field
37
44
  // schema (when called via the form renderer where `field` is the ObjectQL
38
45
  // metadata sub-object), or as an explicit widget prop.
@@ -42,8 +49,6 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
42
49
  (props as any).mobile_fullscreen ??
43
50
  (props as any).schema?.mobile_fullscreen,
44
51
  );
45
- const [fullscreenOpen, setFullscreenOpen] = useState(false);
46
- const [draft, setDraft] = useState(value ?? '');
47
52
 
48
53
  const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
49
54
  const cancelFullscreen = () => setFullscreenOpen(false);
@@ -0,0 +1,66 @@
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
+ * Empty-criteria guard for the sharing-rule criteria builder
11
+ * (objectstack#3896).
12
+ *
13
+ * This widget used to tell an admin that an empty criteria meant "All
14
+ * records" — the most dangerous thing it could say, because it was describing
15
+ * a bug as a feature. A sharing rule with no predicate was stored as
16
+ * `criteria_json: null` and then evaluated as `find(object, { filter: {} })`,
17
+ * i.e. every record of the object granted to the recipient. `SharingRuleSchema`
18
+ * had always forbidden that shape ("never seeded as a permissive match-all",
19
+ * ADR-0049); the REST and data-API entries just never checked.
20
+ *
21
+ * The server now refuses to save such a rule and shares nothing for one
22
+ * already stored. These tests pin the client-side mirror of that predicate, so
23
+ * the widget warns while the admin is still looking at the empty builder
24
+ * rather than after a rejected save.
25
+ */
26
+ import { describe, it, expect } from 'vitest';
27
+ import { isMatchAllCriteria } from '../FilterConditionField';
28
+
29
+ describe('isMatchAllCriteria — shapes that would share every record', () => {
30
+ it('flags the empty and vacuous shapes', () => {
31
+ for (const shape of [
32
+ undefined,
33
+ null,
34
+ {}, // what a blank builder / blank textarea parses to
35
+ [],
36
+ true,
37
+ 42,
38
+ { $and: [] },
39
+ { $or: [] },
40
+ { $and: [{}] },
41
+ { $or: [{ stage: 'won' }, {}] }, // one unconstrained branch opens the OR
42
+ ]) {
43
+ expect(isMatchAllCriteria(shape), `${JSON.stringify(shape) ?? String(shape)}`).toBe(true);
44
+ }
45
+ });
46
+
47
+ it('passes anything that actually narrows the result set', () => {
48
+ for (const shape of [
49
+ { stage: 'won' },
50
+ { amount: { $gte: 100000 } },
51
+ [{ stage: 'won' }],
52
+ { $and: [{ stage: 'won' }] },
53
+ { $or: [{ a: 1 }, { b: 2 }] },
54
+ { health: 'red', budget: { $gt: 1 } },
55
+ ]) {
56
+ expect(isMatchAllCriteria(shape), JSON.stringify(shape)).toBe(false);
57
+ }
58
+ });
59
+
60
+ it('agrees with the server on the reported case: a rule saved with no criteria', () => {
61
+ // The builder emits '' for an empty group, which the widget parses to {}.
62
+ expect(isMatchAllCriteria({})).toBe(true);
63
+ // …and one real condition is enough to clear the gate.
64
+ expect(isMatchAllCriteria({ health: 'red' })).toBe(false);
65
+ });
66
+ });