@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.
- package/CHANGELOG.md +219 -0
- package/README.md +45 -7
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1798 -1721
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/SelectField.d.ts +13 -9
- package/dist/widgets/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +9 -9
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +47 -6
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CapabilityMultiSelectField.test.tsx +26 -1
- package/src/widgets/CapabilityMultiSelectField.tsx +35 -10
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +70 -7
- package/src/widgets/FileField.tsx +9 -1
- package/src/widgets/ImageField.tsx +45 -42
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +72 -8
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +64 -7
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +4 -2
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +13 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- 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
|
-
|
|
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({
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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, {
|
|
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 -
|
|
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
|
-
|
|
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 =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
package/src/widgets/types.ts
CHANGED
|
@@ -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
|
+
}
|