@object-ui/fields 11.5.0 → 13.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.
File without changes
@@ -1,7 +1,14 @@
1
1
  import { default as React } from 'react';
2
2
  import { FieldWidgetProps } from './types';
3
3
  /**
4
- * SelectField - Dropdown selection widget with configurable options
5
- * Renders options from field metadata with support for placeholder and readonly display
4
+ * SelectField - Dropdown selection widget with configurable options.
5
+ *
6
+ * Supports cascading / role-gated options (#2284): each option may carry a
7
+ * `visibleWhen` CEL predicate, evaluated against the live form record +
8
+ * `current_user`, so the offered set narrows as a controlling field changes
9
+ * (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
10
+ * field declares which sibling fields drive its list via `dependsOn`; while any
11
+ * of those is empty the control is gated with a "select the parent first" hint,
12
+ * mirroring the dependent-lookup UX.
6
13
  */
7
- export declare function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>): React.JSX.Element;
14
+ export declare function SelectField({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Open the browser's native picker (date / datetime-local / time) for an input.
3
+ *
4
+ * Native date/time inputs only open their picker when the user clicks the small
5
+ * calendar/clock icon. Calling `showPicker()` on click makes the whole field
6
+ * behave like other widgets — clicking anywhere in the box opens the picker.
7
+ *
8
+ * `showPicker()` can throw (unsupported browser, not user-activated, or the input
9
+ * is disabled/hidden), so failures are swallowed and the native fallback stands.
10
+ */
11
+ export declare function openNativePicker(input: HTMLInputElement): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "11.5.0",
3
+ "version": "13.1.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,12 +30,12 @@
30
30
  "rehype-sanitize": "^6.0.0",
31
31
  "remark-gfm": "^4.0.1",
32
32
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "11.5.0",
34
- "@object-ui/core": "11.5.0",
35
- "@object-ui/i18n": "11.5.0",
36
- "@object-ui/providers": "11.5.0",
37
- "@object-ui/react": "11.5.0",
38
- "@object-ui/types": "11.5.0"
33
+ "@object-ui/components": "13.1.0",
34
+ "@object-ui/core": "13.1.0",
35
+ "@object-ui/i18n": "13.1.0",
36
+ "@object-ui/providers": "13.1.0",
37
+ "@object-ui/react": "13.1.0",
38
+ "@object-ui/types": "13.1.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.0.0 || ^19.0.0",
@@ -46,7 +46,7 @@
46
46
  "@types/react-dom": "19.2.3",
47
47
  "@vitejs/plugin-react": "^6.0.3",
48
48
  "typescript": "^6.0.3",
49
- "vite": "^8.1.0",
49
+ "vite": "^8.1.3",
50
50
  "vite-plugin-dts": "^5.0.3"
51
51
  },
52
52
  "publishConfig": {
package/src/index.tsx CHANGED
@@ -27,7 +27,7 @@ const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
27
27
  * Pick the most reasonable display name from an arbitrary record object.
28
28
  * Tries common name-like keys in priority order, then falls back to undefined.
29
29
  */
30
- function pickRecordDisplayName(
30
+ export function pickRecordDisplayName(
31
31
  record: Record<string, unknown> | null | undefined,
32
32
  preferredField?: string,
33
33
  ): string | undefined {
@@ -1450,7 +1450,13 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
1450
1450
  } else {
1451
1451
  text = String(value);
1452
1452
  }
1453
- return <span className="font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1453
+ // inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
1454
+ // actually clamps to the cell width. On a bare inline <span> truncate never
1455
+ // clips — there is no width box — and its `white-space:nowrap` also defeats
1456
+ // the parent cell's `break-words`, so a long name-keyed map / address JSON
1457
+ // spills into the neighbouring column (objectui#2578). The title keeps the
1458
+ // full value on hover.
1459
+ return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1454
1460
  }
1455
1461
 
1456
1462
  /**
@@ -1760,25 +1766,36 @@ export function formatFileSize(bytes: number): string {
1760
1766
  export function buildValidationRules(field: any): any {
1761
1767
  const rules: any = {};
1762
1768
 
1763
- // Required validation
1769
+ // Required validation. Emit a bare `true` for the auto-generated case rather
1770
+ // than baking an English message here: the form renderer localizes it via
1771
+ // `t('validation.required', { field })`. A field-authored `required_message`
1772
+ // still wins and is passed through verbatim.
1764
1773
  if (field.required) {
1765
- rules.required = typeof field.required_message === 'string'
1766
- ? field.required_message
1767
- : `${field.label || field.name} is required`;
1774
+ rules.required = typeof field.required_message === 'string'
1775
+ ? field.required_message
1776
+ : true;
1768
1777
  }
1769
1778
 
1779
+ // Standard validation messages are localized by the form renderer, which has
1780
+ // an i18n `t` in scope. We therefore emit `message: undefined` for the
1781
+ // auto-generated case and tag each rule with a `messageKey` (+ any interp
1782
+ // vars); a field-authored `*_message` is a string and passes through as-is,
1783
+ // still winning over the localized default. See form.tsx `localizeRule`.
1784
+
1770
1785
  // Length validation for text fields
1771
1786
  if (field.min_length) {
1772
1787
  rules.minLength = {
1773
1788
  value: field.min_length,
1774
- message: field.min_length_message || `Minimum length is ${field.min_length} characters`,
1789
+ message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
1790
+ messageKey: 'validation.minLength',
1775
1791
  };
1776
1792
  }
1777
1793
 
1778
1794
  if (field.max_length) {
1779
1795
  rules.maxLength = {
1780
1796
  value: field.max_length,
1781
- message: field.max_length_message || `Maximum length is ${field.max_length} characters`,
1797
+ message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
1798
+ messageKey: 'validation.maxLength',
1782
1799
  };
1783
1800
  }
1784
1801
 
@@ -1786,14 +1803,16 @@ export function buildValidationRules(field: any): any {
1786
1803
  if (field.min !== undefined) {
1787
1804
  rules.min = {
1788
1805
  value: field.min,
1789
- message: field.min_message || `Minimum value is ${field.min}`,
1806
+ message: typeof field.min_message === 'string' ? field.min_message : undefined,
1807
+ messageKey: 'validation.min',
1790
1808
  };
1791
1809
  }
1792
1810
 
1793
1811
  if (field.max !== undefined) {
1794
1812
  rules.max = {
1795
1813
  value: field.max,
1796
- message: field.max_message || `Maximum value is ${field.max}`,
1814
+ message: typeof field.max_message === 'string' ? field.max_message : undefined,
1815
+ messageKey: 'validation.max',
1797
1816
  };
1798
1817
  }
1799
1818
 
@@ -1801,7 +1820,8 @@ export function buildValidationRules(field: any): any {
1801
1820
  if (field.pattern) {
1802
1821
  rules.pattern = {
1803
1822
  value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
1804
- message: field.pattern_message || 'Invalid format',
1823
+ message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
1824
+ messageKey: 'validation.pattern',
1805
1825
  };
1806
1826
  }
1807
1827
 
@@ -1809,7 +1829,8 @@ export function buildValidationRules(field: any): any {
1809
1829
  if (field.type === 'email') {
1810
1830
  rules.pattern = {
1811
1831
  value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
1812
- message: 'Please enter a valid email address',
1832
+ message: undefined,
1833
+ messageKey: 'validation.email',
1813
1834
  };
1814
1835
  }
1815
1836
 
@@ -1817,7 +1838,8 @@ export function buildValidationRules(field: any): any {
1817
1838
  if (field.type === 'url') {
1818
1839
  rules.pattern = {
1819
1840
  value: /^https?:\/\/.+/,
1820
- message: 'Please enter a valid URL',
1841
+ message: undefined,
1842
+ messageKey: 'validation.url',
1821
1843
  };
1822
1844
  }
1823
1845
 
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { openNativePicker } from './openNativePicker';
4
5
 
5
6
  /**
6
7
  * DateField - Date picker input widget
@@ -20,6 +21,10 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
20
21
  type="date"
21
22
  value={value || ''}
22
23
  onChange={(e) => onChange(e.target.value)}
24
+ onClick={(e) => {
25
+ openNativePicker(e.currentTarget);
26
+ domProps.onClick?.(e);
27
+ }}
23
28
  disabled={readonly || domProps.disabled}
24
29
  />
25
30
  );
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { openNativePicker } from './openNativePicker';
4
5
 
5
6
  /**
6
7
  * DateTimeField - Combined date and time picker widget
@@ -26,6 +27,10 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
26
27
  type="datetime-local"
27
28
  value={value || ''}
28
29
  onChange={(e) => onChange(e.target.value)}
30
+ onClick={(e) => {
31
+ openNativePicker(e.currentTarget);
32
+ domProps.onClick?.(e);
33
+ }}
29
34
  disabled={readonly || domProps.disabled}
30
35
  />
31
36
  );
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Cascading / role-gated `select` options (#2284).
11
+ *
12
+ * Exercises the observable, non-Radix behaviour of {@link SelectField}: the
13
+ * dependency gate (a "select the parent first" empty-state) and the cascade
14
+ * clear (a value dropped when the offered set no longer includes it). The
15
+ * per-option filtering itself is unit-tested in `@object-ui/core`
16
+ * (`optionRules.test.ts`); here we prove the widget wires `dependentValues` +
17
+ * predicate scope into that resolver.
18
+ */
19
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
20
+ import React from 'react';
21
+ import { render, screen } from '@testing-library/react';
22
+ import '@testing-library/jest-dom';
23
+ import { PredicateScopeProvider } from '@object-ui/react';
24
+ import { SelectField } from './SelectField';
25
+
26
+ const provinceField = {
27
+ name: 'province',
28
+ type: 'select',
29
+ dependsOn: 'country',
30
+ options: [
31
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
32
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
33
+ ],
34
+ } as any;
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ describe('SelectField — dependency gating (#2284)', () => {
41
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
42
+ render(
43
+ <SelectField
44
+ value={undefined}
45
+ onChange={vi.fn()}
46
+ field={provinceField}
47
+ {...({ name: 'province', dependentValues: {} } as any)}
48
+ />,
49
+ );
50
+ const gate = screen.getByTestId('select-empty-province');
51
+ expect(gate).toHaveTextContent(/select country first/i);
52
+ // Gated → no live combobox is offered.
53
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('unlocks the combobox once the controlling field is set', () => {
57
+ render(
58
+ <SelectField
59
+ value={undefined}
60
+ onChange={vi.fn()}
61
+ field={provinceField}
62
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
63
+ />,
64
+ );
65
+ expect(screen.queryByTestId('select-empty-province')).not.toBeInTheDocument();
66
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
67
+ });
68
+ });
69
+
70
+ describe('SelectField — cascade clear (#2284)', () => {
71
+ it('clears a value the parent change no longer offers', () => {
72
+ const onChange = vi.fn();
73
+ render(
74
+ <SelectField
75
+ value={'ca'}
76
+ onChange={onChange}
77
+ field={provinceField}
78
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
79
+ />,
80
+ );
81
+ // 'ca' is a US province — under country=cn it is not offered, so it is dropped.
82
+ expect(onChange).toHaveBeenCalledWith(undefined);
83
+ });
84
+
85
+ it('keeps a value that is still offered', () => {
86
+ const onChange = vi.fn();
87
+ render(
88
+ <SelectField
89
+ value={'zj'}
90
+ onChange={onChange}
91
+ field={provinceField}
92
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
93
+ />,
94
+ );
95
+ expect(onChange).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+
99
+ describe('SelectField — role / context gating (#2284)', () => {
100
+ const tierField = {
101
+ name: 'tier',
102
+ type: 'select',
103
+ options: [
104
+ { label: 'Standard', value: 'standard' },
105
+ { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
106
+ ],
107
+ } as any;
108
+
109
+ it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
110
+ const onChange = vi.fn();
111
+ render(
112
+ <PredicateScopeProvider scope={{ current_user: { roles: ['sales'] } }}>
113
+ <SelectField
114
+ value={'admin_only'}
115
+ onChange={onChange}
116
+ field={tierField}
117
+ {...({ name: 'tier', dependentValues: {} } as any)}
118
+ />
119
+ </PredicateScopeProvider>,
120
+ );
121
+ expect(onChange).toHaveBeenCalledWith(undefined);
122
+ });
123
+
124
+ it('keeps an admin-only value for an admin', () => {
125
+ const onChange = vi.fn();
126
+ render(
127
+ <PredicateScopeProvider scope={{ current_user: { roles: ['admin'] } }}>
128
+ <SelectField
129
+ value={'admin_only'}
130
+ onChange={onChange}
131
+ field={tierField}
132
+ {...({ name: 'tier', dependentValues: {} } as any)}
133
+ />
134
+ </PredicateScopeProvider>,
135
+ );
136
+ expect(onChange).not.toHaveBeenCalled();
137
+ });
138
+ });
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useContext, useEffect, useMemo } from 'react';
2
2
  import {
3
3
  Select,
4
4
  SelectContent,
@@ -7,17 +7,42 @@ 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
17
  import { SelectFieldMetadata } from '@object-ui/types';
11
18
  import { useFieldTranslation } from './useFieldTranslation';
12
19
  import { FieldWidgetProps } from './types';
13
20
 
14
21
  /**
15
- * SelectField - Dropdown selection widget with configurable options
16
- * Renders options from field metadata with support for placeholder and readonly display
22
+ * SelectField - Dropdown selection widget with configurable options.
23
+ *
24
+ * Supports cascading / role-gated options (#2284): each option may carry a
25
+ * `visibleWhen` CEL predicate, evaluated against the live form record +
26
+ * `current_user`, so the offered set narrows as a controlling field changes
27
+ * (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
28
+ * field declares which sibling fields drive its list via `dependsOn`; while any
29
+ * of those is empty the control is gated with a "select the parent first" hint,
30
+ * mirroring the dependent-lookup UX.
17
31
  */
18
- export function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
19
- const config = (field || (props as any).schema) as SelectFieldMetadata;
20
- const options = config?.options || [];
32
+ export function SelectField({
33
+ value,
34
+ onChange,
35
+ field,
36
+ readonly,
37
+ schema,
38
+ dependentValues,
39
+ dependsOn: dependsOnProp,
40
+ emptyHint: _emptyHint,
41
+ dataSource: _dataSource,
42
+ ...props
43
+ }: FieldWidgetProps<string>) {
44
+ const config = (field || schema) as SelectFieldMetadata;
45
+ const rawOptions = config?.options || [];
21
46
  const { t } = useFieldTranslation();
22
47
  // Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
23
48
  // driven by synthetic DOM events, so e2e must target the trigger/options by a
@@ -25,32 +50,67 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
25
50
  // react-hook-form field name spread in by the form renderer (FormField).
26
51
  const fieldName = (props as any).name || (config as any)?.name || props.id || '';
27
52
 
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
+ 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],
75
+ );
76
+
77
+ // Cascade clear: once the offered set no longer includes the current value
78
+ // (parent changed / predicate flipped), drop it so no stale pair persists.
79
+ useEffect(() => {
80
+ if (readonly) return;
81
+ if (value === undefined || value === null || (value as unknown) === '') return;
82
+ if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [options, gated]);
85
+
28
86
  if (readonly) {
29
- const option = options.find((o) => o.value === value);
87
+ const option = rawOptions.find((o) => o.value === value);
30
88
  const display = option?.label || value;
31
89
  return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
32
90
  }
33
91
 
34
92
  // A select with no options is unfillable — a silently-empty Radix dropdown
35
- // reads as "broken widget" and hides the real cause (the field metadata has
36
- // no `options`). Surface a legible empty state instead, without needing to
37
- // open the popover. Mirrors the inline form renderer's behaviour
38
- // (see plugin form `renderFieldComponent`'s `case 'select'`).
93
+ // reads as "broken widget" and hides the real cause. Surface a legible state:
94
+ // a dependency-gated list prompts for its controlling field; an unconfigured
95
+ // list says so. Mirrors the inline form renderer's behaviour.
39
96
  if (options.length === 0) {
97
+ const hint = gated
98
+ ? `Select ${dependsOnFields.join(' / ')} first`
99
+ : 'No options available';
40
100
  return (
41
101
  <div
42
102
  data-testid={fieldName ? `select-empty-${fieldName}` : undefined}
43
103
  className="flex h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
44
104
  >
45
- No options available
105
+ {hint}
46
106
  </div>
47
107
  );
48
108
  }
49
109
 
50
110
  return (
51
- <Select
111
+ <Select
52
112
  {...props}
53
- value={value}
113
+ value={value}
54
114
  onValueChange={onChange}
55
115
  disabled={readonly || props.disabled}
56
116
  >
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { openNativePicker } from './openNativePicker';
4
5
 
5
6
  /**
6
7
  * TimeField - Time picker input widget
@@ -20,6 +21,10 @@ export function TimeField({ value, onChange, field, readonly, ...props }: FieldW
20
21
  type="time"
21
22
  value={value || ''}
22
23
  onChange={(e) => onChange(e.target.value)}
24
+ onClick={(e) => {
25
+ openNativePicker(e.currentTarget);
26
+ domProps.onClick?.(e);
27
+ }}
23
28
  disabled={readonly || domProps.disabled}
24
29
  />
25
30
  );
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Open the browser's native picker (date / datetime-local / time) for an input.
3
+ *
4
+ * Native date/time inputs only open their picker when the user clicks the small
5
+ * calendar/clock icon. Calling `showPicker()` on click makes the whole field
6
+ * behave like other widgets — clicking anywhere in the box opens the picker.
7
+ *
8
+ * `showPicker()` can throw (unsupported browser, not user-activated, or the input
9
+ * is disabled/hidden), so failures are swallowed and the native fallback stands.
10
+ */
11
+ export function openNativePicker(input: HTMLInputElement): void {
12
+ if (input.disabled || input.readOnly) return;
13
+ try {
14
+ (input as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
15
+ } catch {
16
+ // Ignore — browsers throw when the picker can't be shown; the icon still works.
17
+ }
18
+ }