Files
factorio-clusterio/packages/web_ui/src/components/BaseConfigTree.tsx
T
Hornwitser 001db4befe Refactor config to JSON data class pattern
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.
2024-01-06 19:53:32 +01:00

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>
</>;
}