Files
factorio-clusterio/packages/web_ui/src/components/ModPackViewPage.tsx
T
2026-11-01 23:10:27 +00:00

1415 lines
43 KiB
TypeScript

import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
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,
} from "antd";
import {
ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined,
CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined,
LikeOutlined,
} from "@ant-design/icons";
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useModPack } from "../model/mod_pack";
import { useMods } from "../model/mods";
import { useClipboard } from "../util/clipboard";
import notify, { notifyErrorHandler } from "../util/notify";
import useResourceState from "../util/useResourceState";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import ModDetails from "./ModDetails";
import { InputPartialVersion } from "./InputVersion";
import Link from "./Link";
const { logger } = lib;
const { Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
type FieldData = NonNullable<FormProps["fields"]>[number];
type ModChange =
{
type: "name" | "description"
name?: never,
scope?: never,
value: string,
} | {
type: "factorioVersion",
name?: never,
scope?: never,
value: lib.PartialVersion,
} | {
type: "settings.set" | "settings.delete",
name: string,
scope: "startup" | "runtime-global" | "runtime-per-user",
value: lib.ModSetting,
} | {
type: "mods.set" | "mods.delete",
name: string,
scope?: never,
value: lib.ModRecord,
}
;
type ModResult = {
name: string;
versions: lib.ModInfo[];
};
type SearchModsTableProps = {
modPack: lib.ModPack;
changes: ModChange[];
onChange: (change: ModChange) => void;
};
function SearchModsTable(props: SearchModsTableProps) {
const control = useContext(ControlContext);
const [searchText, setSearchText] = useState<string>("");
const [modResultSort, setModResultSort] = useState<string|undefined>("title");
const [modResultSortOrder, setModResultSortOrder] = useState<string|undefined>("asc");
const [modResults, setModResults] = useState<ModResult[]>([]);
const [modResultPage, setModResultPage] = useState<number>(1);
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
const [modResultCount, setModResultCount] = useState<number>(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
} catch (err) {
setFactorioVersion(null);
}
}, [props.modPack.factorioVersion]);
useEffect(() => {
let canceled = false;
if (factorioVersion) {
control.send(new lib.ModSearchRequest(
searchText,
factorioVersion,
modResultPage,
modResultPageSize,
modResultSort,
modResultSortOrder,
)).then(response => {
if (canceled) {
return;
}
// In React < v18 this causes 3 renders and a partial state necessitating the use
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
setModResults(response.results);
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
setModResultCount(response.resultCount);
});
}
return () => {
canceled = true;
};
}, [searchText, factorioVersion, modResultPageSize, modResultPage, modResultSort, modResultSortOrder]);
function modResultPageChanged(page: number, pageSize: number) {
setModResultPage(page);
setModResultPageSize(pageSize);
}
function modResultsTableChanged(
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<ModResult>|SorterResult<ModResult>[],
extra: TableCurrentDataSource<ModResult>,
) {
if (extra.action === "sort") {
if (sorter instanceof Array) {
sorter = sorter[0];
}
if (sorter.order) {
const mapping = {
ascend: "asc",
descend: "desc",
};
setModResultSort(String(sorter.columnKey));
setModResultSortOrder(mapping[sorter.order]);
} else {
setModResultSort(undefined);
setModResultSortOrder(undefined);
}
}
}
function actions(mod: lib.ModInfo|lib.ModRecord) {
return <Space>
{(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name)?.version !== mod.version)
&& <Typography.Link
onClick={() => {
props.onChange({
type: "mods.set",
name: mod.name,
value: { name: mod.name, enabled: true, version: mod.version, sha1: mod.sha1 },
});
}}
>{props.modPack.mods.has(mod.name) ? "change" : "add"}</Typography.Link>
}
{props.modPack.mods.has(mod.name)
&& <Typography.Link
type="danger"
onClick={() => {
props.onChange({
type: "mods.delete",
name: mod.name,
value: { name: mod.name, enabled: false, version: mod.version, sha1: mod.sha1 },
});
}}
>remove</Typography.Link>
}
</Space>;
}
return <>
<Input
placeholder="Search Mods"
style={{ marginBottom: 8 }}
onChange={(event) => {
let search = event.target.value.trim();
setSearchText(search);
setModResultPage(1);
}}
/>
<SectionHeader
title="Stored Mods"
extra={<Pagination
current={modResultPage}
onChange={modResultPageChanged}
total={modResultCount}
showTotal={total => `${total} results`}
showSizeChanger={true}
size="small"
/>}
/>
<ConfigProvider renderEmpty={() => "No results"}>
<Table
size="small"
columns={[
{
title: "Title",
key: "title",
render: (_, result) => result.versions[0].title,
defaultSortOrder: "ascend",
sorter: true,
},
{
title: "Author",
key: "author",
render: (_, result) => result.versions[0].author,
responsive: ["lg"],
sorter: true,
},
{
title: "Version",
key: "version",
align: "right",
render: (_, result) => <Select
showSearch
size="small"
variant="borderless"
optionFilterProp="label"
value={modResultSelectedVersion.get(result.name) || 0}
onChange={index => {
const newVersions = new Map(modResultSelectedVersion);
newVersions.set(result.name, index);
setModResultSelectedVersion(newVersions);
}}
options={result.versions.map((mod, index) => ({ label: mod.version, value: index }))}
/>,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: (_, result) => (
actions(result.versions[modResultSelectedVersion.get(result.name) || 0])
),
},
]}
expandable={{
expandedRowRender: result => <ModDetails
mod={result.versions[modResultSelectedVersion.get(result.name) || 0]}
actions={actions}
/>,
expandedRowClassName: () => "no-expanded-padding",
}}
onChange={modResultsTableChanged}
dataSource={modResults}
pagination={false}
rowKey="name"
/>
</ConfigProvider>
</>;
}
type DownloadDependenciesProps = {
disabled?: boolean;
checkForUpdates?: boolean;
modPack: lib.ModPack;
mods: lib.ModRecord[];
onChange: (change: ModChange) => void;
builtInModNames: string[];
};
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
const control = useContext(ControlContext);
const [modInfos] = useMods();
// State for the modal and dependencies
const [open, setOpen] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // Missing mods and dependencies, excluding builtin
const [modErrors, setModErrors] = useState({
notFound: new Array<string>(), // Mods which were not found on the mod portal, can include builtins
incompatible: new Array<string>(), // Mods which are required, but are marked incompatible by another
unsatisfiable: new Array<string>(), // Mods which can not have their version range satisfied
invalidDependency: new Array<string>(), // Mods with invalid dependency specs
});
function resetModErrors() {
setModErrors({
notFound: [],
incompatible: [],
unsatisfiable: [],
invalidDependency: [],
});
}
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
} catch (err) {
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
setFactorioVersion(null);
}
}, [props.modPack.factorioVersion]);
// Fetch all mods from backend when modal opens or version changes
useEffect(() => {
if (!open || !factorioVersion) {
// Clear if closed or no version
setMods([]);
setMissing([]);
resetModErrors();
return;
}
setLoading(true);
setError(null);
let canceled = false;
control.send(
lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates)
).then(response => {
if (canceled) { return; }
const newModErrors = [...response.errors.entries()]
.reduce<typeof modErrors>((acc, [modName, modError]) => {
acc[modError].push(modName);
return acc;
}, {
notFound: [],
incompatible: [],
unsatisfiable: [],
invalidDependency: [],
});
setModErrors(newModErrors);
setMods(response.dependencies);
setMissing(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`)));
setLoading(false);
}).catch(err => {
if (canceled) { return; }
notifyErrorHandler("Error fetching mods dependencies")(err);
setError(err);
setMods([]);
setMissing([]);
resetModErrors();
setLoading(false);
});
// eslint-disable-next-line consistent-return
return () => {
canceled = true;
};
// Re-fetch when modal opens or factorio version changes
}, [open, factorioVersion, control]);
// Fix any mod errors found after resolving dependencies
function fixModErrors() {
if (!factorioVersion) {
return;
}
// Enable any required builtins, they will be within the not found list
for (const modName of modErrors.notFound) {
if (props.builtInModNames.includes(modName)) {
props.onChange({
type: "mods.set",
name: modName,
value: { ...props.modPack.mods.get(modName)!, enabled: true },
});
}
}
// Disable any incompatible mods
for (const modName of modErrors.incompatible) {
const modRecord = props.modPack.mods.get(modName);
if (modRecord) {
props.onChange({
type: "mods.set",
name: modName,
value: { ...modRecord, enabled: false },
});
}
}
// Add all dependencies to the mod pack
for (const mod of mods) {
const existingMod = props.modPack.mods.get(mod.name);
if (!existingMod || existingMod.version !== mod.version) {
props.onChange({
type: "mods.set",
name: mod.name,
value: {
name: mod.name,
version: mod.version,
sha1: mod.sha1,
enabled: true,
},
});
}
}
// Download any missing mods
control.send(
new lib.ModPortalDownloadRequest(
missing.map(mod => ({
name: mod.name,
// mod.version is the raw version; the equality bound must be canonical.
version: new lib.ModVersionEquality("=", mod.normalisedVersion),
})),
factorioVersion,
)
).then(() => {
notification.success({
message: "Download complete",
description: `${missing.length} mods have been downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
);
}
return <>
{props.checkForUpdates && <Button
disabled={props.disabled}
icon={<CloudSyncOutlined />}
onClick={() => { setOpen(true); }}
>Check for Updates</Button> || <Button
disabled={props.disabled}
icon={<CloudDownloadOutlined />}
onClick={() => { setOpen(true); }}
>Download Dependencies</Button>}
<Modal
title={props.checkForUpdates ? "Download Updates" : "Download Dependencies"}
open={open}
onCancel={() => { setOpen(false); }}
width={1000}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
<Button
key="start"
type="primary"
disabled={loading || missing.length === 0}
onClick={() => {
setOpen(false);
fixModErrors();
}}
>Start Download</Button>,
]}
>
{/* Display error message if fetching failed */}
{error && <Alert
message="Error Fetching Mods"
description={error.message}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>}
{/* Display warning message for mod errors */}
{([
["notFound", "Failed to find dependencies:"],
["incompatible", "Dependencies are incompatible:"],
["unsatisfiable", "Dependencies could not be satisfied:"],
["invalidDependency", "Dependencies has invalid specifications:"],
] as const).map(([prop, message]) => {
const modNames = modErrors[prop];
return modNames.length === 0 ? null : <Alert
showIcon
type="warning"
message={message}
description={<ul>{modNames.map(name => <li>{name}</li>)}</ul>}
style={{ marginBottom: 14 }}
/>;
})}
{/* Display a table of the missing mods that need to be downloaded */}
<Table
dataSource={missing}
rowKey={record => record.name}
loading={loading}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
showTotal: (total: number) => (
props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies`
),
}}
columns={[
{
title: "Name",
dataIndex: "name",
key: "name",
sorter: true,
},
{
title: "Title",
dataIndex: "title",
key: "title",
sorter: true,
},
{
title: "Author",
dataIndex: "author",
key: "author",
sorter: true,
},
{
title: "Selected Version",
dataIndex: "version",
key: "version",
},
]}
/>
</Modal>
</>;
}
type ModsTableProps = {
modPack: lib.ModPack;
changes: ModChange[];
onChange: (change: ModChange) => void;
onRevert: (change: ModChange) => void;
builtInModNames: string[];
};
function ModsTable(props: ModsTableProps) {
const account = useAccount();
const [modInfos] = useMods();
const [showAddMods, setShowAddMods] = useState(false);
const deletedMods: Map<string, lib.ModRecord> = new Map();
const changedMods: Map<string, lib.ModRecord> = new Map();
for (let change of props.changes) {
if (change.name) {
if (props.modPack.mods.has(change.name)) {
if (change.type === "mods.set") {
changedMods.set(change.name, change.value);
}
} else if (change.type === "mods.delete") {
deletedMods.set(change.name, change.value);
}
}
}
let mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
(mod: lib.ModRecord): lib.ModRecord => {
if (props.builtInModNames.includes(mod.name)) {
return {
...mod,
enabled: mod.enabled ?? false,
};
}
const modInfo = modInfos.get(`${mod.name}_${mod.version}`);
if (!modInfo) {
return {
...mod,
enabled: mod.enabled ?? false,
error: "missing",
};
} else if (mod.sha1 && modInfo.sha1 !== mod.sha1) {
return {
...mod,
enabled: mod.enabled ?? false,
error: "bad_checksum",
};
}
return {
...mod,
enabled: mod.enabled ?? false,
info: modInfo,
};
}
);
for (const mod of mods) {
if (mod.enabled && mod.info) {
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
try {
const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion);
const modFactorioVersion = mod.info.factorioVersion;
if (packFactorioVersion !== modFactorioVersion) {
mod.warning = "wrong_factorio_version";
}
} catch {
// Hit if the mod or mod pack version is not a valid api version
mod.warning = "wrong_factorio_version";
}
}
}
mods = lib.applyModRecordAdvisories(mods, lib.getInstalledModUpdates(mods, modInfos.values()));
async function fixDependencyIssues(mod: lib.ModRecord) {
if (!mod.info) {
throw new Error("Mod record has no info"); // Should be unreachable
}
if (mod.info.factorioVersion === "0.12") {
throw new Error("Factorio version 0.12 not supported by mod portal");
}
let hasFailure = false;
const enabledMods = mods.filter(m => m.enabled);
const modInfoArray = [...modInfos.values()];
for (const dependency of mod.info.dependencies) {
const reason = dependency.checkUnsatisfiedReason(enabledMods);
if (reason === undefined) {
continue; // No issues for this dependency
}
if (reason === "incompatible") {
// Disable the incompatible mod
props.onChange({
type: "mods.set",
name: dependency.name,
value: { ...props.modPack.mods.get(dependency.name)!, enabled: false },
});
continue;
}
if (props.builtInModNames.includes(dependency.name)) {
// If builtin mod then enable it
props.onChange({
type: "mods.set",
name: dependency.name,
value: { ...props.modPack.mods.get(dependency.name)!, enabled: true },
});
continue;
}
// Find a suitable version to add
const candidate = modInfoArray
.filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion
&& (!dependency.version || dependency.version.testVersion(info.version))
))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
if (candidate) {
props.onChange({
type: "mods.set",
name: candidate.name,
value: {
name: candidate.name,
version: candidate.version,
sha1: candidate.sha1,
enabled: true,
},
});
} else {
hasFailure = true;
}
}
return hasFailure;
}
function actions(mod: lib.ModRecord) {
return <Space>
{!deletedMods.has(mod.name) && !props.builtInModNames.includes(mod.name) && <Typography.Link
type="danger"
onClick={() => {
props.onChange({
type: "mods.delete",
name: mod.name,
value: mod,
});
}}
>remove</Typography.Link>}
{(deletedMods.has(mod.name) || changedMods.has(mod.name)) && <Typography.Link
onClick={() => {
props.onRevert({
type: deletedMods.has(mod.name) ? "mods.delete" : "mods.set",
name: mod.name,
value: mod,
});
}}
>revert</Typography.Link>}
{(mod.warning && mod.warning !== "wrong_factorio_version") && <Typography.Link
onClick={async () => {
if (await fixDependencyIssues(mod)) {
notify("Failed to automatically fix all issues", "warning");
}
}}
>fix issues</Typography.Link>}
</Space>;
}
return <>
<SectionHeader
title="Mods"
extra={<Space wrap>
<Button
icon={<ToolOutlined />}
disabled={!mods.some(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")}
onClick={async () => {
const hasFailure = await Promise.all(mods
.filter(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")
.map(m => fixDependencyIssues(m))
);
if (hasFailure.some(v => v)) {
notify("Failed to automatically fix all issues", "warning");
}
}}
>Fix Issues</Button>
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
&& <DownloadDependenciesButton
mods={mods}
modPack={props.modPack}
onChange={props.onChange}
builtInModNames={props.builtInModNames}
disabled={!mods.some(
m => m.enabled
&& (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning))
)}
/>}
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
&& <DownloadDependenciesButton
mods={mods}
modPack={props.modPack}
onChange={props.onChange}
builtInModNames={props.builtInModNames}
checkForUpdates
/>}
<Button icon={<PlusOutlined />} onClick={() => setShowAddMods(true)}>Add</Button>
</Space>}
/>
{showAddMods && <Card
title="Add mods"
size="small"
extra={<Button type="text" icon={<CloseOutlined />} onClick={() => setShowAddMods(false)} />}
style={{ marginBottom: 8 }}
>
<SearchModsTable modPack={props.modPack} changes={props.changes} onChange={props.onChange} />
</Card>}
<Table
size="small"
rowSelection={{
type: "checkbox",
selectedRowKeys: [...props.modPack.mods.values()].filter(mod => mod.enabled).map(mod => mod.name),
getCheckboxProps(mod) {
return { disabled: deletedMods.has(mod.name) };
},
onChange(selectedRowKeys, selectedRows) {
function setEnabled(modNames: string[], enabled: boolean) {
for (let name of modNames) {
props.onChange({
type: "mods.set",
name,
value: { ...props.modPack.mods.get(name)!, enabled },
});
}
}
const added = selectedRowKeys.filter(
key => !props.modPack.mods.get(key as string)?.enabled
) as string[];
const removed = [...props.modPack.mods.values()].filter(
mod => mod.enabled && selectedRowKeys.indexOf(mod.name) === -1
).map(mod => mod.name);
setEnabled(added, true);
setEnabled(removed, false);
},
}}
columns={[
{
title: "Name",
key: "name",
render: (_, mod: lib.ModRecord) => {
const recommendedBy = mod.advisories
?.filter(advisory => advisory.type === "recommended_dependency")
.map(advisory => advisory.sourceModName) ?? [];
const update = mod.advisories
?.find(advisory => advisory.type === "update_available");
return <>
{mod.error === "missing" && <Tooltip title="Mod is missing from storage.">
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum mismatch.">
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.warning === "incompatible" && <Tooltip title="Mod is incompatible with another.">
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "missing_dependency" && <Tooltip title="Mod is missing a dependency.">
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_version" && <Tooltip
title="Mod has wrong dependency version added."
>
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_factorio_version" && <Tooltip
title="Mod has wrong factorio version."
>
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{recommendedBy.length > 0 && <Tooltip
title={`Recommended by ${recommendedBy.join(", ")}, but currently disabled.`}
>
<LikeOutlined style={{ color: "#0958d9" }} />{" "}
</Tooltip>}
{update?.type === "update_available" && <Tooltip
title={`A newer compatible version (${update.version}) is available.`}
>
<CloudSyncOutlined style={{ color: "#0958d9" }} />{" "}
</Tooltip>}
{mod.info?.title || mod.name}
</>;
},
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
},
{
title: "Version",
dataIndex: "version",
sorter: (a, b) => strcmp(a.version, b.version),
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: mod => actions(mod),
},
]}
expandable={{
expandedRowRender: mod => <ModDetails mod={mod} mods={mods.filter(m => m.enabled)} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={mods}
pagination={false}
rowKey="name"
rowClassName={mod => {
if (deletedMods.has(mod.name)) {
return "deleted";
}
if (changedMods.has(mod.name)) {
return "changed";
}
return "";
}}
/>
</>;
}
// Returns true if a change record with the given fields exist
function hasChange(changes: ModChange[], field:Partial<ModChange>) {
return changes
.some(change => Object.entries(field)
.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++) {
let key = fn(array[index], index, array);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(array[index]);
}
return map;
}
function InputColor(props: {
value: lib.ModSettingColor,
onChange: (value: lib.ModSettingColor) => void,
}) {
// In certain edge cases it's possible a value for this mod setting does not exist
// or is of the wrong type. Fallback to black if this is the case.
function isColor(input: unknown) {
if (typeof input !== "object" || input === null) {
return false;
}
return "r" in input && "g" in input && "b" in input && "a" in input;
}
const value = isColor(props.value) ? props.value : { r: 0, g: 0, b: 0, a: 1 };
return <ColorPicker
defaultFormat="rgb"
defaultValue={`rgba(${value.r * 255}, ${value.g * 255}, ${value.b * 255}, ${value.a}`}
onChangeComplete={(color) => {
const rgb = color.toRgb();
props.onChange({
r: rgb.r / 255,
g: rgb.g / 255,
b: rgb.b / 255,
a: rgb.a,
});
}}
/>;
}
type SettingsTableFieldProps = {
name: string;
type: string;
scope: string;
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;
};
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
const name = props.name;
const isBoolean = props.type === "bool-setting";
const isColor = props.type === "color-setting";
const isNumber = ["int-setting", "double-setting"].includes(props.type);
const isSelect = Boolean(props.allowedValues);
let input;
if (isBoolean) {
input = <Checkbox />;
} else if (isColor) {
const PartialInputColor = InputColor as () => React.JSX.Element;
input = <PartialInputColor />;
} else if (isSelect) {
const options = Object.values(props.allowedValues).map(value => ({
value,
label: props.locale.get(`string-mod-setting.${name}-${value}`) || value,
}));
input = <Select options={options} />;
} else {
input = <Input />;
}
return <Form.Item
name={[props.scope, name]}
initialValue={props.initialValue}
label={props.locale.get(`mod-setting-name.${name}`) || name}
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}
</Form.Item>;
});
type SettingsTableProps = {
modPack: lib.ModPack;
/** Mod pack without unsaved changes, reverting the form goes back to its values */
savedModPack: lib.ModPack;
prototypes: any;
locale: any;
changes: ModChange[];
onChange: (change: ModChange) => void;
onRevert: (change: ModChange) => void;
};
function SettingsTable(props: SettingsTableProps) {
const [mods] = useMods();
const modsInPack = new Set([...props.modPack.mods.values()].map(mod => `${mod.name}_${mod.version}`));
const modTitles = new Map([...mods.values()]
.filter(mod => modsInPack.has(mod.id))
.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 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>);
}
return <>
<SectionHeader
title="Mod Settings"
/>
<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"]))}
<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"]))}
<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"]))}
</>;
}
function CopyButton(props: { content: string }) {
let clipboard = useClipboard();
let [copiedVisible, setCopiedVisible] = useState<boolean|undefined>(false);
let copiedTimeout = useRef<ReturnType<typeof setTimeout>|undefined>();
useEffect(() => () => {
clearTimeout(copiedTimeout.current);
setCopiedVisible(false);
}, []);
let denied = clipboard.readPermissionState === "denied";
return <Tooltip title={denied ? clipboard.deniedReason : "Copied!"} open={denied ? undefined : copiedVisible}>
<Button disabled={denied} onClick={() => {
try {
clipboard.writeText(props.content);
clearTimeout(copiedTimeout.current);
setCopiedVisible(undefined);
copiedTimeout.current = setTimeout(() => {
setCopiedVisible(false);
}, 3000);
} catch (err: any) {
logger.error(`Writing to clipboard failed:\n${err.stack}`);
notify("Unable to copy to clipboard", "error", "Use ordinary select and copy instead.");
}
}}>Copy</Button>
</Tooltip>;
}
function ExportButton(props: { modPack: lib.ModPack }) {
let [open, setOpen] = useState<boolean>(false);
function close() {
setOpen(false);
}
let exportString = "";
if (open) { exportString = props.modPack.toModPackString(); }
return <>
<Button icon={<ExportOutlined />} onClick={() => { setOpen(true); }}>Export to string</Button>
<Modal
title="Mod Pack String"
open={open}
onOk={close}
onCancel={close}
destroyOnHidden
footer={<Space>
<CopyButton content={exportString} />
<Button onClick={close}>Close</Button>
</Space>}
>
<Text code className="code">
{exportString}
</Text>
</Modal>
</>;
}
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 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() : {};
if (!assetFilename) {
setAssetData(empty);
return;
}
let response = await fetch(`${staticRoot}${assetFilename}`);
if (response.ok) {
let data = await response.json();
setAssetData(asset === "locale" ? new Map(data) : data);
} else {
if (response.status === 404) {
logger.warn(`Mod pack asset "${asset}" is missing on the controller`);
} else {
logger.error(
`Error loading mod pack asset "${asset}" (${response.status}): ${await response.text()}`
);
}
setAssetData(empty);
}
}
load().catch(notifyErrorHandler(`Loading mod pack asset "${asset}" failed`));
}, [assetFilename]);
return assetData;
}
function applyModPackChanges(modPack: lib.ModPack, changes: ModChange[]) {
let modifiedModPack = modPack.shallowClone();
for (let change of changes) {
// Only modify fields that change to reduce re-renders
if (
["mods.set", "mods.delete", "factorioVersion"].includes(change.type)
&& modifiedModPack.mods === modPack.mods
) {
modifiedModPack.mods = new Map(modifiedModPack.mods);
}
if (
["settings.set", "settings.delete"].includes(change.type)
&& change.scope
&& modifiedModPack.settings[change.scope] === modPack.settings[change.scope]
) {
if (modifiedModPack.settings === modPack.settings) {
modifiedModPack.settings = { ...modPack.settings };
}
modifiedModPack.settings[change.scope] = new Map(modifiedModPack.settings[change.scope]);
}
if (change.type === "name") {
modifiedModPack.name = change.value;
} else if (change.type === "description") {
modifiedModPack.description = change.value;
} else if (change.type === "mods.set") {
modifiedModPack.mods.set(change.name, change.value as lib.ModRecord);
} else if (change.type === "mods.delete") {
modifiedModPack.mods.delete(change.name);
} else if (change.type === "settings.set") {
modifiedModPack.settings[change.scope].set(change.name, change.value);
} else if (change.type === "settings.delete") {
modifiedModPack.settings[change.scope].delete(change.name);
} else if (change.type === "factorioVersion") {
const newBuiltinMods = lib.ModPack.getBuiltinMods(change.value);
for (const newBuiltinMod of newBuiltinMods) {
const builtinMod = modifiedModPack.mods.get(newBuiltinMod.name);
if (builtinMod) { newBuiltinMod.enabled = builtinMod.enabled; }
modifiedModPack.mods.set(newBuiltinMod.name, newBuiltinMod);
}
const oldBuiltinMods = lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion);
for (const oldBuiltinMod of oldBuiltinMods) {
if (!newBuiltinMods.some(builtinMod => builtinMod.name === oldBuiltinMod.name)) {
modifiedModPack.mods.delete(oldBuiltinMod.name);
}
}
modifiedModPack.factorioVersion = change.value;
} else {
throw new Error(`Unknown change type ${change.type}`);
}
}
return modifiedModPack;
}
export default function ModPackViewPage() {
let account = useAccount();
let navigate = useNavigate();
let control = useContext(ControlContext);
let params = useParams();
let modPackId = Number(params.id);
const [form] = Form.useForm();
const [modPack, synced, error] = useModPack(modPackId);
const state = useResourceState(modPack, synced, error, modPackId);
let prototypes: any = useExportedAsset(modPack, "settings");
let locale: Map<any, any> = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState<ModChange[]>([]);
let syncTimeout = useRef<ReturnType<typeof setTimeout>|undefined>();
const modifiedModPack = modPack ? applyModPackChanges(modPack, changes) : undefined;
useEffect(() => () => { clearTimeout(syncTimeout.current); });
const pushChange = useCallback((change: ModChange) => {
setChanges(oldChanges => {
const newChanges = [...oldChanges];
if (
newChanges.length
&& newChanges.slice(-1)[0].type === change.type
&& newChanges.slice(-1)[0].name === change.name
&& newChanges.slice(-1)[0].scope === change.scope
) {
newChanges.splice(-1, 1, change);
} else {
newChanges.push(change);
}
return newChanges;
});
}, []);
const revertChange = useCallback((change: ModChange) => {
setChanges(oldChanges => {
const newChanges = [...oldChanges];
const index = newChanges.findLastIndex(reference => (
change.type === reference.type && change.name === reference.name
));
if (index === -1) {
logger.error(`Unable to revert ${change.type} ${change.name}: change not found`);
return oldChanges;
}
newChanges.splice(index, 1);
return newChanges;
});
}, []);
function syncPack(fields: FieldData[]) {
for (let field of fields) {
if (!field.touched || field.validating || (field.errors?.length??false)) {
continue;
}
if (["name", "description", "factorioVersion"].includes(field.name[0])) {
const name = field.name[0] as "name" | "description" | "factorioVersion";
if (modifiedModPack![name] !== field.value) {
pushChange({ type: name, value: field.value });
}
} else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) {
let [scope, settingName]: ["startup"|"runtime-global"|"runtime-per-user", string] = field.name;
let value = field.value;
if (typeof modPack!.settings[scope].get(settingName)?.value === "number") {
value = Number(value);
if (Number.isNaN(value)) {
continue;
}
}
if (modifiedModPack!.settings[scope].get(settingName)?.value !== value) {
pushChange({ type: "settings.set", scope, name: settingName, value: { value }});
}
}
}
}
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }];
if (!modifiedModPack || !modPack) {
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
}
return <PageLayout nav={nav}>
<PageHeader
title={modPack.name}
extra={<Space>
<ExportButton modPack={modifiedModPack}/>
{account.hasPermission("core.mod_pack.delete") && <Popconfirm
title="Delete mod pack and all of its settings?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
if (modPack.id !== undefined) {
control.send(
new lib.ModPackDeleteRequest(modPack.id)
).then(() => {
navigate("/mods");
}).catch(notifyErrorHandler("Error deleting mod pack"));
}
}}
>
<Button danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>}
</Space>}
/>
<Form
form={form}
onFieldsChange={(changedFields, allFields) => {
clearTimeout(syncTimeout.current);
if (typeof changedFields[0].value === "boolean") {
syncPack(allFields);
} else {
// Defer sync if this is not a checkbox to make the UI responsive.
syncTimeout.current = setTimeout(syncPack.bind(undefined, allFields), 500);
}
}}
// Mod settings get their value on each field instead, the form only applies initialValues when it is
// created, so settings added by an export while the page is open would show up empty.
initialValues={{
name: modPack.name,
description: modPack.description,
factorioVersion: modPack.factorioVersion,
}}
>
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
<Descriptions.Item span={3} label="Name">
<Form.Item noStyle name="name">
<Input
className={hasChange(changes, { type: "name" }) ? "changed" : ""}
/>
</Form.Item>
</Descriptions.Item>
<Descriptions.Item span={3} label="Description">
<Form.Item noStyle name="description">
<Input.TextArea
autoSize={{ minRows: 2 }}
className={hasChange(changes, { type: "description" }) ? "changed" : ""}
/>
</Form.Item>
</Descriptions.Item>
<Descriptions.Item label="Mods">
{modifiedModPack.mods.size}
</Descriptions.Item>
<Descriptions.Item label="Factorio Version">
<Form.Item
name="factorioVersion"
style={{ marginBottom: 0 }}
rules={[{
required: true,
pattern: /^\d+\.\d+(\.\d+)?$/,
message: "Must be an a.b or a.b.c version number.",
}]}
>
<InputPartialVersion
value={form.getFieldValue("factorioVersion")}
onChange={(value: any) => form.setFieldValue("factorioVersion", value)}
className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""}
/>
</Form.Item>
</Descriptions.Item>
</Descriptions>
<ModsTable
modPack={modifiedModPack}
changes={changes}
onChange={pushChange}
onRevert={revertChange}
builtInModNames={lib.ModPack.getBuiltinModNames(modifiedModPack.factorioVersion)}
/>
<SettingsTable
modPack={modifiedModPack}
savedModPack={modPack}
changes={changes}
onChange={pushChange}
onRevert={revertChange}
prototypes={prototypes}
locale={locale}
/>
<Typography.Title level={4}>
Inspect exported data
</Typography.Title>
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
Icon reference
</Link>
</Form>
<div
className="sticky-notice"
style={{
visibility: changes.length ? "visible" : "hidden",
}}
>
<Row style={{ alignItems: "center", rowGap: 12 }}>
<Col flex="auto">You have unsaved changes</Col>
<Col flex="0 0 auto" style={{ marginLeft: "auto" }}>
<Space>
<Button
onClick={() => {
form.resetFields();
setChanges([]);
}}
>Revert</Button>
<Button
type="primary"
onClick={() => {
control.send(
new lib.ModPackUpdateRequest(modifiedModPack)
).then(() => {
form.resetFields();
setChanges([]);
}).catch(notifyErrorHandler("Error deleting mod pack"));
}}
>Apply</Button>
</Space>
</Col>
</Row>
</div>
<ResourceNotes resourceType="mod_pack" resourceId={modPackId} />
</PageLayout>;
}