返回 DeepSeek-Reasonix
ExtensionFormDialog.tsx
根目录 / desktop / frontend / src / components / ExtensionFormDialog.tsx
1 import { useEffect, useId, useMemo, useRef, useState } from "react";
2 import { useT } from "../lib/i18n";
3 import type { ExtensionFormState } from "../lib/useController";
4 import type { WireExtensionFormField } from "../lib/types";
5 import { PromptAction, PromptBadge, PromptShelf } from "./PromptShelf";
6
7 // ExtensionFormDialog renders one extension-published form surface as the
8 // footer's decision shelf: structured fields (confirm / input / select /
9 // multiselect) with required validation, a submit bar, and cancel. Submitting
10 // delivers the values to the owning sidecar; cancelling reports
11 // values{"cancelled": true} (wired by the caller).
12 export function ExtensionFormDialog({
13 surface,
14 busy = false,
15 onSubmit,
16 onCancel,
17 }: {
18 surface: ExtensionFormState;
19 busy?: boolean;
20 onSubmit: (values: Record<string, unknown>) => void;
21 onCancel: () => void;
22 }) {
23 const t = useT();
24 const titleId = useId();
25 const shelfRef = useRef<HTMLDivElement | null>(null);
26 const onCancelRef = useRef(onCancel);
27 onCancelRef.current = onCancel;
28 const [values, setValues] = useState<Record<string, unknown>>(() => initialFormValues(surface.form.fields));
29
30 useEffect(() => {
31 shelfRef.current?.focus();
32 const onKeyDown = (event: globalThis.KeyboardEvent) => {
33 if (event.key !== "Escape") return;
34 const target = event.target instanceof Element ? event.target : null;
35 const tag = target?.tagName.toLowerCase();
36 if (tag === "input" || tag === "textarea" || (target instanceof HTMLElement && target.isContentEditable)) return;
37 event.preventDefault();
38 onCancelRef.current();
39 };
40 document.addEventListener("keydown", onKeyDown);
41 return () => document.removeEventListener("keydown", onKeyDown);
42 }, []);
43
44 const missing = useMemo(() => missingRequiredLabels(surface.form.fields, values), [surface.form.fields, values]);
45 const setField = (key: string, value: unknown) => setValues((current) => ({ ...current, [key]: value }));
46 const toggleMulti = (key: string, option: string) => {
47 setValues((current) => {
48 const selected = Array.isArray(current[key]) ? (current[key] as string[]) : [];
49 const next = selected.includes(option) ? selected.filter((entry) => entry !== option) : [...selected, option];
50 return { ...current, [key]: next };
51 });
52 };
53
54 const hint = missing.length > 0 ? t("ext.form.required", { label: missing[0] }) : " ";
55 const { form } = surface;
56 return (
57 <PromptShelf
58 decision
59 barRef={shelfRef}
60 titleId={titleId}
61 title={form.title || t("ext.form.defaultTitle")}
62 badges={<PromptBadge>{surface.pluginId}</PromptBadge>}
63 actionsRole="group"
64 footer={
65 <div className="decision-confirm-bar">
66 <div className="decision-confirm-bar__hint">{hint}</div>
67 <button type="button" className="btn btn--small" onClick={onCancel} disabled={busy}>
68 {t("common.cancel")}
69 </button>
70 <button
71 type="button"
72 className="btn btn--small btn--primary decision-confirm-bar__confirm"
73 onClick={() => onSubmit(values)}
74 disabled={busy || missing.length > 0}
75 >
76 {t("ext.form.submit")}
77 </button>
78 </div>
79 }
80 >
81 <div className="extension-form">
82 {form.message ? <p className="prompt-shelf__note">{form.message}</p> : null}
83 {form.fields.map((field) => (
84 <ExtensionFormFieldRow
85 key={field.key}
86 field={field}
87 value={values[field.key]}
88 disabled={busy}
89 onChange={(value) => setField(field.key, value)}
90 onToggleOption={(option) => toggleMulti(field.key, option)}
91 />
92 ))}
93 </div>
94 </PromptShelf>
95 );
96 }
97
98 function fieldLabel(field: WireExtensionFormField): string {
99 return field.label || field.key;
100 }
101
102 function fieldKind(field: WireExtensionFormField): string {
103 return field.kind || "input";
104 }
105
106 function initialFormValues(fields: WireExtensionFormField[]): Record<string, unknown> {
107 const values: Record<string, unknown> = {};
108 for (const field of fields) {
109 switch (fieldKind(field)) {
110 case "confirm":
111 values[field.key] = field.default === true;
112 break;
113 case "multiselect":
114 values[field.key] = Array.isArray(field.default) ? field.default.map(String) : [];
115 break;
116 default:
117 values[field.key] = field.default === undefined || field.default === null ? "" : String(field.default);
118 }
119 }
120 return values;
121 }
122
123 function missingRequiredLabels(fields: WireExtensionFormField[], values: Record<string, unknown>): string[] {
124 const missing: string[] = [];
125 for (const field of fields) {
126 if (!field.required) continue;
127 const value = values[field.key];
128 const present =
129 fieldKind(field) === "confirm"
130 ? value === true
131 : fieldKind(field) === "multiselect"
132 ? Array.isArray(value) && value.length > 0
133 : typeof value === "string" && value.trim() !== "";
134 if (!present) missing.push(fieldLabel(field));
135 }
136 return missing;
137 }
138
139 function ExtensionFormFieldRow({
140 field,
141 value,
142 disabled,
143 onChange,
144 onToggleOption,
145 }: {
146 field: WireExtensionFormField;
147 value: unknown;
148 disabled: boolean;
149 onChange: (value: unknown) => void;
150 onToggleOption: (option: string) => void;
151 }) {
152 const label = fieldLabel(field);
153 const kind = fieldKind(field);
154 if (kind === "confirm") {
155 return (
156 <label className="extension-form__row extension-form__row--check">
157 <input type="checkbox" checked={value === true} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
158 <span>{label}{field.required ? " *" : ""}</span>
159 </label>
160 );
161 }
162 if (kind === "select" || kind === "multiselect") {
163 const options = field.options ?? [];
164 const selected = kind === "select" ? (typeof value === "string" && value !== "" ? [value] : []) : Array.isArray(value) ? (value as string[]) : [];
165 return (
166 <div className="extension-form__row">
167 <div className="extension-form__label">{label}{field.required ? " *" : ""}</div>
168 <div className="extension-form__options" role={kind === "select" ? "listbox" : "group"} aria-label={label}>
169 {options.map((option) => (
170 <PromptAction
171 key={option}
172 keyLabel=""
173 label={option}
174 role={kind === "select" ? "option" : "button"}
175 selected={selected.includes(option)}
176 disabled={disabled}
177 onClick={() => (kind === "select" ? onChange(option) : onToggleOption(option))}
178 />
179 ))}
180 </div>
181 </div>
182 );
183 }
184 return (
185 <label className="extension-form__row">
186 <span className="extension-form__label">{label}{field.required ? " *" : ""}</span>
187 <input
188 type="text"
189 className="extension-form__input"
190 value={typeof value === "string" ? value : ""}
191 disabled={disabled}
192 onChange={(event) => onChange(event.target.value)}
193 />
194 </label>
195 );
196 }
197
197 lines Plain Text