Files
factorio-clusterio/packages/web_ui/src/components/BaseConfigTree.tsx
T
Danielv123 eb78cdc322 Allow declaring custom config input components for objects
Input components allow us to customize the config field editing experience in plugins, but due to an oversight this did not include object definitions.
2024-05-18 20:57:46 +01:00

382 lines
10 KiB
TypeScript

import React, { useContext, useEffect, useState } from "react";
import {
Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, 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<any>, props: BaseConfigTreeProps) {
let initialValues: any = {};
for (let [name, def] of Object.entries(config.constructor.fieldDefinitions)) {
if (!config.canAccess(name)) {
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<string, InputComponent>, def: lib.FieldDefinition) {
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<Omit<InputComponentProps, "value" | "onChange">>;
const CustomInput = inputComponents[def.inputComponent] as unknown as InputPartial;
return <CustomInput fieldDefinition={def} />;
}
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => Promise<void>;
id?: number;
onApply?: () => void;
available?: boolean;
};
export default function BaseConfigTree(props: BaseConfigTreeProps) {
let [config, setConfig] = useState<lib.Config<any>|null>(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
let [errorFields, setErrorFields] = useState(new Map<string, string>());
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 <>
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
<Card size="small" className="config-container">
Config is currently unavailable.
</Card>
</>;
}
if (config === null) {
return <>
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
<Card size="small" className="config-container">
<Spin size="large" />;
</Card>
</>;
}
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);
}
}
const [propsMap, treeData] = computeTreeData(
control,
form,
initialValues,
errorFields,
config,
setConfig,
props.ConfigClass,
onValuesChange,
);
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>
</>;
}
function computeTreeData(
control: Control,
form: FormInstance<any>,
initialValues: any,
errorFields: Map<string, string>,
config: lib.Config<any>,
setConfig: (newConfig: lib.Config<any>) => void,
ConfigClass: BaseConfigTreeProps["ConfigClass"],
onValuesChange: (changedValues: object) => void,
) {
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();
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
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 (!config.canAccess(`${groupName}.${field}`)) {
continue;
}
let fieldName = `${groupName}.${field}`;
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 = <Form.Item
label={<>
{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: <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 = `${groupName}.${field}: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 = ConfigClass.fromJSON(config!.toJSON(), "control");
(newConfig as lib.Config<any>).setProp(fieldName, propName, null);
setConfig(newConfig);
}
let propPath = `${groupName}.${field}.${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 || 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)}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
return [propsMap, treeData] as [typeof propsMap, typeof treeData];
}