)}
);
}
/**
* Display-only component for `__system.*` fields (e.g. the deployment's
* outbound IPs that the operator must add to a platform's trusted-IP list).
* Renders one read-only row per value, each with a copy button. Rendered
* outside of react-hook-form binding since the values come from
* systemContext, not user input.
*/
function SystemInfoField({
label,
description,
values,
}: {
label: string;
description?: string;
values: string[];
}) {
const [copiedIndex, setCopiedIndex] = useState(null);
const handleCopy = (text: string, index: number) => {
copyToClipboard(text).catch(() => {});
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
return (
{label}
{values.map((value, index) => (
(e.target as HTMLInputElement).select()}
/>
))}
{description && (
{description}
)}
);
}
// Hover-only Radix tooltips never open on touch devices (no pointer hover),
// so the ``disabled_tooltip`` explaining why a field is locked was invisible on
// mobile. This wrapper makes the info icon also toggle the tooltip on tap while
// keeping hover behavior on desktop.
function DisabledTooltipIcon({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
{text}
);
}
/**
* Normalize plugin manifest type names to frontend-compatible types
*/
function normalizeItemType(type: string): DynamicFormItemType {
return parseDynamicFormItemType(type);
}
export default function DynamicFormComponent({
itemConfigList,
onSubmit,
initialValues,
onFileUploaded,
isEditing,
externalDependentValues,
systemContext,
onValidate,
renderItem,
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
initialValues?: Record;
onFileUploaded?: (fileKey: string) => void;
isEditing?: boolean;
externalDependentValues?: Record;
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record;
/** Callback to expose validation function to parent component.
* Parent can call this function to trigger validation and get validity state. */
onValidate?: (validateFn: () => Promise) => void;
/** Override a field control while retaining the DynamicForm label,
* description, validation, and value emission behavior. Return undefined
* to use the standard control for that item. */
renderItem?: (args: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps;
formValues: Record;
setFormValue: (name: string, value: unknown) => void;
}) => ReactNode | undefined;
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation();
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
const editableItems = useMemo(
() =>
itemConfigList.filter(
(item) =>
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login' &&
item.type !== 'download-link' &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
),
[itemConfigList],
);
const editableValueSpecs = useMemo(
() => editableItems.flatMap(getValueSpecs),
[editableItems],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
editableValueSpecs.reduce(
(acc, item) => {
let fieldSchema = getValueSchema(item);
if (
item.required &&
(fieldSchema instanceof z.ZodString ||
fieldSchema instanceof z.ZodArray)
) {
fieldSchema = fieldSchema.min(1, {
message: t('common.fieldRequired'),
});
}
return {
...acc,
[item.name]: fieldSchema,
};
},
{} as Record,
),
);
type FormValues = z.infer;
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: editableValueSpecs.reduce((acc, item) => {
// 优先使用 initialValues,如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
...acc,
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
};
}, {} as FormValues),
});
// Expose validation function to parent component
const validate = useCallback(async (): Promise => {
// Trigger validation for all fields
const result = await form.trigger();
return result;
}, [form]);
useEffect(() => {
onValidate?.(validate);
}, [onValidate, validate]);
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => {
// 首次挂载时,使用 initialValues 初始化表单
if (isInitialMount.current) {
isInitialMount.current = false;
previousInitialValues.current = initialValues;
return;
}
// 检查 initialValues 是否真的发生了实质性变化
// 使用 JSON.stringify 进行深度比较
const hasRealChange =
JSON.stringify(previousInitialValues.current) !==
JSON.stringify(initialValues);
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeDynamicFormFieldValue(
item,
rawValue,
) as object;
return acc;
},
{} as Record,
);
Object.entries(mergedValues).forEach(([key, value]) => {
form.setValue(key as keyof FormValues, value);
});
previousInitialValues.current = initialValues;
}
}, [initialValues, form, editableValueSpecs]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
const setFormValue = (name: string, value: unknown) => {
form.setValue(name as keyof FormValues, value as never, {
shouldDirty: true,
shouldValidate: true,
});
};
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render.
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
// 监听表单值变化
useEffect(() => {
// Emit initial form values immediately so the parent always has a valid snapshot,
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record,
);
onSubmitRef.current?.(initialFinalValues);
// Update previousInitialValues to the emitted snapshot so that if the
// parent writes these values back as new initialValues, the deep
// comparison in the initialValues-sync useEffect won't detect a change
// and won't trigger an infinite update loop.
previousInitialValues.current = initialFinalValues as Record<
string,
object
>;
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record,
);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record;
});
return () => subscription.unsubscribe();
}, [form, editableValueSpecs]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogPlatform, setQrDialogPlatform] =
useState('feishu');
return (