import React, { useContext, useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography, } from "antd"; import DisconnectOutlined from "@ant-design/icons/DisconnectOutlined"; import LinkOutlined from "@ant-design/icons/LinkOutlined"; import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { InputComponent, InputComponentProps } from "../BaseWebPlugin"; import type { Control } from "../util/websocket"; import notify from "../util/notify"; const { Title } = Typography; function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { let initialValues: any = {}; for (let [name, def] of Object.entries(config.constructor.fieldDefinitions)) { if (!config.canAccess(name, lib.ConfigAccess.read)) { continue; } let value = config.get(name) as any; if (def.type === "object") { for (let prop of Object.keys(value ?? {})) { initialValues[`${name}.${prop}`] = JSON.stringify(value[prop]); } } else { initialValues[name] = value; } } return initialValues; } function renderInput(inputComponents: Map, def: lib.FieldDefinition, readonly: boolean) { if (def.inputComponent && inputComponents.has(def.inputComponent)) { // Field.Item will provide the value and onChange props to the component. type InputPartial = React.ComponentClass>; const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial; return ; } if (def.type === "boolean") { return ; } if (def.type === "string") { if (def.credential) { return ; } if (def.enum) { return ; } if (def.type === "number") { return ; } return `Unknown type ${def.type}`; } /** Lets fields follow the value of another config, inherited values are locked until unlinking */ export type ConfigInheritance = { canInherit: (config: lib.Config, field: string) => boolean; isInherited: (config: lib.Config, field: string, prop?: string) => boolean; inherit: (field: string, prop?: string) => Promise; templateValue: (field: string, prop?: string) => unknown; notice?: (config: lib.Config) => React.ReactNode; }; type Linking = Map; type LinkState = { inheritance?: ConfigInheritance; unlinking: Set; setUnlinking: (unlinking: Set) => void; linking: Linking; setLinking: (linking: Linking) => void; }; type BaseConfigTreeProps = { ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig; inheritance?: ConfigInheritance; retrieveConfig: () => Promise; setConfig: (fields: Record>) => Promise; id?: number; onApply?: () => void; available?: boolean; }; export default function BaseConfigTree(props: BaseConfigTreeProps) { let [config, setConfig] = useState|null>(null); let [form] = Form.useForm(); let [changedFields, setChangedFields] = useState>(new Set()); let [errorFields, setErrorFields] = useState(new Map()); let [applying, setApplying] = useState(false); let [unlinking, setUnlinking] = useState>(new Set()); let [linking, setLinking] = useState(new Map()); const available = props.available ?? true; const control = useContext(ControlContext); async function updateConfig() { let serializedConfig = await props.retrieveConfig(); const newConfig = props.ConfigClass.fromJSON(serializedConfig, "control"); setConfig(newConfig); return newConfig; } useEffect(() => { if (available) { updateConfig().then(updatedConfig => { form.setFieldsValue(getInitialValues(updatedConfig, props)); }); } }, [props.id, available]); if (!available) { return <> Config Config is currently unavailable. ; } if (config === null) { return <> Config ; ; } let initialValues: any = {}; function onValuesChange(changedValues: object) { let newChangedFields = new Set(changedFields); let changed = false; for (let [key, value] of Object.entries(changedValues)) { if (key.endsWith(":add.name") || key.endsWith(":add.value")) { continue; } const def = config?.constructor.fieldDefinitions[key]; if (!def?.credential && initialValues[key] === value) { changed = newChangedFields.delete(key) || changed; } else { changed = !newChangedFields.has(key) || changed; newChangedFields.add(key); } } if (changed) { setChangedFields(newChangedFields); } } async function applyChanges(fields: Record>) { if (Object.keys(fields).length) { await props.setConfig(fields); } for (const [field, prop] of linking.values()) { await props.inheritance!.inherit(field, prop); } setChangedFields(new Set()); setUnlinking(new Set()); setLinking(new Map()); } const [propsMap, treeData] = computeTreeData( control, form, initialValues, errorFields, config, setConfig, props.ConfigClass, onValuesChange, { inheritance: props.inheritance, unlinking, setUnlinking, linking, setLinking }, ); return <> Config <Space style={{ float: "right" }}> <Button type="primary" size="small" loading={applying} onClick={async () => { setApplying(true); const fields: Record<string, string | Record<string, string>> = {}; // Unlinked fields are sent even when unchanged so they stop following the template for (const field of new Set([...changedFields, ...unlinking])) { const value = form.getFieldValue(field); if (propsMap.has(field)) { const [fieldName, prop] = propsMap.get(field)!; const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>; fields[fieldName] = fieldValue; try { fieldValue[prop] = value ? JSON.parse(value) : undefined; } catch (err: any) { setErrorFields(errors => { const newErrors = new Map(errors); newErrors.set(fieldName, err.message); return newErrors; }); } } else { fields[field] = value === null ? "" : String(value); } } let applied = false; try { await applyChanges(fields); applied = true; } catch (err: any) { notify(err.message, "error"); } const newConfig = await updateConfig(); if (applied) { // Linking can change other values form.setFieldsValue(getInitialValues(newConfig, props)); } setApplying(false); if (props.onApply) { props.onApply(); } }} disabled={changedFields.size === 0 && linking.size === 0 && unlinking.size === 0} > Apply </Button> <Button size="small" onClick={() => { form.resetFields(); setChangedFields(new Set()); setUnlinking(new Set()); setLinking(new Map()); setErrorFields(new Map()); }} > Revert </Button> </Space> {props.inheritance?.notice?.(config)}
changedFields.has(node.key)} />
; } function computeTreeData( control: Control, form: FormInstance, initialValues: any, errorFields: Map, config: lib.Config, setConfig: (newConfig: lib.Config) => void, ConfigClass: BaseConfigTreeProps["ConfigClass"], onValuesChange: (changedValues: object) => void, link: LinkState, ) { let restartTip = ; type ChildNode = { key: string; children: any[]; title?: React.ReactElement; }; type TreeNode = { key: string; title: string; children: ChildNode[]; }; function linkButton(key: string, fieldName: string, prop?: string) { const { inheritance, unlinking, setUnlinking, linking, setLinking } = link; if (!inheritance?.canInherit(config, fieldName) || !config.canAccess(fieldName, lib.ConfigAccess.write)) { return null; } const button = ( title: string, icon: React.ReactNode, label: string, pending: boolean, onClick: () => void, ) => , }); } if (!childNode.children.length) { continue; } } else { childNode.title = {def.title || field} {def.restartRequired && restartTip} {linkButton(fieldName, fieldName)} } validateStatus={errorFields.has(fieldName) ? "error" : undefined} help={errorFields.get(fieldName)} tooltip={def.description} dependencies={def.dependsOn} valuePropName={def.type === "boolean" ? "checked" : "value"} rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => { if (!def.validator) { return; } config.stage(fieldName, fieldValue); for (const dependsOn of def.dependsOn ?? []) { const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def; try { if (propsMap.has(dependsOn)) { const [name, prop] = propsMap.get(dependsOn)!; config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn))); } else if (dependsOnDef.type !== "object") { config.stage(dependsOn, getFieldValue(dependsOn)); } } catch (err) { throw new Error(`Failed to fetch field: ${dependsOn}`); } } def.validator(fieldValue, config.staging); }, })]} > {renderInput(control.inputComponents, def, isReadonly(canWrite, fieldName, fieldName))} ; initialValues[fieldName] = value; } treeNode.children.push(childNode); } treeData.push(treeNode); } return [propsMap, treeData] as [typeof propsMap, typeof treeData]; }