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 <noreply@anthropic.com>
This commit is contained in:
BastiaanandClaude Opus 5.5 authored and GitHub committed 2026-11-04 00:18:32 +00:00
1 parent d250394580
commit 5d1e98dafd
3 files changed
+212 -77

No files matched your search

@@ -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<ModChange>) {
.every(([key, value]) => (change as any)[key] === value));
}
// picks out the value property from settings.
function pickValue(map: Map<string, lib.ModSetting>): [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<string, any>;
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 = <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 <Form.Item
name={[props.scope, name]}
initialValue={props.initialValue}
label={props.locale.get(`mod-setting-name.${name}`) || name}
label={props.removed ? <Text delete>{label}</Text> : 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}
<Space>
<ConfigProvider componentDisabled={props.removed}><Form.Item
noStyle
name={[props.scope, name]}
initialValue={props.initialValue}
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();
},
}] : []]}
>
{input}
</Form.Item></ConfigProvider>
{props.removable && !props.removed && <Typography.Link
type="danger"
onClick={() => props.onChange?.(change)}
>remove</Typography.Link>}
{props.removable && props.removed && <Typography.Link
onClick={() => props.onRevert?.(change)}
>revert</Typography.Link>}
</Space>
</Form.Item>;
});
@@ -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<any>(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes))
;
function field(scope: ModSettingScope, setting: ModSettingField, removable = false) {
return <SettingsTableField
key={`${scope} ${setting.name}`}
name={setting.name}
type={setting.type}
scope={scope}
allowedValues={setting.allowed_values}
locale={props.locale}
changed={hasChange(props.changes, { type: "settings.set", scope, name: setting.name })}
initialValue={props.savedModPack.settings[scope].get(setting.name)?.value}
removable={removable}
removed={removable && hasChange(props.changes, { type: "settings.delete", scope, name: setting.name })}
onChange={props.onChange}
onRevert={props.onRevert}
/>;
}
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]) => <Fragment key={mod}>
<Typography.Title level={5}>
{props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod}
</Typography.Title>
{[...fieldsForMod].map(
prototype => <SettingsTableField
key={`${scope} ${prototype.name}`}
name={prototype.name}
type={prototype.type}
scope={scope}
allowedValues={prototype.allowed_values}
locale={props.locale}
changed={hasChange(props.changes, { type: "settings.set", scope, name: prototype.name })}
initialValue={props.savedModPack.settings[scope].get(prototype.name)?.value}
/>
)}
</Fragment>);
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]) => <Fragment key={mod ?? ""}>
{mod !== undefined && <Typography.Title level={5}>
{props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod}
</Typography.Title>}
{fieldsForMod.map((setting: ModSettingField) => field(scope, setting))}
</Fragment>)}
{unknown.length > 0 && <Collapse
size="small"
style={{ marginBottom: 24 }}
items={[{
key: "unknown",
label: `Unknown settings (${unknown.length})`,
children: <>
<Typography.Paragraph type="secondary">
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.
</Typography.Paragraph>
{unknown.map(setting => field(scope, setting, true))}
</>,
}]}
/>}
</>;
}
return <>
@@ -1031,15 +1044,15 @@ function SettingsTable(props: SettingsTableProps) {
/>
<Typography.Paragraph>Run export data on an instance to update available fields.</Typography.Paragraph>
<Typography.Title level={4}>Startup</Typography.Title>
{controls("startup", pickValue(props.modPack.settings["startup"]))}
{controls("startup")}
<Typography.Title level={4}>Map</Typography.Title>
<Typography.Paragraph>These only apply for new saves.</Typography.Paragraph>
{controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))}
{controls("runtime-global")}
<Typography.Title level={4}>Per Player</Typography.Title>
<Typography.Paragraph>Default settings for new players, these only apply for new saves.</Typography.Paragraph>
{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, any> | any {
let [assetData, setAssetData] = useState<Map<any, any>|any>(asset === "locale" ? new Map() : {});
let [assetData, setAssetData] = useState<Map<any, any>|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<any, any> = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState<ModChange[]>([]);
let syncTimeout = useRef<ReturnType<typeof setTimeout>|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);
+63
View File
@@ -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<string, lib.ModSetting>,
) {
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 };
}
+56
View File
@@ -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);
});
});
});