mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
Refactor the Config and ConfigGroup classes to match the JSON data class pattern with toJSON, toRemote and fromJSON methods replacing the load, init, and serialize methods. Since fromJSON requires extra information the messages structures passes around the plain JSON representation of the configs instead of serialising instances of the config classes automatically.
324 lines
8.2 KiB
TypeScript
324 lines
8.2 KiB
TypeScript
import type * as lib from "@clusterio/lib";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd";
|
|
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
|
|
|
|
const { Title } = Typography;
|
|
|
|
function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) {
|
|
let initialValues: any = {};
|
|
for (let [name, GroupClass] of props.ConfigClass.groups) {
|
|
let group = config.group(name);
|
|
for (let def of GroupClass.definitions.values()) {
|
|
if (!group.canAccess(def.name)) {
|
|
continue;
|
|
}
|
|
|
|
let value = group.get(def.name) as any;
|
|
let fieldName = `${group.name}.${def.name}`;
|
|
|
|
if (def.type === "object") {
|
|
for (let prop of Object.keys(value)) {
|
|
let propPath = `${group.name}.${def.name}.${prop}`;
|
|
initialValues[propPath] = JSON.stringify(value[prop]);
|
|
}
|
|
} else {
|
|
initialValues[fieldName] = value;
|
|
}
|
|
}
|
|
}
|
|
return initialValues;
|
|
}
|
|
|
|
type BaseConfigTreeProps = {
|
|
ConfigClass: typeof lib.Config;
|
|
retrieveConfig: () => Promise<lib.SerializedConfig>;
|
|
setField: (field:string, value:any) => Promise<void>;
|
|
setProp: (field:string, prop:string, value:any) => Promise<void>;
|
|
id?: number;
|
|
onApply?: () => void;
|
|
};
|
|
export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
|
let [config, setConfig] = useState<lib.Config|null>(null);
|
|
let [form] = Form.useForm();
|
|
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
|
|
let [errorFields, setErrorFields] = useState(new Map());
|
|
let [applying, setApplying] = useState(false);
|
|
|
|
async function updateConfig() {
|
|
let serializedConfig = await props.retrieveConfig();
|
|
const newConfig = props.ConfigClass.fromJSON(serializedConfig, "control");
|
|
setConfig(newConfig);
|
|
return newConfig;
|
|
}
|
|
useEffect(() => {
|
|
updateConfig().then(updatedConfig => {
|
|
form.setFieldsValue(getInitialValues(updatedConfig, props));
|
|
});
|
|
}, [props.id]);
|
|
|
|
function renderInput(def: lib.FieldDefinition) {
|
|
if (def.type === "boolean") {
|
|
return <Checkbox/>;
|
|
}
|
|
if (def.type === "string") {
|
|
return <Input/>;
|
|
}
|
|
if (def.type === "number") {
|
|
return <InputNumber/>;
|
|
}
|
|
|
|
return `Unknown type ${def.type}`;
|
|
}
|
|
|
|
if (config === null) {
|
|
return <Spin/>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (initialValues[key] === value) {
|
|
changed = newChangedFields.delete(key) || changed;
|
|
} else {
|
|
changed = !newChangedFields.has(key) || changed;
|
|
newChangedFields.add(key);
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setChangedFields(newChangedFields);
|
|
}
|
|
}
|
|
|
|
let restartTip = <Tooltip
|
|
className="ant-form-item-tooltip"
|
|
title="A restart is required for this setting to take effect"
|
|
><ReloadOutlined/></Tooltip>;
|
|
|
|
|
|
type ChildNode = {
|
|
key: string;
|
|
children: any[];
|
|
title?: React.ReactElement;
|
|
};
|
|
type TreeNode = {
|
|
key: string;
|
|
title: string;
|
|
children: ChildNode[];
|
|
};
|
|
|
|
let treeData = [];
|
|
let propsMap = new Map();
|
|
for (let [name, GroupClass] of props.ConfigClass.groups) {
|
|
let group = config.group(name);
|
|
let treeNode: TreeNode = {
|
|
key: group.name,
|
|
title: group.name,
|
|
children: [],
|
|
};
|
|
|
|
for (let def of GroupClass.definitions.values()) {
|
|
if (!group.canAccess(def.name)) {
|
|
continue;
|
|
}
|
|
|
|
let value = group.get(def.name) as any;
|
|
let fieldName = `${group.name}.${def.name}`;
|
|
let childNode: ChildNode = {
|
|
key: fieldName,
|
|
children: [],
|
|
};
|
|
|
|
if (def.type === "object") {
|
|
let restartRequiredProps = new Set(def.restartRequiredProps || []);
|
|
childNode.title = <Form.Item
|
|
label={<>
|
|
{def.title || def.name}
|
|
{!def.restartRequiredProps && def.restartRequired && restartTip}
|
|
</>}
|
|
tooltip={def.description}
|
|
/>;
|
|
for (let prop of Object.keys(value)) {
|
|
let propPath = `${group.name}.${def.name}.${prop}`;
|
|
let restart = Boolean(
|
|
def.restartRequiredProps && Number(def.restartRequired) ^ Number(restartRequiredProps.has(prop))
|
|
);
|
|
childNode.children.push({
|
|
key: propPath,
|
|
title: <Form.Item
|
|
name={propPath}
|
|
label={<>
|
|
{`"${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();
|
|
}}]}
|
|
>
|
|
<Input className="json-input" />
|
|
</Form.Item>,
|
|
});
|
|
|
|
initialValues[propPath] = JSON.stringify(value[prop]);
|
|
propsMap.set(propPath, [fieldName, prop]);
|
|
}
|
|
|
|
let newPropPath = `${group.name}.${def.name}:add`;
|
|
childNode.children.push({
|
|
key: newPropPath,
|
|
title: <Space>
|
|
<Form.Item
|
|
name={`${newPropPath}.name`}
|
|
noStyle={true}
|
|
>
|
|
<Input />
|
|
</Form.Item>:
|
|
<Form.Item
|
|
name={`${newPropPath}.value`}
|
|
noStyle={true}
|
|
>
|
|
<Input className="json-input" />
|
|
</Form.Item>
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
let propName = form.getFieldValue(`${newPropPath}.name`);
|
|
let propValue = form.getFieldValue(`${newPropPath}.value`);
|
|
if (!Object.prototype.hasOwnProperty.call(value, propName)) {
|
|
let newConfig = props.ConfigClass.fromJSON(config!.toJSON(), "control");
|
|
newConfig.setProp(fieldName, propName, null);
|
|
setConfig(newConfig);
|
|
}
|
|
|
|
let propPath = `${group.name}.${def.name}.${propName}`;
|
|
form.setFieldsValue({
|
|
[`${newPropPath}.name`]: "",
|
|
[`${newPropPath}.value`]: "",
|
|
[propPath]: propValue,
|
|
});
|
|
form.validateFields([propPath]);
|
|
onValuesChange({ [propPath]: propValue });
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Space>,
|
|
});
|
|
|
|
} else {
|
|
childNode.title = <Form.Item
|
|
name={fieldName}
|
|
label={<>
|
|
{def.title || def.name}
|
|
{def.restartRequired && restartTip}
|
|
</>}
|
|
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
|
help={errorFields.get(fieldName)}
|
|
tooltip={def.description}
|
|
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
|
>
|
|
{renderInput(def)}
|
|
</Form.Item>;
|
|
initialValues[fieldName] = value;
|
|
}
|
|
|
|
treeNode.children.push(childNode);
|
|
}
|
|
|
|
treeData.push(treeNode);
|
|
}
|
|
|
|
return <>
|
|
<Title level={5} style={{ marginTop: 16 }}>
|
|
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>
|
|
</Title>
|
|
<Card size="small" className="config-container">
|
|
<Form
|
|
size="small"
|
|
form={form}
|
|
initialValues={initialValues}
|
|
onValuesChange={onValuesChange}
|
|
>
|
|
<Tree
|
|
treeData={treeData}
|
|
selectable={false}
|
|
defaultExpandAll={true}
|
|
filterTreeNode={(node) => changedFields.has(node.key)}
|
|
/>
|
|
</Form>
|
|
</Card>
|
|
</>;
|
|
}
|