@object-ui/fields 15.0.0 → 16.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 (42) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +45 -7
  3. package/dist/index.cjs +5 -5
  4. package/dist/index.d.ts +21 -0
  5. package/dist/index.js +1798 -1721
  6. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  7. package/dist/widgets/CheckboxesField.d.ts +8 -1
  8. package/dist/widgets/FileField.d.ts +1 -1
  9. package/dist/widgets/ImageField.d.ts +1 -1
  10. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  11. package/dist/widgets/MultiSelectField.d.ts +10 -2
  12. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  13. package/dist/widgets/RadioField.d.ts +8 -1
  14. package/dist/widgets/SelectField.d.ts +13 -9
  15. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  16. package/dist/widgets/types.d.ts +6 -0
  17. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  18. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  19. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  20. package/package.json +9 -9
  21. package/src/field-type-coverage.test.ts +3 -1
  22. package/src/index.tsx +47 -6
  23. package/src/standard-widgets.test.tsx +44 -0
  24. package/src/widgets/CapabilityMultiSelectField.test.tsx +26 -1
  25. package/src/widgets/CapabilityMultiSelectField.tsx +35 -10
  26. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  27. package/src/widgets/CheckboxesField.tsx +70 -7
  28. package/src/widgets/FileField.tsx +9 -1
  29. package/src/widgets/ImageField.tsx +45 -42
  30. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  31. package/src/widgets/MultiSelectField.tsx +72 -8
  32. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  33. package/src/widgets/RadioField.tsx +64 -7
  34. package/src/widgets/SelectField.tsx +33 -31
  35. package/src/widgets/TextAreaField.tsx +4 -2
  36. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  37. package/src/widgets/deriveLookupColumns.ts +5 -15
  38. package/src/widgets/types.ts +6 -0
  39. package/src/widgets/useCascadingOptions.ts +46 -0
  40. package/src/widgets/useFieldTranslation.ts +13 -0
  41. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  42. 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 ?? ''}
@@ -31,7 +88,7 @@ export function RadioField({ value, onChange, field, readonly, className, ...pro
31
88
  const id = `${groupId}-${opt.value}`;
32
89
  return (
33
90
  <div key={opt.value} className="flex items-center space-x-2">
34
- <RadioGroupItem value={opt.value} id={id} />
91
+ <RadioGroupItem value={opt.value} id={id} data-testid={`radio-option-${opt.value}`} />
35
92
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
36
93
  </div>
37
94
  );
@@ -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">
@@ -42,8 +46,6 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
42
46
  (props as any).mobile_fullscreen ??
43
47
  (props as any).schema?.mobile_fullscreen,
44
48
  );
45
- const [fullscreenOpen, setFullscreenOpen] = useState(false);
46
- const [draft, setDraft] = useState(value ?? '');
47
49
 
48
50
  const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
49
51
  const cancelFullscreen = () => setFullscreenOpen(false);
@@ -50,6 +50,19 @@ describe('deriveLookupColumns', () => {
50
50
  expect(deriveLookupColumns(accountSchema, { displayField: 'name', max: 2 })).toHaveLength(2);
51
51
  });
52
52
 
53
+ it('skips fields flagged `system: true` even when the name is not a known audit column', () => {
54
+ const schema = {
55
+ fields: {
56
+ name: { type: 'text', label: 'Name' },
57
+ // Non-canonical name, but stamped system by the framework — must be skipped.
58
+ custom_ref: { type: 'lookup', label: 'Custom Ref', system: true } as any,
59
+ code: { type: 'text', label: 'Code' },
60
+ },
61
+ };
62
+ const cols = deriveLookupColumns(schema, { displayField: 'name', max: 10 });
63
+ expect(cols.map((c) => c.field)).toEqual(['name', 'code']);
64
+ });
65
+
53
66
  it('honours an object-level displayFields list, with the display field first', () => {
54
67
  const schema = {
55
68
  fields: {
@@ -1,17 +1,5 @@
1
1
  import type { LookupColumnDef } from '@object-ui/types';
2
-
3
- /**
4
- * System / audit fields that carry no disambiguating value in a record picker.
5
- * Excluded from auto-derived columns so the picker shows business data, not
6
- * bookkeeping.
7
- */
8
- const SYSTEM_FIELDS = new Set<string>([
9
- 'id', '_id',
10
- 'created', 'modified', 'created_at', 'updated_at',
11
- 'created_by', 'updated_by', 'modified_by',
12
- 'owner_id', 'organization_id', 'space', 'company_id',
13
- 'instance_state', 'locked', 'is_deleted', 'deleted',
14
- ]);
2
+ import { isSystemManagedField } from '@object-ui/types';
15
3
 
16
4
  /**
17
5
  * Field types that don't render usefully as a compact picker column (large
@@ -31,7 +19,7 @@ export interface DeriveColumnsOptions {
31
19
  }
32
20
 
33
21
  interface ObjectSchemaLike {
34
- fields?: Record<string, { type?: string; label?: string; hidden?: boolean }>;
22
+ fields?: Record<string, { type?: string; label?: string; hidden?: boolean; system?: boolean }>;
35
23
  /**
36
24
  * ADR-0085 semantic role: the object's most important fields. The single
37
25
  * source for "how to list this object" — shared with the detail-page related
@@ -106,8 +94,10 @@ export function deriveLookupColumns(
106
94
  // 3. Derive from the field set in declaration order.
107
95
  const candidates = Object.keys(fields).filter((name) => {
108
96
  if (name === displayField) return false;
109
- if (SYSTEM_FIELDS.has(name)) return false;
110
97
  const f = fieldDef(name);
98
+ // Skip framework-managed system/audit/ownership columns (incl. owner_id) —
99
+ // they carry no disambiguating value in a record picker.
100
+ if (isSystemManagedField(name, f)) return false;
111
101
  if (f.hidden) return false;
112
102
  if (f.type && NON_TABULAR_TYPES.has(f.type)) return false;
113
103
  return true;
@@ -9,5 +9,11 @@ export type FieldWidgetProps<T = any> = {
9
9
  disabled?: boolean;
10
10
  className?: string;
11
11
  errorMessage?: string;
12
+ /**
13
+ * Upload widgets (`file`/`image`) fire this when their in-progress state
14
+ * flips, so a host can block submit until a presigned upload settles. Other
15
+ * widgets ignore it.
16
+ */
17
+ onUploadingChange?: (uploading: boolean) => void;
12
18
  [key: string]: any;
13
19
  }
@@ -0,0 +1,46 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import {
3
+ resolveCascadingOptions,
4
+ type CascadingOptions,
5
+ type OptionLike,
6
+ type DependsOnInput,
7
+ } from '@object-ui/core';
8
+ import { SchemaRendererContext, usePredicateScope } from '@object-ui/react';
9
+
10
+ /**
11
+ * Shared per-option cascading / role-gating resolution for the option widgets
12
+ * (`SelectField` single, `MultiSelectField`, `RadioField`) — the client half of
13
+ * ADR-0058 (#2284). Each option may carry a `visibleWhen` CEL predicate; the
14
+ * offered set narrows against the live form record (`record.country == 'cn'`) +
15
+ * the global predicate scope (`'admin' in current_user.positions`). A field
16
+ * declares which sibling fields drive its list via `dependsOn`; while any is
17
+ * empty the list is *gated* — callers surface a "select the parent first" hint
18
+ * rather than an unfiltered set, mirroring the dependent-lookup UX.
19
+ *
20
+ * This is the React wrapper that sources `record` (the live form values) and the
21
+ * predicate scope from context; the actual resolution is the pure
22
+ * {@link resolveCascadingOptions} in `@object-ui/core`, shared with the form
23
+ * renderer so gating/filtering can never drift between them (#2715).
24
+ */
25
+ export type CascadingOptionsResult<T extends OptionLike> = CascadingOptions<T>;
26
+
27
+ export function useCascadingOptions<T extends OptionLike>(
28
+ rawOptions: readonly T[],
29
+ dependsOn: DependsOnInput,
30
+ dependentValues: Record<string, unknown> | undefined,
31
+ ): CascadingOptionsResult<T> {
32
+ // Live form values for cascading options — injected by the form renderer as
33
+ // `dependentValues` (same channel dependent lookups use), falling back to the
34
+ // record on SchemaRendererContext. `current_user` etc. come from the global
35
+ // predicate scope so role/context predicates resolve too.
36
+ const ctx = useContext(SchemaRendererContext) as any;
37
+ const record = useMemo<Record<string, unknown>>(() => {
38
+ return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
39
+ }, [dependentValues, ctx?.formValues, ctx?.data]);
40
+ const predicateScope = usePredicateScope();
41
+
42
+ return useMemo(
43
+ () => resolveCascadingOptions(rawOptions, record, dependsOn, predicateScope),
44
+ [rawOptions, record, dependsOn, predicateScope],
45
+ );
46
+ }
@@ -39,6 +39,19 @@ const FIELD_DEFAULTS: Record<string, string> = {
39
39
  'lookup.nextPage': 'Next page',
40
40
  'lookup.jumpToPage': 'Jump to page',
41
41
  'lookup.retry': 'Retry',
42
+ // objectui#2600 B5 — capability picker scope group headers.
43
+ 'capability.group.platform': 'Platform',
44
+ 'capability.group.org': 'Organization',
45
+ 'capability.group.other': 'Other',
46
+ // objectui#2600 B5 — curated platform capability labels (registry serves
47
+ // English; dots in the api-name become underscores in the key).
48
+ 'capability.label.manage_users': 'Manage Users',
49
+ 'capability.label.manage_org_users': 'Manage Organization Users',
50
+ 'capability.label.manage_metadata': 'Manage Metadata',
51
+ 'capability.label.manage_platform_settings': 'Manage Platform Settings',
52
+ 'capability.label.setup_access': 'Setup Access',
53
+ 'capability.label.setup_write': 'Write Settings',
54
+ 'capability.label.studio_access': 'Studio Access',
42
55
  };
43
56
 
44
57
  export const useFieldTranslation = createSafeTranslation(
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useUploadingSignal } from './useUploadingSignal';
4
+
5
+ /**
6
+ * useUploadingSignal — surfaces an upload widget's in-progress flips to a host
7
+ * (ActionParamDialog blocks Confirm while a file/image param uploads). It must
8
+ * fire only when `uploading` changes and always call the latest callback.
9
+ */
10
+ describe('useUploadingSignal', () => {
11
+ it('fires with the initial value on mount', () => {
12
+ const cb = vi.fn();
13
+ renderHook(() => useUploadingSignal(false, cb));
14
+ expect(cb).toHaveBeenCalledTimes(1);
15
+ expect(cb).toHaveBeenLastCalledWith(false);
16
+ });
17
+
18
+ it('fires again only when `uploading` changes', () => {
19
+ const cb = vi.fn();
20
+ const { rerender } = renderHook(({ u }) => useUploadingSignal(u, cb), {
21
+ initialProps: { u: false },
22
+ });
23
+ cb.mockClear();
24
+
25
+ rerender({ u: true });
26
+ expect(cb).toHaveBeenCalledTimes(1);
27
+ expect(cb).toHaveBeenLastCalledWith(true);
28
+
29
+ // Same value → no extra fire.
30
+ rerender({ u: true });
31
+ expect(cb).toHaveBeenCalledTimes(1);
32
+
33
+ rerender({ u: false });
34
+ expect(cb).toHaveBeenCalledTimes(2);
35
+ expect(cb).toHaveBeenLastCalledWith(false);
36
+ });
37
+
38
+ it('always invokes the latest callback (no stale closure)', () => {
39
+ const first = vi.fn();
40
+ const second = vi.fn();
41
+ const { rerender } = renderHook(
42
+ ({ cb, u }) => useUploadingSignal(u, cb),
43
+ { initialProps: { cb: first, u: false } },
44
+ );
45
+ // Swap the callback without changing `uploading` → no fire yet.
46
+ rerender({ cb: second, u: false });
47
+ expect(second).not.toHaveBeenCalled();
48
+ // Now flip `uploading` → the NEW callback fires, not the old one.
49
+ rerender({ cb: second, u: true });
50
+ expect(second).toHaveBeenCalledWith(true);
51
+ expect(first).not.toHaveBeenCalledWith(true);
52
+ });
53
+
54
+ it('is a no-op when no callback is provided', () => {
55
+ expect(() => {
56
+ const { rerender } = renderHook(({ u }) => useUploadingSignal(u), {
57
+ initialProps: { u: false },
58
+ });
59
+ rerender({ u: true });
60
+ }).not.toThrow();
61
+ });
62
+ });
@@ -0,0 +1,20 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * Notify a parent whenever an upload widget's in-progress state flips. Used by
5
+ * hosts that must block a submit until the presigned upload resolves — e.g.
6
+ * `ActionParamDialog` disables Confirm so a `file`/`image` param can't be
7
+ * submitted mid-upload (the value only becomes the fileId once the upload
8
+ * settles). Fires with the latest callback and only when `uploading` actually
9
+ * changes, so an inline arrow prop doesn't thrash the parent every render.
10
+ */
11
+ export function useUploadingSignal(
12
+ uploading: boolean,
13
+ onUploadingChange?: (uploading: boolean) => void,
14
+ ): void {
15
+ const ref = useRef(onUploadingChange);
16
+ ref.current = onUploadingChange;
17
+ useEffect(() => {
18
+ ref.current?.(uploading);
19
+ }, [uploading]);
20
+ }