From 5d1e98dafd7f1e2e102b688b329bfd45d24c19d9 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:18:32 +0100 Subject: [PATCH] Hide unknown mod pack settings and allow removing them (#1179) Settings stored in a mod pack that no setting prototype in the export data defines are now listed in a collapsed "Unknown settings" panel under each scope instead of under an "Unknown Fields" heading. Each one has a remove link, and a revert link once removed, matching what `clusterioctl mod-pack edit --remove-setting` already allows. They are never removed automatically. Without export data nothing is called unknown and stored settings are listed as before, without a mod heading. Fixes #773 Co-authored-by: Claude Opus 5.5 --- .../web_ui/src/components/ModPackViewPage.tsx | 170 ++++++++++-------- packages/web_ui/src/util/mod_settings.ts | 63 +++++++ test/web_ui/mod_settings.js | 56 ++++++ 3 files changed, 212 insertions(+), 77 deletions(-) create mode 100644 packages/web_ui/src/util/mod_settings.ts create mode 100644 test/web_ui/mod_settings.js diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index cbb4e592..933f6f4b 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -4,7 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Switch, Modal, Tooltip, - TablePaginationConfig, Alert, notification, FormProps, + TablePaginationConfig, Alert, notification, FormProps, Collapse, } from "antd"; import { @@ -21,6 +21,7 @@ import { useAccount } from "../model/account"; import { useModPack } from "../model/mod_pack"; import { useMods } from "../model/mods"; import { useClipboard } from "../util/clipboard"; +import { modSettingFields, ModSettingField, ModSettingScope } from "../util/mod_settings"; import notify, { notifyErrorHandler } from "../util/notify"; import useResourceState from "../util/useResourceState"; import PageHeader from "./PageHeader"; @@ -856,11 +857,6 @@ function hasChange(changes: ModChange[], field:Partial) { .every(([key, value]) => (change as any)[key] === value)); } -// picks out the value property from settings. -function pickValue(map: Map): [string, lib.ModSetting["value"]][] { - return [...map].map(([name, value]) => [name, value.value]); -} - function groupToMap(array: any[], fn: (...args:any) => any) { const map = new Map(); for (let index = 0; index < array.length; index++) { @@ -903,12 +899,17 @@ function InputColor(props: { type SettingsTableFieldProps = { name: string; type: string; - scope: string; + scope: ModSettingScope; allowedValues: any; locale: Map; changed: boolean; /** Saved value of the setting, used when the field is created and when the form is reset */ initialValue: lib.ModSetting["value"] | undefined; + /** Show a remove link, or a revert link if removed is set */ + removable?: boolean; + removed?: boolean; + onChange?: (change: ModChange) => void; + onRevert?: (change: ModChange) => void; }; const SettingsTableField = memo((props: SettingsTableFieldProps) => { const name = props.name; @@ -932,25 +933,42 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => { input = ; } + const label = props.locale.get(`mod-setting-name.${name}`) || name; + const change: ModChange = { + type: "settings.delete", scope: props.scope, name, value: { value: props.initialValue! }, + }; return {label} : label} tooltip={props.locale.get(`mod-setting-description.${name}`)} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} - valuePropName={isBoolean ? "checked" : "value"} - rules={[...isNumber ? [{ - validator(rule: any, value: any) { - if (Number.isNaN(Number(value))) { - return Promise.reject(new Error("Must be a number")); - } - return Promise.resolve(); - }, - }] : []]} className={props.changed ? "changed" : ""} > - {input} + + + {input} + + {props.removable && !props.removed && props.onChange?.(change)} + >remove} + {props.removable && props.removed && props.onRevert?.(change)} + >revert} + ; }); @@ -959,7 +977,8 @@ type SettingsTableProps = { modPack: lib.ModPack; /** Mod pack without unsaved changes, reverting the form goes back to its values */ savedModPack: lib.ModPack; - prototypes: any; + /** Exported setting prototypes, undefined if the mod pack has no export data */ + prototypes: lib.ExportSettings | undefined; locale: any; changes: ModChange[]; onChange: (change: ModChange) => void; @@ -973,56 +992,50 @@ function SettingsTable(props: SettingsTableProps) { .map(mod => [mod.name, mod.title]) ); - const types = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"]; - let prototypes = Object.entries(props.prototypes) - .filter(([type, _]) => types.includes(type)) - .flatMap(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes)) - ; + function field(scope: ModSettingScope, setting: ModSettingField, removable = false) { + return ; + } - function controls( - scope: "startup"|"runtime-global"|"runtime-per-user", - storedFields: [string, lib.ModSetting["value"]][] - ) { - let fields = new Map(prototypes - .filter(p => p.setting_type === scope) - .sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name)) - .map(p => [p.name, p]) - ); - for (let [name, value] of storedFields) { - if (!fields.has(name)) { - let type; - if (typeof value === "boolean") { - type = "bool-setting"; - } else if (typeof value === "number") { - type = "double-setting"; - } else if (typeof value === "string") { - type = "string-setting"; - } else if (typeof value === "object" && "r" in value) { - type = "color-setting"; - } else { - throw Error(`Unhandled setting type ${typeof value}`); - } - fields.set(name, { mod: "Unknown Fields", name, setting_type: scope, type }); - } - } - let fieldsByMod = groupToMap([...fields.values()], prototype => prototype.mod); - return [...fieldsByMod].map(([mod, fieldsForMod]) => - - {props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod} - - {[...fieldsForMod].map( - prototype => - )} - ); + function controls(scope: ModSettingScope) { + // Listed from the saved pack so removed settings stay visible until applied + const { known, unknown } = modSettingFields(props.prototypes, scope, props.savedModPack.settings[scope]); + const fieldsByMod = groupToMap(known, setting => setting.mod); + return <> + {[...fieldsByMod].map(([mod, fieldsForMod]) => + {mod !== undefined && + {props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod} + } + {fieldsForMod.map((setting: ModSettingField) => field(scope, setting))} + )} + {unknown.length > 0 && + + No mod in the exported data defines these settings. They are kept so the values come back + if a mod that uses them is added again. + + {unknown.map(setting => field(scope, setting, true))} + , + }]} + />} + ; } return <> @@ -1031,15 +1044,15 @@ function SettingsTable(props: SettingsTableProps) { /> Run export data on an instance to update available fields. Startup - {controls("startup", pickValue(props.modPack.settings["startup"]))} + {controls("startup")} Map These only apply for new saves. - {controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))} + {controls("runtime-global")} Per Player Default settings for new players, these only apply for new saves. - {controls("runtime-per-user", pickValue(props.modPack.settings["runtime-per-user"]))} + {controls("runtime-per-user")} ; } @@ -1101,14 +1114,14 @@ function ExportButton(props: { modPack: lib.ModPack }) { } function useExportedAsset(modPack: lib.ModPack | undefined, asset: "settings"|"locale"): Map | any { - let [assetData, setAssetData] = useState|any>(asset === "locale" ? new Map() : {}); + let [assetData, setAssetData] = useState|any>(asset === "locale" ? new Map() : undefined); let assetFilename: string | undefined; if (modPack instanceof lib.ModPack && modPack.exportManifest?.assets[asset]) { assetFilename = modPack.exportManifest.assets[asset]; } useEffect(() => { async function load() { - const empty = asset === "locale" ? new Map() : {}; + const empty = asset === "locale" ? new Map() : undefined; if (!assetFilename) { setAssetData(empty); return; @@ -1202,7 +1215,7 @@ export default function ModPackViewPage() { const [form] = Form.useForm(); const [modPack, synced, error] = useModPack(modPackId); const state = useResourceState(modPack, synced, error, modPackId); - let prototypes: any = useExportedAsset(modPack, "settings"); + let prototypes: lib.ExportSettings | undefined = useExportedAsset(modPack, "settings"); let locale: Map = useExportedAsset(modPack, "locale"); let [changes, setChanges] = useState([]); let syncTimeout = useRef|undefined>(); @@ -1232,7 +1245,7 @@ export default function ModPackViewPage() { setChanges(oldChanges => { const newChanges = [...oldChanges]; const index = newChanges.findLastIndex(reference => ( - change.type === reference.type && change.name === reference.name + change.type === reference.type && change.name === reference.name && change.scope === reference.scope )); if (index === -1) { logger.error(`Unable to revert ${change.type} ${change.name}: change not found`); @@ -1255,6 +1268,9 @@ export default function ModPackViewPage() { } } else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) { let [scope, settingName]: ["startup"|"runtime-global"|"runtime-per-user", string] = field.name; + if (modPack!.settings[scope].has(settingName) && !modifiedModPack!.settings[scope].has(settingName)) { + continue; // Removed + } let value = field.value; if (typeof modPack!.settings[scope].get(settingName)?.value === "number") { value = Number(value); diff --git a/packages/web_ui/src/util/mod_settings.ts b/packages/web_ui/src/util/mod_settings.ts new file mode 100644 index 00000000..0dc43ce5 --- /dev/null +++ b/packages/web_ui/src/util/mod_settings.ts @@ -0,0 +1,63 @@ +import type * as lib from "@clusterio/lib"; + +export type ModSettingScope = "startup" | "runtime-global" | "runtime-per-user"; + +export type ModSettingField = { + name: string; + type: string; + mod?: string; + order?: string; + allowed_values?: unknown; +}; + +const settingTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"]; +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +function typeFromValue(value: lib.ModSetting["value"]) { + if (typeof value === "boolean") { + return "bool-setting"; + } else if (typeof value === "number") { + return "double-setting"; + } else if (typeof value === "string") { + return "string-setting"; + } else if (typeof value === "object" && "r" in value) { + return "color-setting"; + } + throw Error(`Unhandled setting type ${typeof value}`); +} + +/** + * Split the settings of a scope into ones defined by the exported prototypes and stored ones + * no prototype defines. Without export data there is nothing to compare to, so none are unknown. + */ +export function modSettingFields( + prototypes: lib.ExportSettings | undefined, + scope: ModSettingScope, + stored: Map, +) { + const fromStored = (name: string): ModSettingField => ({ name, type: typeFromValue(stored.get(name)!.value) }); + if (!prototypes) { + return { known: [...stored.keys()].map(fromStored), unknown: [] }; + } + + const known: ModSettingField[] = Object.entries(prototypes) + .filter(([type]) => settingTypes.includes(type)) + .flatMap(([_, settings]) => Object.values(settings)) + .filter(p => p.setting_type === scope) + .map(p => ({ + name: p.name, + type: p.type, + mod: p.mod as string, + order: p.order, + allowed_values: p.allowed_values, + })) + .sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name)) + ; + const knownNames = new Set(known.map(field => field.name)); + const unknown = [...stored.keys()] + .filter(name => !knownNames.has(name)) + .sort(strcmp) + .map(fromStored) + ; + return { known, unknown }; +} diff --git a/test/web_ui/mod_settings.js b/test/web_ui/mod_settings.js new file mode 100644 index 00000000..fe533cb0 --- /dev/null +++ b/test/web_ui/mod_settings.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; + +import { modSettingFields } from "../../packages/web_ui/src/util/mod_settings.ts"; + +describe("web_ui/util/mod_settings", function() { + describe("modSettingFields()", function() { + const stored = new Map([ + ["kept", { value: true }], + ["gone-number", { value: 2 }], + ["gone-color", { value: { r: 1, g: 0, b: 0, a: 1 } }], + ["gone-string", { value: "x" }], + ]); + const prototypes = { + "bool-setting": { + "kept": { type: "bool-setting", name: "kept", mod: "b", setting_type: "startup", default_value: false }, + "map": { + type: "bool-setting", name: "map", mod: "a", setting_type: "runtime-global", default_value: false, + }, + }, + "string-setting": { + "first": { + type: "string-setting", name: "first", mod: "a", setting_type: "startup", + default_value: "x", allowed_values: ["x", "y"], + }, + }, + "unrelated": { "gone-number": { type: "unrelated", name: "gone-number", setting_type: "startup" } }, + }; + + it("should split stored settings no prototype defines into unknown", function() { + const { known, unknown } = modSettingFields(prototypes, "startup", stored); + assert.deepEqual(known.map(field => field.name), ["first", "kept"]); + assert.deepEqual(known[0].allowed_values, ["x", "y"]); + assert.deepEqual( + unknown.map(field => [field.name, field.type]), + [["gone-color", "color-setting"], ["gone-number", "double-setting"], ["gone-string", "string-setting"]], + ); + }); + it("should only use prototypes of the given scope", function() { + const kept = new Map([["kept", { value: true }]]); + const { known, unknown } = modSettingFields(prototypes, "runtime-global", kept); + assert.deepEqual(known.map(field => field.name), ["map"]); + assert.deepEqual(unknown.map(field => field.name), ["kept"]); + }); + it("should not call settings unknown without export data", function() { + const { known, unknown } = modSettingFields(undefined, "startup", stored); + assert.deepEqual(known.map(field => field.name), [...stored.keys()]); + assert(known.every(field => field.mod === undefined)); + assert.deepEqual(unknown, []); + }); + it("should treat every stored setting as unknown if the export has no settings", function() { + const { known, unknown } = modSettingFields({}, "startup", stored); + assert.deepEqual(known, []); + assert.equal(unknown.length, stored.size); + }); + }); +});