All files / blong-browser/src/widgets DropdownWidget.tsx

0% Statements 0/196
0% Branches 0/1
0% Functions 0/1
0% Lines 0/196

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197                                                                                                                                                                                                                                                                                                                                                                                                         
import {Dropdown} from '../primereact/index.js';

import type {IDropdownOption, IWidgetProps} from '@feasibleone/blong';
import {useEffect, useRef, useState} from 'react';
import {useWatch, type Control} from 'react-hook-form';
import {useBlongForm} from '../components/Form/FormContext.js';
import {useBlong} from '../context/BlongContext.js';
import {dropdownRegistry} from '../model/dropdownRegistry.js';

type SelectOption = IDropdownOption;

function toOptions(data: unknown): SelectOption[] {
    if (!Array.isArray(data)) return [];
    return data.map((item: Record<string, unknown>) => ({
        ...item, // preserve extra properties (e.g., parent field keys for cascade filtering)
        label: String(item.label ?? item.name ?? item.value ?? item),
        value: item.value ?? item,
    }));
}

/**
 * Core rendering logic for DropdownWidget.
 * Receives `parentValue` already resolved — either from `useWatch` (inside a Form)
 * or `undefined` (standalone / no cascade needed).
 */
function DropdownCore({
    id,
    name,
    schema,
    value,
    onChange,
    onBlur,
    error,
    readOnly,
    disabled,
    parentValue,
}: IWidgetProps & {parentValue: unknown}) {
    const {
        fetch: fetchAction,
        options: staticOptions,
        dropdown: dropdownKey,
        parent,
    } = schema.widget ?? {};
    const {handler} = useBlong();

    const [options, setOptions] = useState<SelectOption[]>(() =>
        staticOptions ? toOptions(staticOptions) : [],
    );

    // Clear this widget's value when the parent selection changes
    const prevParentValueRef = useRef<unknown>(parentValue);
    useEffect(() => {
        if (parent === undefined) return;
        if (prevParentValueRef.current !== parentValue) {
            prevParentValueRef.current = parentValue;
            if (value !== undefined && value !== null) onChange(undefined);
        }
    }, [parentValue, parent, onChange, value]);

    useEffect(() => {
        let cancelled = false;

        // Skip async load when static options are already provided
        if (staticOptions) return;

        // Priority 1: named dropdown via portal orchestrator (handles batching + caching)
        if (dropdownKey) {
            const loader = (key: string) =>
                (
                    handler.portalDropdownList({names: [key]}, {}) as Promise<
                        Record<string, unknown>
                    >
                ).then(result => toOptions(result[key]));

            dropdownRegistry
                .get(dropdownKey, loader)
                .then(data => {
                    if (!cancelled) setOptions(data);
                })
                .catch(() => {
                    // Error already surfaced by the central dispatch wrapper in
                    // BlongProvider. Widget renders with empty options.
                });

            return () => {
                cancelled = true;
            };
        }

        // Priority 2: explicit fetch action (re-fetch when parent value changes)
        if (!fetchAction) return;
        const params: Record<string, unknown> = {};
        if (parent && parentValue !== undefined) params[parent] = parentValue;
        (handler[fetchAction](params, {}) as Promise<unknown>)
            .then(data => {
                if (!cancelled) setOptions(toOptions(data));
            })
            .catch(() => {});
        return () => {
            cancelled = true;
        };
        // eslint-disable-next-line @eslint-react/exhaustive-deps -- parent and staticOptions intentionally omitted
    }, [fetchAction, dropdownKey, handler, parentValue]);

    // Filter options by parent value (client-side cascade).
    // Supports both named-key format ({continent: 1}) and generic parent key ({parent: 1}).
    const visibleOptions =
        parent && parentValue !== undefined
            ? options.filter(o => {
                  const opt = o as unknown as Record<string, unknown>;
                  return opt[parent] === parentValue || opt['parent'] === parentValue;
              })
            : options;

    if (readOnly) {
        const found = visibleOptions.find(o => o.value === value);
        return (
            <span className="blong-display">
                {found?.label ?? (value != null ? String(value) : '')}
            </span>
        );
    }

    const isParentEmpty = !!parent && (parentValue === undefined || parentValue === null);

    return (
        <Dropdown
            inputId={id ?? name}
            data-testid={id ?? name}
            value={value}
            options={visibleOptions}
            onChange={e => onChange(e.value)}
            onHide={onBlur}
            disabled={disabled || isParentEmpty}
            className={`blong-dropdown w-full ${error ? 'p-invalid' : ''}`}
            showClear={!schema.fieldRequired}
            filter={visibleOptions.length > 8}
            placeholder={schema.placeholder ?? 'Select…'}
        />
    );
}

/**
 * Connected variant — uses `useWatch` to subscribe only to the specific parent field.
 * This component is only rendered when inside a Form that has a `control` and a `parent`
 * field is configured.  It re-renders only when the parent field value changes, not on
 * every keystroke in any other field.
 */
function ConnectedDropdown({
    parentFieldName,
    control,
    ...props
}: IWidgetProps & {
    parentFieldName: string;
    control: Control<Record<string, unknown>>;
}) {
    const parentValue = useWatch({control, name: parentFieldName});
    return (
        <DropdownCore
            {...props}
            parentValue={parentValue}
        />
    );
}

/**
 * DropdownWidget — wraps PrimeReact Dropdown with cascade-filtering support.
 *
 * When a `parent` field is configured in the schema and the widget is rendered inside
 * a Form, it delegates to `ConnectedDropdown` which subscribes only to that specific
 * parent field via `useWatch`.  This means the dropdown only rerenders when the parent
 * field changes — not on every keystroke in any field.
 *
 * When used standalone (no Form context / no parent), rendering is handled directly.
 */
export function DropdownWidget(props: IWidgetProps) {
    const formCtx = useBlongForm();
    const parent = props.schema.widget?.parent;

    if (formCtx?.control && parent) {
        return (
            <ConnectedDropdown
                {...props}
                parentFieldName={parent}
                control={formCtx.control}
            />
        );
    }

    return (
        <DropdownCore
            {...props}
            parentValue={undefined}
        />
    );
}