Files
NetMesh/components/scripts/ScriptDialogHost.tsx

551 lines
19 KiB
TypeScript
Raw Permalink Normal View History

import { useCallback, useEffect, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type {
ScriptDialogCondition,
ScriptDialogField,
ScriptDialogForm,
ScriptDialogFormValue,
ScriptDialogRequest,
} from '@/types/global/netcatty-bridge-script.d.ts';
type FormValues = Record<string, ScriptDialogFormValue>;
type FormErrors = Record<string, string>;
type FormValidationMessages = string | {
required: string;
numberInvalid: string;
numberMin: (min: number) => string;
numberMax: (max: number) => string;
numberStep: (step: number) => string;
};
export function getInitialFormValues(request: ScriptDialogRequest): FormValues {
if (request.type !== 'form' || !request.form) return {};
return Object.fromEntries(
request.form.fields.map((field) => [field.name, field.defaultValue]),
);
}
export function applyFormValue(values: FormValues, name: string, value: ScriptDialogFormValue): FormValues {
return { ...values, [name]: value };
}
export function getDialogFieldDomId(name: string): string {
return `script-dialog-${encodeURIComponent(name || 'field')}`;
}
function resolveValidationMessages(messages: FormValidationMessages = 'Required') {
if (typeof messages === 'string') {
return {
required: messages,
numberInvalid: messages,
numberMin: () => messages,
numberMax: () => messages,
numberStep: () => messages,
};
}
return messages;
}
function matchesNumberStep(value: number, step: number, base = 0) {
const quotient = (value - base) / step;
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
}
function getNumberStepBase(field: Extract<ScriptDialogField, { type: 'number' }>) {
return field.min ?? field.defaultValue ?? 0;
}
function getConditionFieldValue(
form: ScriptDialogForm,
values: FormValues,
fieldName: string,
): ScriptDialogFormValue {
const field = form.fields.find((candidate) => candidate.name === fieldName);
const value = values[fieldName];
if (field?.type === 'number') {
if (value === undefined || value === '') return undefined;
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (field?.type === 'checkbox') {
return Boolean(value);
}
return value;
}
export function evaluateDialogCondition(
form: ScriptDialogForm,
values: FormValues,
condition: ScriptDialogCondition,
): boolean {
const value = getConditionFieldValue(form, values, condition.field);
if ('equals' in condition) {
return value === condition.equals;
}
if ('notEquals' in condition) {
return value !== condition.notEquals;
}
if ('truthy' in condition) {
return Boolean(value);
}
return !value;
}
export function isDialogFieldVisible(
form: ScriptDialogForm,
field: ScriptDialogField,
values: FormValues,
): boolean {
return getVisibleDialogFormFields(form, values).some((visibleField) => visibleField.name === field.name);
}
export function getVisibleDialogFormFields(form: ScriptDialogForm, values: FormValues): ScriptDialogField[] {
const visibleFields: ScriptDialogField[] = [];
const visibleNames = new Set<string>();
for (const field of form.fields) {
if (!field.visibleWhen) {
visibleFields.push(field);
visibleNames.add(field.name);
continue;
}
if (!visibleNames.has(field.visibleWhen.field)) {
continue;
}
if (evaluateDialogCondition(form, values, field.visibleWhen)) {
visibleFields.push(field);
visibleNames.add(field.name);
}
}
return visibleFields;
}
export function validateDialogFormValues(
form: ScriptDialogForm,
values: FormValues,
messages: FormValidationMessages = 'Required',
): FormErrors {
const validationMessages = resolveValidationMessages(messages);
const errors: FormErrors = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'checkbox') {
if (field.required === true && !value) {
errors[field.name] = validationMessages.required;
}
continue;
}
if (field.type === 'number') {
const isEmpty = value === undefined || value === '';
if (isEmpty) {
if (field.required !== false) {
errors[field.name] = validationMessages.required;
}
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) {
errors[field.name] = validationMessages.numberInvalid;
continue;
}
if (field.min !== undefined && numberValue < field.min) {
errors[field.name] = validationMessages.numberMin(field.min);
continue;
}
if (field.max !== undefined && numberValue > field.max) {
errors[field.name] = validationMessages.numberMax(field.max);
continue;
}
if (field.step !== undefined && !matchesNumberStep(numberValue, field.step, getNumberStepBase(field))) {
errors[field.name] = validationMessages.numberStep(field.step);
}
continue;
}
if (field.required === false) continue;
if (value === undefined || String(value).trim() === '') {
errors[field.name] = validationMessages.required;
}
}
return errors;
}
export function normalizeDialogFormSubmitValues(form: ScriptDialogForm, values: FormValues): FormValues {
const next: FormValues = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'number') {
if (value === undefined || value === '') {
next[field.name] = undefined;
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
next[field.name] = Number.isFinite(numberValue) ? numberValue : undefined;
continue;
}
next[field.name] = value;
}
return next;
}
export function ScriptDialogFormFields({
form,
formValues,
formErrors = {},
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
const renderFormField = (field: ScriptDialogField) => {
const inputId = getDialogFieldDomId(field.name);
const fieldError = formErrors[field.name];
const descriptionId = field.description ? `${inputId}-description` : undefined;
const errorId = fieldError ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const fieldDescription = field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
) : null;
const inlineDescription = field.description ? (
<span id={descriptionId} className="mt-1 block text-xs text-muted-foreground">{field.description}</span>
) : null;
const errorMessage = fieldError ? (
<p id={errorId} className="text-xs text-destructive">{fieldError}</p>
) : null;
const inlineErrorMessage = fieldError ? (
<span id={errorId} className="mt-1 block text-xs text-destructive">{fieldError}</span>
) : null;
if (field.type === 'select') {
const labelId = `${inputId}-label`;
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
const selectedOption = field.options.find((option) => option.value === selectedValue);
const selectedDescriptionId = selectedOption?.description ? `${inputId}-selected-description` : undefined;
const selectDescribedBy = [describedBy, selectedDescriptionId].filter(Boolean).join(' ') || undefined;
return (
<div key={field.name} className="space-y-2">
<Label id={labelId} htmlFor={inputId}>{field.label}</Label>
<Select
value={selectedValue}
onValueChange={(value) => onValueChange(field.name, value)}
>
<SelectTrigger
id={inputId}
aria-labelledby={labelId}
aria-describedby={selectDescribedBy}
aria-invalid={fieldError ? true : undefined}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
textValue={option.label}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption?.description ? (
<p id={selectedDescriptionId} className="text-xs text-muted-foreground">{selectedOption.description}</p>
) : null}
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'radio') {
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
return (
<fieldset
key={field.name}
className="space-y-2"
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
>
<legend className="text-sm font-medium leading-5">{field.label}</legend>
{fieldDescription}
<div className="space-y-2">
{field.options.map((option, index) => {
const optionInputId = `${inputId}-${index}`;
return (
<label
key={option.value}
htmlFor={optionInputId}
className="flex items-start gap-2 rounded-md border border-border/60 px-3 py-2 text-sm"
>
<input
id={optionInputId}
type="radio"
name={inputId}
value={option.value}
checked={selectedValue === option.value}
disabled={option.disabled}
onChange={(event) => onValueChange(field.name, event.target.value)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block">{option.label}</span>
{option.description ? (
<span className="block text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
{errorMessage}
</fieldset>
);
}
if (field.type === 'textarea') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Textarea
id={inputId}
value={String(formValues[field.name] ?? field.defaultValue)}
placeholder={field.placeholder}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'number') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Input
id={inputId}
type="number"
value={formValues[field.name] ?? field.defaultValue ?? ''}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
return (
<div key={field.name} className="space-y-2">
<label htmlFor={inputId} className="flex items-start gap-2 text-sm">
<input
id={inputId}
type="checkbox"
checked={Boolean(formValues[field.name] ?? field.defaultValue)}
onChange={(event) => onValueChange(field.name, event.target.checked)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block font-medium leading-5">{field.label}</span>
{inlineDescription}
{inlineErrorMessage}
</span>
</label>
</div>
);
};
return (
<div className="space-y-4">
{getVisibleDialogFormFields(form, formValues).map(renderFormField)}
</div>
);
}
export function ScriptDialogFormBody({
form,
formValues,
formErrors,
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
return (
<ScrollArea className="min-h-0 pr-3">
<ScriptDialogFormFields
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={onValueChange}
/>
</ScrollArea>
);
}
export function ScriptDialogHost() {
const { t } = useI18n();
const [request, setRequest] = useState<ScriptDialogRequest | null>(null);
const [promptValue, setPromptValue] = useState('');
const [formValues, setFormValues] = useState<FormValues>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
useEffect(() => {
const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => {
setRequest(payload);
setPromptValue(payload.defaultValue ?? '');
setFormValues(getInitialFormValues(payload));
setFormErrors({});
});
return dispose;
}, []);
const respond = useCallback(async (value?: unknown, cancelled = false) => {
if (!request) return;
await netcattyBridge.get()?.scriptDialogResponse?.(request.requestId, value, cancelled);
setRequest(null);
}, [request]);
if (!request) return null;
const form = request.type === 'form' ? request.form : undefined;
const dialogTitle = request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: form?.title || t('scripts.dialog.title');
const message = form?.message ?? request.message;
const setFormValue = (name: string, value: ScriptDialogFormValue) => {
setFormValues((current) => applyFormValue(current, name, value));
setFormErrors((current) => {
if (!current[name]) return current;
const { [name]: _removed, ...rest } = current;
return rest;
});
};
const submitForm = () => {
if (!form) return;
const errors = validateDialogFormValues(form, formValues, {
required: t('scripts.dialog.required'),
numberInvalid: t('scripts.dialog.numberInvalid'),
numberMin: (min) => t('scripts.dialog.numberMin', { min }),
numberMax: (max) => t('scripts.dialog.numberMax', { max }),
numberStep: (step) => t('scripts.dialog.numberStep', { step }),
});
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
const submitValues = normalizeDialogFormSubmitValues(form, formValues);
void respond(submitValues);
};
return (
<Dialog open onOpenChange={(open) => {
if (!open) {
void respond(request.type === 'waitForTimeout' ? 'abort' : undefined, true);
}
}}
>
<DialogContent className={form ? 'max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden' : undefined}>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
{message ? <DialogDescription>{message}</DialogDescription> : null}
</DialogHeader>
{request.type === 'prompt' ? (
<Input
type={request.sensitive ? 'password' : 'text'}
value={promptValue}
onChange={(event) => setPromptValue(event.target.value)}
autoFocus
/>
) : null}
{form ? (
<ScriptDialogFormBody
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={setFormValue}
/>
) : null}
<DialogFooter>
{request.type === 'waitForTimeout' ? (
<>
<Button variant="outline" onClick={() => void respond('abort')}>
{t('scripts.dialog.abort')}
</Button>
<Button variant="secondary" onClick={() => void respond('skip')}>
{t('scripts.dialog.skip')}
</Button>
<Button onClick={() => void respond('retry')}>
{t('scripts.dialog.retry')}
</Button>
</>
) : request.type === 'confirm' ? (
<>
<Button variant="outline" onClick={() => void respond(false)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(true)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'prompt' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'form' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>
{form?.cancelLabel || t('common.cancel')}
</Button>
<Button onClick={submitForm}>
{form?.submitLabel || t('scripts.dialog.ok')}
</Button>
</>
) : (
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}