mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 23:54:52 +09:00
- The instance sends every value of a feature, defaults included, so update_config is one loop which sets each field and compares. The stored table is the last set of values, and _apply is gone. - values_equal compares table sizes before the contents. - Protected entities are registered with register_on_object_destroyed and forgotten in on_object_destroyed, which covers every way an entity can go. The four removal handlers and the validity checks are gone. Entities destroyed while the feature is disabled are swept when it is enabled again. Saves from before the registrations table get it through the Storage on_init callback. - Spawn area applied the water offset twice and the pattern offset never. The pattern tiles now move with the entities above them. - pruneFeatureValues reads a validation result instead of catching an error, and FeaturesPage hands its pending state and unsaved bar to helpers. - Shorter comments throughout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import React, { useContext, useState } from "react";
|
|
import { Button, Checkbox, Col, ConfigProvider, Divider, Input, InputNumber, Row, Select, Space, Spin, Switch } from "antd";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui";
|
|
|
|
import { Feature, FeatureField, FeatureValue, features, isDefaultValue, sameValue } from "../../features.js";
|
|
import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js";
|
|
import { useFeatures } from "../index";
|
|
|
|
type FeatureState = { enabled: boolean, values: Record<string, FeatureValue> };
|
|
type PendingChanges = Record<string, { enabled?: boolean, values: Record<string, FeatureValue> }>;
|
|
|
|
// Same colours as the role and permission group pages
|
|
const borderColor = "#424242";
|
|
const modifiedColor = "#2a1912";
|
|
|
|
/** The stored state of a feature, with defaults for values which were not set. */
|
|
function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState {
|
|
return {
|
|
enabled: record?.enabled ?? true,
|
|
values: {
|
|
...Object.fromEntries(feature.fields.map(field => [field.name, field.default])),
|
|
...record?.values,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */
|
|
function groupFields(fields: FeatureField[]) {
|
|
const groups = new Map<string, FeatureField[]>();
|
|
for (const field of fields) {
|
|
const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : "";
|
|
if (!groups.has(section)) {
|
|
groups.set(section, []);
|
|
}
|
|
groups.get(section)!.push(field);
|
|
}
|
|
return [...groups.entries()];
|
|
}
|
|
|
|
/** "afk_belts" to "Afk Belts", like the group titles on the role page */
|
|
function formatSectionTitle(name: string) {
|
|
return name
|
|
.replace(/\./g, " / ")
|
|
.replace(/_/g, " ")
|
|
.split(" ")
|
|
.filter(Boolean)
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
.join(" ");
|
|
}
|
|
|
|
function matches(text: string, query: string) {
|
|
return text.toLowerCase().includes(query);
|
|
}
|
|
|
|
/** Fields matching the search, every field when the feature itself matches */
|
|
function searchFields(feature: Feature, query: string) {
|
|
if (!query || matches(feature.title, query) || matches(feature.description, query) || matches(feature.name, query)) {
|
|
return feature.fields;
|
|
}
|
|
return feature.fields.filter(field => (
|
|
matches(field.title, query) || matches(field.description, query) || matches(field.name, query)
|
|
));
|
|
}
|
|
|
|
/** Renders a setting like the config pages do, registered input components take priority */
|
|
function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) {
|
|
const { field, value, disabled, onChange } = props;
|
|
const control = useContext(ControlContext);
|
|
if (field.type !== "string_list" && field.inputComponent) {
|
|
const CustomInput = control.inputComponents.get(field.inputComponent);
|
|
if (CustomInput) {
|
|
const fieldDefinition: lib.FieldDefinition = {
|
|
type: field.type,
|
|
title: field.title,
|
|
description: field.description,
|
|
optional: field.optional,
|
|
inputComponent: field.inputComponent,
|
|
};
|
|
return <CustomInput
|
|
fieldDefinition={fieldDefinition}
|
|
value={value as Exclude<FeatureValue, string[]>}
|
|
onChange={onChange}
|
|
disabled={disabled}
|
|
/>;
|
|
}
|
|
}
|
|
|
|
switch (field.type) {
|
|
case "number":
|
|
return <InputNumber
|
|
value={value as number}
|
|
min={field.min}
|
|
addonAfter={field.unit}
|
|
disabled={disabled}
|
|
onChange={next => onChange(next ?? (field.optional ? null : field.default))}
|
|
/>;
|
|
case "string":
|
|
if (field.enum) {
|
|
return <Select
|
|
showSearch
|
|
style={{ minWidth: 175 }}
|
|
value={value as string}
|
|
options={field.enum.map(option => ({ label: option, value: option }))}
|
|
allowClear={field.optional}
|
|
disabled={disabled}
|
|
onChange={next => onChange(next ?? null)}
|
|
/>;
|
|
}
|
|
return <Input value={value as string} disabled={disabled} onChange={e => onChange(e.target.value)} style={{ width: 300 }} />;
|
|
case "string_list":
|
|
return <Select
|
|
mode="tags"
|
|
value={value as string[]}
|
|
disabled={disabled}
|
|
onChange={next => onChange(next)}
|
|
open={false}
|
|
style={{ width: "100%", maxWidth: 600 }}
|
|
/>;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** A setting, laid out like a permission on the role page */
|
|
function FieldItem(props: {
|
|
field: FeatureField, value: FeatureValue, stored: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void,
|
|
}) {
|
|
const { field, value, stored, disabled, onChange } = props;
|
|
const modified = !sameValue(value, stored);
|
|
return <div style={{
|
|
marginBottom: 8,
|
|
padding: "4px 6px",
|
|
background: modified ? modifiedColor : undefined,
|
|
borderRadius: 4,
|
|
}}>
|
|
{field.type === "boolean"
|
|
? <Checkbox checked={value as boolean} disabled={disabled} onChange={e => onChange(e.target.checked)}>
|
|
{field.title}
|
|
</Checkbox>
|
|
: <Space wrap>
|
|
<span>{field.title}</span>
|
|
<FieldInput field={field} value={value} disabled={disabled} onChange={onChange} />
|
|
</Space>}
|
|
<div style={{ marginLeft: field.type === "boolean" ? 24 : 0, color: "#888", fontSize: 12 }}>
|
|
{field.description}
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
function FeatureSection(props: {
|
|
feature: Feature,
|
|
fields: FeatureField[],
|
|
state: FeatureState,
|
|
stored: FeatureState,
|
|
canEdit: boolean,
|
|
onEnabled: (enabled: boolean) => void,
|
|
onValue: (name: string, value: FeatureValue) => void,
|
|
onReset: () => void,
|
|
}) {
|
|
const { feature, fields, state, stored, canEdit } = props;
|
|
const atDefaults = feature.fields.every(field => isDefaultValue(field, state.values[field.name]));
|
|
return <div id={`feature-${feature.name}`} style={{ border: `1px solid ${borderColor}`, padding: 12, borderRadius: 6, scrollMarginTop: 80 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<div style={{
|
|
padding: "4px 6px",
|
|
borderRadius: 4,
|
|
background: state.enabled !== stored.enabled ? modifiedColor : undefined,
|
|
}}>
|
|
<Switch size="default" checked={state.enabled} disabled={!canEdit} onChange={props.onEnabled} />
|
|
</div>
|
|
<div style={{ flexGrow: 1 }}>
|
|
<strong>{feature.title}</strong>
|
|
<div style={{ color: "#888", fontSize: 12 }}>{feature.description}</div>
|
|
</div>
|
|
{canEdit && feature.fields.length > 0 && <Button size="small" disabled={atDefaults} onClick={props.onReset}>
|
|
Reset to defaults
|
|
</Button>}
|
|
</div>
|
|
{fields.length > 0 && <>
|
|
<Divider style={{ margin: "12px 0" }} />
|
|
<div style={{ opacity: state.enabled ? 1 : 0.6 }}>
|
|
{groupFields(fields).map(([section, sectionFields]) => <div key={section}>
|
|
{section && <div style={{ fontWeight: 600, margin: "8px 6px 4px" }}>{formatSectionTitle(section)}</div>}
|
|
{sectionFields.map(field => <FieldItem
|
|
key={field.name}
|
|
field={field}
|
|
value={state.values[field.name]}
|
|
stored={stored.values[field.name]}
|
|
disabled={!canEdit}
|
|
onChange={value => props.onValue(field.name, value)}
|
|
/>)}
|
|
</div>)}
|
|
</div>
|
|
</>}
|
|
</div>;
|
|
}
|
|
|
|
function FeatureNav(props: { feature: Feature, enabled: boolean }) {
|
|
return <div
|
|
role="button"
|
|
onClick={() => document.getElementById(`feature-${props.feature.name}`)?.scrollIntoView({ behavior: "smooth" })}
|
|
style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 8px", cursor: "pointer", borderRadius: 4 }}
|
|
>
|
|
<span style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
flexShrink: 0,
|
|
background: props.enabled ? "#52c41a" : "#595959",
|
|
}} />
|
|
{props.feature.title}
|
|
</div>;
|
|
}
|
|
|
|
/** The pending changes with one more, dropped again when it matches what is stored */
|
|
function withChange(pending: PendingChanges, stored: FeatureState, name: string, update: (entry: PendingChanges[string]) => void) {
|
|
const entry = { enabled: pending[name]?.enabled, values: { ...pending[name]?.values } };
|
|
update(entry);
|
|
if (entry.enabled === stored.enabled) {
|
|
delete entry.enabled;
|
|
}
|
|
for (const [key, value] of Object.entries(entry.values)) {
|
|
if (sameValue(value, stored.values[key])) {
|
|
delete entry.values[key];
|
|
}
|
|
}
|
|
const next = { ...pending };
|
|
if (entry.enabled === undefined && !Object.keys(entry.values).length) {
|
|
delete next[name];
|
|
} else {
|
|
next[name] = entry;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function UnsavedChangesBar(props: { onRevert: () => void, onApply: () => void }) {
|
|
return <div style={{
|
|
position: "fixed",
|
|
bottom: 24,
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
background: modifiedColor,
|
|
borderRadius: 8,
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
|
|
padding: "10px 14px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
zIndex: 1000,
|
|
}}>
|
|
<span style={{ color: "#FFF" }}>You have unsaved changes</span>
|
|
<Space>
|
|
<Button onClick={props.onRevert}>Revert</Button>
|
|
<Button type="primary" onClick={props.onApply}>Apply</Button>
|
|
</Space>
|
|
</div>;
|
|
}
|
|
|
|
/** Enable, disable, and configure the features listed in features.ts */
|
|
export default function FeaturesPage() {
|
|
const control = useContext(ControlContext);
|
|
const account = useAccount();
|
|
const [records, synced] = useFeatures();
|
|
const [pending, setPending] = useState<PendingChanges>({});
|
|
const [search, setSearch] = useState("");
|
|
const canEdit = Boolean(account.hasPermission("exp_scenario.config.edit"));
|
|
|
|
// Before the first update every feature would look enabled with its defaults
|
|
if (!synced) {
|
|
return <PageLayout nav={[{ name: "Scenario Features" }]}>
|
|
<PageHeader title="Scenario Features" />
|
|
<Spin size="large" />
|
|
</PageLayout>;
|
|
}
|
|
|
|
const stored = Object.fromEntries(features.map(feature => [feature.name, storedState(feature, records.get(feature.name))]));
|
|
const current = (name: string): FeatureState => ({
|
|
enabled: pending[name]?.enabled ?? stored[name].enabled,
|
|
values: { ...stored[name].values, ...pending[name]?.values },
|
|
});
|
|
|
|
function change(name: string, update: (entry: PendingChanges[string]) => void) {
|
|
setPending(prev => withChange(prev, stored[name], name, update));
|
|
}
|
|
|
|
function applyChanges() {
|
|
for (const name of Object.keys(pending)) {
|
|
const state = current(name);
|
|
control.send(new FeatureUpdateRequest(name, state.enabled, state.values))
|
|
.then(() => setPending(prev => {
|
|
const next = { ...prev };
|
|
delete next[name];
|
|
return next;
|
|
}))
|
|
.catch(notifyErrorHandler(`Error saving ${features.find(f => f.name === name)?.title ?? name}`));
|
|
}
|
|
}
|
|
|
|
const query = search.trim().toLowerCase();
|
|
const shown = features
|
|
.map(feature => ({ feature, fields: searchFields(feature, query) }))
|
|
.filter(({ feature, fields }) => !query || fields.length || matches(feature.title, query));
|
|
|
|
return <PageLayout nav={[{ name: "Scenario Features" }]}>
|
|
<PageHeader title="Scenario Features" />
|
|
|
|
{Object.keys(pending).length > 0 && <UnsavedChangesBar onRevert={() => setPending({})} onApply={applyChanges} />}
|
|
|
|
<Input.Search placeholder="Search features and settings" allowClear
|
|
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
|
|
|
|
<Row gutter={16} wrap={false}>
|
|
<Col flex="200px">
|
|
{/* Clear of the fixed page header */}
|
|
<div style={{ position: "sticky", top: 80 }}>
|
|
{shown.map(({ feature }) => <FeatureNav
|
|
key={feature.name}
|
|
feature={feature}
|
|
enabled={current(feature.name).enabled}
|
|
/>)}
|
|
</div>
|
|
</Col>
|
|
<Col flex="auto" style={{ minWidth: 0 }}>
|
|
{/* Same input size as the config pages */}
|
|
<ConfigProvider componentSize="small">
|
|
<Space direction="vertical" style={{ width: "100%" }}>
|
|
{shown.map(({ feature, fields }) => <FeatureSection
|
|
key={feature.name}
|
|
feature={feature}
|
|
fields={fields}
|
|
state={current(feature.name)}
|
|
stored={stored[feature.name]}
|
|
canEdit={canEdit}
|
|
onEnabled={enabled => change(feature.name, entry => { entry.enabled = enabled; })}
|
|
onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })}
|
|
onReset={() => change(feature.name, entry => {
|
|
for (const field of feature.fields) {
|
|
entry.values[field.name] = field.default;
|
|
}
|
|
})}
|
|
/>)}
|
|
</Space>
|
|
</ConfigProvider>
|
|
</Col>
|
|
</Row>
|
|
</PageLayout>;
|
|
}
|