mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user