Files
factorio-clusterio/packages/web_ui/src/components/BaseConfigTree.tsx
T
bbassieandClaude Opus 5.5 f14bfc3222 Address review of template inheritance
- Unlinking a field enables Apply on its own and sends the current value.
- The link button no longer toggles the checkbox of its label.
- Link and disconnect icons for all four states, pending ones highlighted.
- instance.is_template is hidden.
- ctl create takes --as-template.
- Clearer names for the InstanceManager inheritance helpers.

Part of #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:57:01 +00:00

560 lines
17 KiB
TypeScript

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<any>, 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<string, InputComponent>, 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<Omit<InputComponentProps, "value" | "onChange">>;
const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial;
return <CustomInput fieldDefinition={def} disabled={readonly} />;
}
if (def.type === "boolean") {
return <Checkbox disabled={readonly} />;
}
if (def.type === "string") {
if (def.credential) {
return <Input.Password autoComplete={def.autoComplete ?? "new-password"} disabled={readonly} />;
}
if (def.enum) {
return <Select
showSearch
style={{ minWidth: 175 }}
options={def.enum.map(value => ({ label: value, value: value }))}
allowClear={def.optional}
disabled={readonly}
/>;
}
return <Input autoComplete={def.autoComplete} disabled={readonly} />;
}
if (def.type === "number") {
return <InputNumber autoComplete={def.autoComplete} disabled={readonly} />;
}
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<any>, field: string) => boolean;
isInherited: (config: lib.Config<any>, field: string, prop?: string) => boolean;
inherit: (field: string, prop?: string) => Promise<void>;
templateValue: (field: string, prop?: string) => unknown;
notice?: (config: lib.Config<any>) => React.ReactNode;
};
type Linking = Map<string, [field: string, prop?: string]>;
type LinkState = {
inheritance?: ConfigInheritance;
unlinking: Set<string>;
setUnlinking: (unlinking: Set<string>) => void;
linking: Linking;
setLinking: (linking: Linking) => void;
};
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
inheritance?: ConfigInheritance;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setConfig: (fields: Record<string, string | Record<string, string>>) => 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);
let [unlinking, setUnlinking] = useState<Set<string>>(new Set());
let [linking, setLinking] = useState<Linking>(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 <>
<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;
}
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<string, string | Record<string, string>>) {
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 <>
<Title level={5} style={{ marginTop: 16 }}>
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>
</Title>
<Card size="small" className="config-container">
{props.inheritance?.notice?.(config)}
<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,
link: LinkState,
) {
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[];
};
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,
) => <Tooltip title={title}>
<Button
type={pending ? "link" : "text"} size="small" icon={icon} aria-label={`${label} ${key}`}
onClick={e => {
// The button sits in the label, which would toggle checkboxes
e.preventDefault();
onClick();
}}
/>
</Tooltip>;
const revert = () => {
form.setFieldValue(key, initialValues[key]);
onValuesChange({ [key]: initialValues[key] });
};
if (inheritance.isInherited(config, fieldName, prop)) {
if (!unlinking.has(key)) {
return button(
"Linked to the template. Click to unlink it and set your own value.", <LinkOutlined />, "Unlink",
false, () => setUnlinking(new Set([...unlinking, key])),
);
}
return button(
"Unlinked from the template when applied. Click to keep it linked.", <DisconnectOutlined />,
"Keep linked", true,
() => { setUnlinking(new Set([...unlinking].filter(k => k !== key))); revert(); },
);
}
if (!linking.has(key)) {
return button(
"Not linked to the template. Click to link it again.", <DisconnectOutlined />, "Link", false,
() => {
setLinking(new Map([...linking, [key, [fieldName, prop]]]));
revert();
// Show the value the field takes after apply
const value = inheritance.templateValue(fieldName, prop);
form.setFieldValue(key, prop === undefined ? value : JSON.stringify(value));
},
);
}
return button(
"Linked to the template when applied. Click to keep this value instead.", <LinkOutlined />,
"Keep unlinked", true,
() => { setLinking(new Map([...linking].filter(([k]) => k !== key))); revert(); },
);
}
function isReadonly(canWrite: boolean, key: string, fieldName: string, prop?: string) {
return !canWrite || link.linking.has(key) || Boolean(link.inheritance?.canInherit(config, fieldName))
&& link.inheritance!.isInherited(config, fieldName, prop)
&& !link.unlinking.has(key);
}
let treeData = [];
const propsMap = new Map<string, [fieldName: string, prop: string]>();
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 (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" && !control.inputComponents.has(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}
{linkButton(propPath, fieldName, prop)}
</>}
dependencies={def.dependsOn}
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
if (!fieldValue.length) {
throw new Error("Will be removed");
}
try {
fieldValue = JSON.parse(fieldValue);
} catch (err) {
throw new Error("Must be valid json");
}
if (!def.validator) { return; }
config.stageProp(fieldName, prop, fieldValue);
for (const dependsOn of def.dependsOn ?? []) {
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
try {
if (propsMap.has(dependsOn)) {
const [name, otherProp] = propsMap.get(dependsOn)!;
config.stageProp(name, otherProp, 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);
}})]}
>
<Input className="json-input" disabled={isReadonly(canWrite, propPath, fieldName, prop)} />
</Form.Item>,
});
initialValues[propPath] = JSON.stringify(value[prop]);
propsMap.set(propPath, [fieldName, prop]);
}
if (canWrite) {
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 (!value || !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}`;
link.setUnlinking(new Set([...link.unlinking, propPath]));
form.setFieldsValue({
[`${newPropPath}.name`]: "",
[`${newPropPath}.value`]: "",
[propPath]: propValue,
});
form.validateFields([propPath]);
onValuesChange({ [propPath]: propValue });
}}
>
Add
</Button>
</Space>,
});
}
if (!childNode.children.length) {
continue;
}
} else {
childNode.title = <Form.Item
name={fieldName}
label={<>
{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))}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
return [propsMap, treeData] as [typeof propsMap, typeof treeData];
}