Implement custom input for mod pack config (#547)

Add custom input component support to the Web UI config in order to
provide a user friendly dropdown selector for the Mod Pack that is
configured as default on the controller and overridden instances.
This commit is contained in:
Hornwitser authored and GitHub committed 2024-01-17 20:29:04 +01:00
1 parent 80f0c63113
commit 863d90c7fb
8 files changed
+86 -13

No files matched your search

+1 -1
View File
@@ -638,7 +638,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
*/
async syncMods() {
this.logger.info("Syncing mods");
const modPackId = this.config.get("factorio.mod_pack");
const modPackId = this.config.get("factorio.mod_pack_id");
let modPack;
if (modPackId === null) {
modPack = await this.sendTo("controller", new lib.ModPackGetDefaultRequest());
+5
View File
@@ -77,6 +77,11 @@ export interface FieldDefinition {
* Text used to describe the config field in user interfaces.
*/
description?: string;
/**
* Web UI component to use for rending this input. Can be extended by
* @{link "@clusterio/web_ui".BaseWebPlugin.inputComponents}
*/
inputComponent?: string;
/**
* True if this field can be set to null. Defaults to false.
*/
+7 -3
View File
@@ -150,6 +150,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
"controller.default_mod_pack_id": {
title: "Default Mod Pack",
description: "Mod pack used by default for instances.",
inputComponent: "mod_pack",
type: "number",
optional: true,
},
@@ -265,7 +266,7 @@ export interface InstanceConfigFields {
"factorio.rcon_port": number | null;
"factorio.rcon_password": string | null;
"factorio.player_online_autosave_slots": number;
"factorio.mod_pack": number | null;
"factorio.mod_pack_id": number | null;
"factorio.enable_save_patching": boolean;
"factorio.enable_whitelist": boolean;
"factorio.enable_authserver_bans": boolean;
@@ -340,8 +341,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "number",
initialValue: 5,
},
"factorio.mod_pack": {
description: "Mod pack to use on this server",
"factorio.mod_pack_id": {
title: "Mod Pack",
description:
"Mod pack to use on this server, if not set the default configured on the controller will be used",
inputComponent: "mod_pack",
restartRequired: true,
type: "number",
optional: true,
+14 -2
View File
@@ -1,5 +1,5 @@
import type React from "react";
import type { AccountRole, Logger, PluginWebpackEnvInfo } from "@clusterio/lib";
import type { AccountRole, FieldDefinition, Logger, PluginWebpackEnvInfo } from "@clusterio/lib";
import type { Control } from "./util/websocket";
/**
@@ -21,7 +21,14 @@ export interface PluginLoginForm {
* when an authentication token is aquired via this form.
*/
Component: React.ComponentType<{ setToken(token: string): void }>;
};
}
export interface InputComponentProps {
fieldDefinition: FieldDefinition,
value: null | boolean | number | string,
onChange: (value: null | boolean | number | string) => void,
}
export type InputComponent = React.ComponentType<InputComponentProps>;
export type UserAccount = {
/** Name of the currently logged in account. */
@@ -94,6 +101,11 @@ export default class BaseWebPlugin {
* List of pages provided by this plugin
*/
pages: PluginPage[] = [];
/**
* Additional Config inputComponent types available to render config
* entries with.
*/
inputComponents: Record<string, InputComponent> = {};
/**
* Extra react component to add to core components
*
+18 -1
View File
@@ -6,7 +6,8 @@ import { createRoot } from "react-dom/client";
import * as lib from "@clusterio/lib";
import App from "./components/App";
import BaseWebPlugin from "./BaseWebPlugin";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputModPack from "./components/InputModPack";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -97,6 +98,21 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
return plugins;
}
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"mod_pack": InputModPack,
};
for (let [pluginName, plugin] of plugins) {
for (const [name, Component] of Object.entries(plugin.inputComponents)) {
if (Object.prototype.hasOwnProperty.call(inputComponents, name)) {
lib.logger.warn(`Plugin ${pluginName} is redefining config inputComponent ${name}`);
}
inputComponents[name] = Component;
}
}
return inputComponents;
}
export default async function bootstrap() {
logger.add(new ConsoleTransport({
level: "verbose",
@@ -110,6 +126,7 @@ export default async function bootstrap() {
let controlConnector = new ControlConnector(wsUrl.href, 120, undefined);
let control = new Control(controlConnector);
control.plugins = await loadPlugins(pluginInfos, control);
control.inputComponents = inputComponentsFromPlugins(control.plugins);
const root = createRoot(document.getElementById("root") as HTMLDivElement);
root.render(<App control={control}/>);
@@ -1,13 +1,18 @@
import * as lib from "@clusterio/lib";
import React, { useEffect, useState } from "react";
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)) {
@@ -27,7 +32,13 @@ function getInitialValues(config: lib.Config<any>, props: BaseConfigTreeProps) {
return initialValues;
}
function renderInput(def: lib.FieldDefinition) {
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/>;
}
@@ -57,6 +68,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
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();
@@ -112,6 +124,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
}
const [propsMap, treeData] = computeTreeData(
control,
form,
initialValues,
errorFields,
@@ -201,6 +214,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
}
function computeTreeData(
control: Control,
form: FormInstance<any>,
initialValues: any,
errorFields: Map<string, string>,
@@ -352,7 +366,7 @@ function computeTreeData(
tooltip={def.description}
valuePropName={def.type === "boolean" ? "checked" : "value"}
>
{renderInput(def)}
{renderInput(control.inputComponents, def)}
</Form.Item>;
initialValues[fieldName] = value;
}
@@ -0,0 +1,20 @@
import React from "react";
import { Select } from "antd";
import { useModPacks } from "../model/mod_pack";
import { InputComponentProps } from "../BaseWebPlugin";
export default function InputModPack(props: InputComponentProps) {
const [modPacks] = useModPacks();
return <Select
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
options={[...modPacks.values()].map(modPack => ({
label: modPack.name,
value: modPack.id,
}))}
allowClear={props.fieldDefinition.optional}
/>;
}
+2 -1
View File
@@ -1,6 +1,6 @@
import * as lib from "@clusterio/lib";
import packageJson from "../../package.json";
import BaseWebPlugin from "../BaseWebPlugin";
import BaseWebPlugin, { type InputComponent } from "../BaseWebPlugin";
const { logFilter, logger } = lib;
@@ -53,6 +53,7 @@ export class Control extends lib.Link {
/** Plugins loaded in the web interface */
public plugins = new Map<string, BaseWebPlugin>();
public inputComponents: Record<string, InputComponent> = {};
/** Updates not handled by the subscription service */
accountUpdateHandlers: accountHandler[] = [];