import React, { useContext, useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography, } from "antd"; 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"; 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: Record, def: lib.FieldDefinition, readonly: boolean) { if (def.inputComponent && Object.prototype.hasOwnProperty.call(inputComponents, def.inputComponent)) { // Field.Item will provide the value and onChange props to the component. type InputPartial = React.ComponentClass>; const CustomInput = inputComponents[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}`; } type BaseConfigTreeProps = { ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig; retrieveConfig: () => Promise; setField: (field:string, value:any) => Promise; setProp: (field:string, prop:string, value:any) => 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); 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); } } const [propsMap, treeData] = computeTreeData( control, form, initialValues, errorFields, config, setConfig, props.ConfigClass, onValuesChange, ); return <> Config <Space style={{ float: "right" }}> <Button type="primary" size="small" loading={applying} onClick={async () => { setApplying(true); for (let field of changedFields) { let value = form.getFieldValue(field); let request; if (propsMap.has(field)) { let [fieldName, prop] = propsMap.get(field); request = props.setProp(fieldName, prop, value); } else { request = props.setField(field, value === null ? "" : String(value)); } try { await request; setChangedFields(changed => { let newChanged = new Set(changed); newChanged.delete(field); return newChanged; }); } catch (err: any) { setErrorFields(errors => { let newErrors = new Map(errors); newErrors.set(field, err.message); return newErrors; }); } } await updateConfig(); setApplying(false); if (props.onApply) { props.onApply(); } }} disabled={changedFields.size === 0} > Apply </Button> <Button size="small" onClick={() => { form.resetFields(); setChangedFields(new Set()); setErrorFields(new Map()); }} > Revert </Button> </Space>
changedFields.has(node.key)} />
; } // eslint-disable-next-line complexity 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, ) { let restartTip = ; type ChildNode = { key: string; children: any[]; title?: React.ReactElement; }; type TreeNode = { key: string; title: string; children: ChildNode[]; }; let treeData = []; let propsMap = new Map(); const groups = new Map(); for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) { const [groupName, field] = lib.splitOn(".", fieldName); let group = groups.get(groupName); if (!group) { group = {}; groups.set(groupName, group); } group[field] = def; } for (let [groupName, groupDefs] of groups) { let treeNode: TreeNode = { key: groupName, title: groupName, children: [], }; for (let [field, def] of Object.entries(groupDefs)) { if (def.hidden === true) { continue; } const fieldName = `${groupName}.${field}`; const canWrite = config.canAccess(fieldName, lib.ConfigAccess.write); const canRead = config.canAccess(fieldName, lib.ConfigAccess.read); if (!canWrite && !canRead) { continue; } let value = config.get(fieldName) as any; let childNode: ChildNode = { key: fieldName, children: [], }; if (def.type === "object" && !Object.keys(control.inputComponents).includes(def.inputComponent!)) { let restartRequiredProps = new Set(def.restartRequiredProps || []); childNode.title = {def.title || field} {!def.restartRequiredProps && def.restartRequired && restartTip} } tooltip={def.description} />; for (let prop of Object.keys(value ?? {})) { let propPath = `${groupName}.${field}.${prop}`; let restart = Boolean( def.restartRequiredProps && Number(def.restartRequired) ^ Number(restartRequiredProps.has(prop)) ); childNode.children.push({ key: propPath, title: {`"${prop}"`} {restart && restartTip} } rules={[{ validator: (_, fieldValue) => { if (!fieldValue.length) { return Promise.reject(new Error("Will be removed")); } try { JSON.parse(fieldValue); } catch (err) { return Promise.reject(new Error("Must be valid json")); } return Promise.resolve(); }}]} > , }); initialValues[propPath] = JSON.stringify(value[prop]); propsMap.set(propPath, [fieldName, prop]); } if (canWrite) { let newPropPath = `${groupName}.${field}:add`; childNode.children.push({ key: newPropPath, title: : , }); } if (!childNode.children.length) { continue; } } else { childNode.title = {def.title || field} {def.restartRequired && restartTip} } validateStatus={errorFields.has(fieldName) ? "error" : undefined} help={errorFields.get(fieldName)} tooltip={def.description} valuePropName={def.type === "boolean" ? "checked" : "value"} > {renderInput(control.inputComponents, def, !canWrite)} ; initialValues[fieldName] = value; } treeNode.children.push(childNode); } treeData.push(treeNode); } return [propsMap, treeData] as [typeof propsMap, typeof treeData]; }