All files / core/blong-browser/src/components/ActionButton ActionButton.tsx

39.65% Statements 23/58
44.28% Branches 31/70
33.33% Functions 3/9
41.17% Lines 21/51

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                                                                                                  4x 4x         4x 4x 4x   4x       4x       4x     4x 4x 4x 12x 12x 4x   4x   4x   4x                                                                 4x                         4x               4x                                               4x                    
/**
 * ActionButton — permission-aware button that triggers an action from the registry.
 *
 * Supports:
 *  - Action name string → looks up action registry, calls via useAction
 *  - Confirmation dialog before invocation
 *  - Loading indicator while in-flight
 *  - Submit form before calling (when submit=true)
 *  - Split-button variant when menu items are provided
 */
import { confirmDialog, SplitButton } from '../../primereact/index.js';
 
import { useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useAction } from '../../hooks/useAction.js';
import { usePermission } from '../../hooks/usePermission.js';
import type { IToolbarButton } from '../../index.js';
import { useAppStore } from '../../state/appStore.js';
import { Button } from '../Button/Button.js';
 
export interface IActionButtonProps extends IToolbarButton {
    /** Ref to the form element to submit before calling */
    formId?: string;
    className?: string;
    /** Called with true when the action starts, false when it finishes */
    onBusyChange?: (busy: boolean) => void;
    /** External disabled override — disables the button regardless of its configured `enabled` */
    disabled?: boolean;
}
 
export function ActionButton({
    label,
    icon,
    action: actionRef,
    method,
    permission,
    submit: shouldSubmit = false,
    confirm: confirmMessage,
    enabled = true,
    visible = true,
    menu,
    params: extraParams,
    successHint,
    refresh,
    formId,
    className = '',
    onBusyChange,
    disabled: externalDisabled = false,
}: IActionButtonProps) {
    const permitted = usePermission(permission);
    const [loading, setLoading] = useState(false);
    /**
     * Set for as long as the failure hint is on screen, so visual themes can
     * style the button that errored (the wood variant paints it rust).
     */
    const [failed, setFailed] = useState(false);
    const queryClient = useQueryClient();
    const actionMethod = typeof actionRef === 'string' ? actionRef : actionRef?.method;
    const actionParamsOverride =
        typeof actionRef === 'object' && actionRef !== null ? actionRef.params : undefined;
    // extraParams may be a pre-resolved Record; if it arrives as a non-object (rare, e.g. a
    // primitive wrapped by resolveTemplate), treat it as the whole call payload.
    const mergedParams =
        extraParams !== null && typeof extraParams === 'object' && !Array.isArray(extraParams)
            ? {...(actionParamsOverride ?? {}), ...(extraParams as Record<string, unknown>)}
            : {...(actionParamsOverride ?? {})};
    const callParams =
        extraParams !== null && typeof extraParams === 'object' && !Array.isArray(extraParams)
            ? mergedParams
            : ((extraParams as Record<string, unknown> | undefined) ?? mergedParams);
    const directMethod = method ?? actionMethod ?? '';
    const testId = directMethod ? `action-${directMethod.replace(/[/.]/g, '-')}` : undefined;
    const {call} = useAction(directMethod, 'mutation', mergedParams);
    const clearError = useAppStore(s => s.clearError);
    const showHint = useAppStore(s => s.showHint);
    const hintTargetRef = useRef<HTMLSpanElement>(null);
 
    Iif (!visible || !permitted) return null;
 
    const isDisabled = externalDisabled || enabled === false || loading;
 
    const doCall = async () => {
        if (shouldSubmit && formId) {
            const form = document.getElementById(formId) as HTMLFormElement | null;
            if (form) {
                // Trigger native form submit; the form's onSubmit handles validation
                form.requestSubmit();
                return;
            }
        }
        setLoading(true);
        onBusyChange?.(true);
        try {
            await call(callParams);
            if (refresh && directMethod) {
                const ns = directMethod.split('.').slice(0, -1).join('.');
                if (ns) {
                    void queryClient.invalidateQueries({queryKey: [ns + '.find']});
                    void queryClient.invalidateQueries({queryKey: [ns + '.get']});
                }
            }
            if (successHint) showHint(hintTargetRef.current, successHint, false);
        } catch (err: unknown) {
            const e = err as {print?: string; message?: string};
            clearError(); // dismiss the global error dialog — we're showing it locally
            showHint(hintTargetRef.current, e.print ?? e.message ?? 'Error', true);
            setFailed(true);
            window.setTimeout(() => setFailed(false), 2000); // matches ActionHint's auto-dismiss
        } finally {
            setLoading(false);
            onBusyChange?.(false);
        }
    };
 
    const handleClick = () => {
        if (confirmMessage) {
            confirmDialog({
                message: confirmMessage,
                header: 'Confirm',
                icon: 'pi pi-exclamation-triangle',
                accept: () => void doCall(),
            });
        } else {
            void doCall();
        }
    };
 
    const buttonProps = {
        label,
        icon: loading ? 'pi pi-spinner pi-spin' : icon,
        disabled: isDisabled,
        onClick: handleClick,
        className: `blong-action-btn ${className}${failed ? ' blong-action-error' : ''}`,
    };
 
    Iif (menu && menu.length > 0) {
        const splitItems = menu.map(item => ({
            label: item.label,
            icon: item.icon,
            command: () => {
                const subAction =
                    typeof item.action === 'string' ? item.action : item.action?.method;
                if (subAction) void call({...mergedParams, _action: subAction});
            },
        }));
        return (
            <span
                ref={hintTargetRef}
                style={{display: 'inline-block'}}
                data-testid={testId}
            >
                <SplitButton
                    {...buttonProps}
                    model={splitItems}
                />
            </span>
        );
    }
 
    return (
        <span
            ref={hintTargetRef}
            style={{display: 'inline-block'}}
            data-testid={testId}
        >
            <Button {...buttonProps} />
        </span>
    );
}