240 lines
7.9 KiB
TypeScript
240 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import type { ComponentType, ReactNode } from "react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Button, Switch } from "@mantine/core";
|
|
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
|
|
import { useCustomWidgetRuntime } from "./context";
|
|
import { normalizeParams } from "./data";
|
|
import { RequestIdRequiredAlert } from "./status";
|
|
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
|
|
|
|
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
|
play: IconPlayerPlay,
|
|
check: IconCheck,
|
|
refresh: IconRefresh,
|
|
power: IconPower,
|
|
trash: IconTrash,
|
|
};
|
|
|
|
function parseBool(value: unknown, fallback = false) {
|
|
if (typeof value === "boolean") return value;
|
|
if (value === "true") return true;
|
|
if (value === "false") return false;
|
|
return fallback;
|
|
}
|
|
|
|
function useActionExecutor() {
|
|
const runtime = useCustomWidgetRuntime();
|
|
const mutation = useMutation({
|
|
mutationFn: ({
|
|
requestId,
|
|
params,
|
|
confirmed,
|
|
}: {
|
|
requestId: string;
|
|
params: CustomJsxRuntimeParams;
|
|
confirmed: boolean;
|
|
}): Promise<CustomWidgetRequestResult> => {
|
|
if (!runtime.itemId && !runtime.previewSessionId) {
|
|
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.widgetItemUnavailable });
|
|
}
|
|
if (runtime.isEditMode) {
|
|
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.actionsDisabledEditMode });
|
|
}
|
|
return runtime.port.executeAction({
|
|
itemId: runtime.itemId,
|
|
previewSessionId: runtime.previewSessionId,
|
|
requestId,
|
|
params,
|
|
confirmed,
|
|
});
|
|
},
|
|
});
|
|
return { runtime, mutation };
|
|
}
|
|
|
|
export interface ActionButtonProps {
|
|
requestId?: string;
|
|
params?: CustomJsxRuntimeParams;
|
|
label?: string;
|
|
children?: ReactNode;
|
|
color?: string;
|
|
variant?: string;
|
|
size?: string;
|
|
confirmMessage?: string;
|
|
successMessage?: string;
|
|
errorMessage?: string;
|
|
icon?: string;
|
|
disabled?: boolean | string;
|
|
fullWidth?: boolean | string;
|
|
}
|
|
|
|
export function ActionButton(props: ActionButtonProps) {
|
|
const { runtime, mutation } = useActionExecutor();
|
|
const params = normalizeParams(props.params);
|
|
const title = props.label ?? (typeof props.children === "string" ? props.children : props.requestId) ?? "Action";
|
|
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
|
|
const capability = runtime.requestCapabilities.find(
|
|
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
|
);
|
|
const run = async (confirmed: boolean) => {
|
|
if (!props.requestId || !params) return;
|
|
try {
|
|
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed });
|
|
const success = result.ok;
|
|
runtime.port.notify({
|
|
kind: success ? "success" : "error",
|
|
title,
|
|
message: success
|
|
? (props.successMessage ??
|
|
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
|
|
: (props.errorMessage ?? result.error ?? `${runtime.messages.requestFailed} (${result.status})`),
|
|
});
|
|
if (success)
|
|
await runtime.port.invalidate({
|
|
itemId: runtime.itemId,
|
|
previewSessionId: runtime.previewSessionId,
|
|
targets: result.invalidates ?? capability?.invalidates ?? [],
|
|
});
|
|
} catch {
|
|
runtime.port.notify({
|
|
kind: "error",
|
|
title,
|
|
message: props.errorMessage ?? runtime.messages.requestFailed,
|
|
});
|
|
}
|
|
};
|
|
const click = async () => {
|
|
const confirmMessage =
|
|
props.confirmMessage ??
|
|
capability?.confirmation?.message ??
|
|
(capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
|
if (
|
|
confirmMessage &&
|
|
!(await runtime.port.confirm({
|
|
title: capability?.confirmation?.title ?? title,
|
|
message: confirmMessage,
|
|
confirmLabel: capability?.confirmation?.confirmLabel,
|
|
destructive: capability?.confirmation?.destructive ?? capability?.method === "DELETE",
|
|
}))
|
|
)
|
|
return;
|
|
await run(Boolean(confirmMessage));
|
|
};
|
|
if (!props.requestId) return <RequestIdRequiredAlert />;
|
|
return (
|
|
<Button
|
|
color={props.color ?? "blue"}
|
|
variant={(props.variant ?? "filled") as never}
|
|
size={(props.size ?? "sm") as never}
|
|
onClick={() => void click()}
|
|
loading={mutation.isPending}
|
|
leftSection={Icon ? <Icon size={16} /> : undefined}
|
|
disabled={
|
|
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
|
|
}
|
|
fullWidth={parseBool(props.fullWidth)}
|
|
>
|
|
{props.children ?? props.label ?? props.requestId}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export interface ToggleSwitchProps {
|
|
requestId?: string;
|
|
enabledParams?: CustomJsxRuntimeParams;
|
|
disabledParams?: CustomJsxRuntimeParams;
|
|
initialValue?: boolean | string;
|
|
label?: string;
|
|
color?: string;
|
|
size?: string;
|
|
errorMessage?: string;
|
|
disabled?: boolean | string;
|
|
}
|
|
|
|
export function ToggleSwitch(props: ToggleSwitchProps) {
|
|
const { runtime, mutation } = useActionExecutor();
|
|
const initial = parseBool(props.initialValue);
|
|
const [checked, setChecked] = useState(initial);
|
|
const locked = useRef(false);
|
|
const enabledParams = normalizeParams(props.enabledParams);
|
|
const disabledParams = normalizeParams(props.disabledParams);
|
|
const capability = runtime.requestCapabilities.find(
|
|
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
|
);
|
|
useEffect(() => {
|
|
if (!locked.current) setChecked(initial);
|
|
}, [initial]);
|
|
const change = async (next: boolean) => {
|
|
const params = next ? enabledParams : disabledParams;
|
|
if (!props.requestId || !params || locked.current) return;
|
|
const confirmation = capability?.confirmation;
|
|
const confirmMessage =
|
|
confirmation?.message ?? (capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
|
if (
|
|
confirmMessage &&
|
|
!(await runtime.port.confirm({
|
|
title: confirmation?.title ?? props.label ?? runtime.messages.toggle,
|
|
message: confirmMessage,
|
|
confirmLabel: confirmation?.confirmLabel,
|
|
destructive: confirmation?.destructive ?? capability?.method === "DELETE",
|
|
}))
|
|
)
|
|
return;
|
|
locked.current = true;
|
|
const previous = checked;
|
|
setChecked(next);
|
|
try {
|
|
const result = await mutation.mutateAsync({
|
|
requestId: props.requestId,
|
|
params,
|
|
confirmed: confirmMessage !== undefined,
|
|
});
|
|
if (!result.ok) {
|
|
setChecked(previous);
|
|
runtime.port.notify({
|
|
kind: "error",
|
|
title: props.label ?? runtime.messages.toggle,
|
|
message: props.errorMessage ?? result.error ?? runtime.messages.requestFailed,
|
|
});
|
|
} else {
|
|
await runtime.port.invalidate({
|
|
itemId: runtime.itemId,
|
|
previewSessionId: runtime.previewSessionId,
|
|
targets: result.invalidates ?? capability?.invalidates ?? [],
|
|
});
|
|
}
|
|
} catch {
|
|
setChecked(previous);
|
|
runtime.port.notify({
|
|
kind: "error",
|
|
title: props.label ?? runtime.messages.toggle,
|
|
message: props.errorMessage ?? runtime.messages.requestFailed,
|
|
});
|
|
} finally {
|
|
locked.current = false;
|
|
}
|
|
};
|
|
if (!props.requestId) return <RequestIdRequiredAlert />;
|
|
return (
|
|
<Switch
|
|
label={props.label}
|
|
color={props.color ?? "blue"}
|
|
size={(props.size ?? "sm") as never}
|
|
checked={checked}
|
|
onChange={(event) => void change(event.currentTarget.checked)}
|
|
disabled={
|
|
parseBool(props.disabled) ||
|
|
mutation.isPending ||
|
|
runtime.isEditMode ||
|
|
(!runtime.itemId && !runtime.previewSessionId) ||
|
|
!enabledParams ||
|
|
!disabledParams
|
|
}
|
|
/>
|
|
);
|
|
}
|