Files
bbassieandClaude Fable 5.1 2959d7476b Send every value, register protected entities, fix the pattern offset
- 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>
2026-10-04 12:55:08 +00:00

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>;
}