diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index e1b6b1c3..b4954413 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -9,8 +9,7 @@ import App from "./components/App"; import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; -import InputTargetVersion from "./components/InputTargetVersion"; -import InputPartialVersion from "./components/InputPartialVersion"; +import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -111,6 +110,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { + "full_version": InputFullVersion, "partial_version": InputPartialVersion, "target_version": InputTargetVersion, "mod_pack": InputModPack, diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx deleted file mode 100644 index 250b5de6..00000000 --- a/packages/web_ui/src/components/InputPartialVersion.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; -import { TreeSelect } from "antd"; - -import * as lib from "@clusterio/lib"; -import { InputComponentProps } from "../BaseWebPlugin"; -import { useAccount } from "../model/account"; -import ControlContext from "./ControlContext"; - -const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; - -export default function InputPartialVersion ( - props: Omit & { - fieldDefinition?: lib.FieldDefinition; - className?: string; - } -) { - const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; - const [versions, setVersions] = useState(lib.ApiVersions); - const control = useContext(ControlContext); - const account = useAccount(); - - useEffect(() => { - (async () => { - if (account.hasPermission("core.external.get_factorio_versions")) { - const res = await control.factorioVersions.get(5 * 60 * 1000); - setVersions(res.map(v => v.version)); - } - })(); - }, [control]); - - // Split the versions into groups based on major minor - const groups = new Map(); - - for (const version of versions) { - const [major, minor] = version.split("."); - const key = `${major}.${minor}` as lib.PartialVersion; - - if (!groups.has(key)) { - groups.set(key, []); - } - groups.get(key)!.push(version); - } - - // Construct the tree to be disabled - const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( - { - title: majorMinor, - value: majorMinor, - key: majorMinor, - children: patchVersions.map((v) => ({ - title: v, - value: v, - key: v, - })), - } - )); - - return props.onChange(value ?? null)} - value={props.value} - treeData={tree} - allowClear={fieldDefinition.optional} - className={props.className} - disabled={props.disabled} - />; -} diff --git a/packages/web_ui/src/components/InputTargetVersion.tsx b/packages/web_ui/src/components/InputTargetVersion.tsx deleted file mode 100644 index 3e52d33d..00000000 --- a/packages/web_ui/src/components/InputTargetVersion.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; -import { TreeSelect } from "antd"; - -import * as lib from "@clusterio/lib"; -import { InputComponentProps } from "../BaseWebPlugin"; -import { useAccount } from "../model/account"; -import ControlContext from "./ControlContext"; - -const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; - -export default function InputTargetVersion ( - props: Omit & { - fieldDefinition?: lib.FieldDefinition; - className?: string; - } -) { - const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; - const [versions, setVersions] = useState(lib.ApiVersions); - const control = useContext(ControlContext); - const account = useAccount(); - - useEffect(() => { - (async () => { - if (account.hasPermission("core.external.get_factorio_versions")) { - const res = await control.factorioVersions.get(5 * 60 * 1000); - setVersions(res.map(v => v.version)); - } - })(); - }, [control]); - - // Split the versions into groups based on major minor - const groups = new Map(); - - for (const version of versions) { - const [major, minor] = version.split("."); - const key = `${major}.${minor}` as lib.PartialVersion; - - if (!groups.has(key)) { - groups.set(key, []); - } - groups.get(key)!.push(version); - } - - // Construct the tree to be disabled - const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( - { - title: majorMinor, - value: majorMinor, - key: majorMinor, - children: patchVersions.map((v) => ({ - title: v, - value: v, - key: v, - })), - } - )); - - // Add "latest" option for TargetVersion - tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] }); - - return props.onChange(value ?? null)} - value={props.value} - treeData={tree} - allowClear={fieldDefinition.optional} - className={props.className} - disabled={props.disabled} - />; -} diff --git a/packages/web_ui/src/components/InputVersion.tsx b/packages/web_ui/src/components/InputVersion.tsx new file mode 100644 index 00000000..bcb7beeb --- /dev/null +++ b/packages/web_ui/src/components/InputVersion.tsx @@ -0,0 +1,179 @@ +import React, { useContext, useEffect, useState, useRef } from "react"; +import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd"; + +import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; + +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; + +function InputVersion< + Version extends lib.TargetVersion, + IncludeLatest extends (Version extends "latest" ? boolean : false), +> ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + version: { + isValid: (v: string) => v is Version; + includeLatest: IncludeLatest; + }; + } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const [customVersion, setCustomVersion] = useState(""); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const selectRef = useRef(null); + const control = useContext(ControlContext); + const account = useAccount(); + + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); + + // Split the versions into groups based on major minor + const groups = new Map(); + + for (const version of versions) { + const [major, minor] = version.split("."); + const key = `${major}.${minor}`; + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(version); + } + + // Construct the tree to be disabled + const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( + { + title: majorMinor, + value: majorMinor, + key: majorMinor, + children: patchVersions.map((v) => ({ + title: v, + value: v, + key: v, + })), + } + )); + + // Add "latest" option for TargetVersion + if (props.version.includeLatest) { + tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] }); + } + + return props.onChange(value ?? null)} + value={props.value} + treeData={tree} + allowClear={fieldDefinition.optional} + className={props.className} + disabled={props.disabled} + open={open} + onOpenChange={newOpen => { + setOpen(newOpen); + if (newOpen && typeof props.value === "string") { + setCustomVersion(props.value); + } + }} + onKeyDown={e => { + if (open && e.key === "Tab") { + e.preventDefault(); + inputRef.current?.focus(); + } + }} + popupRender={(menu) => { + const isValid = props.version.isValid(customVersion); + + const submit = () => { + if (customVersion && isValid) { + setTimeout(() => selectRef.current?.focus(), 0); + props.onChange(customVersion); + setOpen(false); + } + }; + + return ( + <> + {menu} + +
+ setCustomVersion(e.target.value)} + onPressEnter={submit} + /> + +
+ + ); + }} + />; +} + +export function InputTargetVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + return ; +} + +export function InputPartialVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + return ; +} + +export function InputFullVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + return ; +} diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index caea5202..d2640d18 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -26,7 +26,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; -import InputPartialVersion from "./InputPartialVersion"; +import { InputPartialVersion } from "./InputVersion"; import Link from "./Link"; const { logger } = lib; diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 0c526b9d..a6ece310 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -21,7 +21,7 @@ import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; import { Dropzone } from "./Dropzone"; import UploadButton from "./UploadButton"; -import InputPartialVersion from "./InputPartialVersion"; +import { InputPartialVersion } from "./InputVersion"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;