Improve UI dropdown

This commit is contained in:
Carlos Monastyrski
2025-09-15 14:42:14 -03:00
parent ecb9466617
commit 5fc88610e4
@@ -1,18 +1,31 @@
/* eslint-disable no-nested-ternary */ /* eslint-disable no-nested-ternary */
import { useEffect, useMemo, useRef, useState } from "react"; import { useMemo, useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { faExternalLink, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { SingleValue } from "react-select";
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FilterableSelect, FormControl } from "@app/components/v2";
import { import {
useCalculateUpgradePath, useCalculateUpgradePath,
useGetUpgradePathVersions useGetUpgradePathVersions
} from "../../../hooks/api/upgradePath/queries"; } from "../../../hooks/api/upgradePath/queries";
type VersionOption = {
label: string;
value: string;
isLatest: boolean;
};
const formatVersionOption = (option: VersionOption) => (
<div className="flex items-center justify-between">
<span>{option.label}</span>
{option.isLatest && <span className="text-xs text-primary">(Latest)</span>}
</div>
);
interface UpgradeResult { interface UpgradeResult {
path: Array<{ path: Array<{
version: string; version: string;
@@ -39,66 +52,9 @@ interface UpgradeResult {
} }
export const UpgradePathPage = () => { export const UpgradePathPage = () => {
const [fromVersion, setFromVersion] = useState(""); const [fromVersion, setFromVersion] = useState<string | null>(null);
const [toVersion, setToVersion] = useState(""); const [toVersion, setToVersion] = useState<string | null>(null);
const [fromSearch, setFromSearch] = useState("");
const [toSearch, setToSearch] = useState("");
const [showFromDropdown, setShowFromDropdown] = useState(false);
const [showToDropdown, setShowToDropdown] = useState(false);
const [upgradeResult, setUpgradeResult] = useState<UpgradeResult | null>(null); const [upgradeResult, setUpgradeResult] = useState<UpgradeResult | null>(null);
const [debouncedFromSearch, setDebouncedFromSearch] = useState("");
const [debouncedToSearch, setDebouncedToSearch] = useState("");
const fromDropdownRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const fromSearchTimeoutRef = useRef<NodeJS.Timeout>();
const toSearchTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (fromSearchTimeoutRef.current) {
clearTimeout(fromSearchTimeoutRef.current);
}
fromSearchTimeoutRef.current = setTimeout(() => {
setDebouncedFromSearch(fromSearch);
}, 300);
return () => {
if (fromSearchTimeoutRef.current) {
clearTimeout(fromSearchTimeoutRef.current);
}
};
}, [fromSearch]);
useEffect(() => {
if (toSearchTimeoutRef.current) {
clearTimeout(toSearchTimeoutRef.current);
}
toSearchTimeoutRef.current = setTimeout(() => {
setDebouncedToSearch(toSearch);
}, 300);
return () => {
if (toSearchTimeoutRef.current) {
clearTimeout(toSearchTimeoutRef.current);
}
};
}, [toSearch]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (fromDropdownRef.current && !fromDropdownRef.current.contains(event.target as Node)) {
setShowFromDropdown(false);
}
if (toDropdownRef.current && !toDropdownRef.current.contains(event.target as Node)) {
setShowToDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const { const {
data: versions, data: versions,
@@ -128,44 +84,26 @@ export const UpgradePathPage = () => {
} }
}); });
const filteredFromVersions = useMemo(() => { const versionOptions = useMemo(() => {
if (!versions?.versions) return []; if (!versions?.versions) return [];
const filtered = versions.versions return versions.versions
.filter((version) => !version.tagName.includes("nightly")) .filter((version) => !version.tagName.includes("nightly"))
.filter((version) => { .map((version) => ({
if (!debouncedFromSearch) return true; label: version.tagName,
const searchTerm = debouncedFromSearch.toLowerCase(); value: version.tagName,
return version.tagName.toLowerCase().includes(searchTerm); isLatest: versions.versions[0]?.tagName === version.tagName
}); }));
}, [versions?.versions]);
return filtered.slice(0, 25); const handleFromVersionSelect = (value: unknown) => {
}, [versions?.versions, debouncedFromSearch]); const selected = value as SingleValue<VersionOption>;
setFromVersion(selected?.value || null);
const filteredToVersions = useMemo(() => {
if (!versions?.versions) return [];
const filtered = versions.versions
.filter((version) => !version.tagName.includes("nightly"))
.filter((version) => {
if (!debouncedToSearch) return true;
const searchTerm = debouncedToSearch.toLowerCase();
return version.tagName.toLowerCase().includes(searchTerm);
});
return filtered.slice(0, 25);
}, [versions?.versions, debouncedToSearch]);
const handleFromVersionSelect = (version: string) => {
setFromVersion(version);
setFromSearch(version);
setShowFromDropdown(false);
}; };
const handleToVersionSelect = (version: string) => { const handleToVersionSelect = (value: unknown) => {
setToVersion(version); const selected = value as SingleValue<VersionOption>;
setToSearch(version); setToVersion(selected?.value || null);
setShowToDropdown(false);
}; };
const handleCalculate = () => { const handleCalculate = () => {
@@ -232,121 +170,36 @@ export const UpgradePathPage = () => {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* From Version Selector */} {/* From Version Selector */}
<div className="relative" ref={fromDropdownRef}> <FormControl label="From Version" isRequired>
<FormControl label="From Version" isRequired> <FilterableSelect
<div className="relative"> options={versionOptions}
<Input value={versionOptions.find((opt) => opt.value === fromVersion) || null}
value={fromSearch} onChange={handleFromVersionSelect}
onChange={(e) => { placeholder="Search or select version..."
setFromSearch(e.target.value); isLoading={versionsLoading || versionsFetching}
setFromVersion(""); isDisabled={versionsLoading || versionsFetching}
setShowFromDropdown(true); isSearchable
}} isClearable
onFocus={() => setShowFromDropdown(true)} menuPortalTarget={document.body}
placeholder="Search or select version..." formatOptionLabel={formatVersionOption}
className="border-mineshaft-600 bg-mineshaft-900" />
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} </FormControl>
isDisabled={versionsLoading || versionsFetching}
/>
{showFromDropdown && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 shadow-lg">
{(() => {
if (versionsLoading || versionsFetching) {
return (
<div className="flex items-center justify-center px-3 py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-bunker-300">
Loading versions...
</span>
</div>
);
}
if (filteredFromVersions.length > 0) {
return filteredFromVersions.slice(0, 8).map((version) => (
<button
key={version.tagName}
type="button"
onClick={() => handleFromVersionSelect(version.tagName)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-bunker-200 hover:bg-mineshaft-700 focus:bg-mineshaft-700 focus:outline-none"
>
<span>{version.tagName}</span>
</button>
));
}
return (
<div className="px-3 py-2 text-sm text-bunker-400">
{debouncedFromSearch
? `No versions found matching "${debouncedFromSearch}"`
: "No versions available"}
</div>
);
})()}
</div>
)}
</div>
</FormControl>
</div>
{/* To Version Selector */} {/* To Version Selector */}
<div className="relative" ref={toDropdownRef}> <FormControl label="To Version" isRequired>
<FormControl label="To Version" isRequired> <FilterableSelect
<div className="relative"> options={versionOptions}
<Input value={versionOptions.find((opt) => opt.value === toVersion) || null}
value={toSearch} onChange={handleToVersionSelect}
onChange={(e) => { placeholder="Search or select version..."
setToSearch(e.target.value); isLoading={versionsLoading || versionsFetching}
setToVersion(""); isDisabled={versionsLoading || versionsFetching}
setShowToDropdown(true); isSearchable
}} isClearable
onFocus={() => setShowToDropdown(true)} menuPortalTarget={document.body}
placeholder="Search or select version..." formatOptionLabel={formatVersionOption}
className="border-mineshaft-600 bg-mineshaft-900" />
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} </FormControl>
isDisabled={versionsLoading || versionsFetching}
/>
{showToDropdown && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 shadow-lg">
{(() => {
if (versionsLoading || versionsFetching) {
return (
<div className="flex items-center justify-center px-3 py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-bunker-300">
Loading versions...
</span>
</div>
);
}
if (filteredToVersions.length > 0) {
return filteredToVersions.slice(0, 8).map((version) => (
<button
key={version.tagName}
type="button"
onClick={() => handleToVersionSelect(version.tagName)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-bunker-200 hover:bg-mineshaft-700 focus:bg-mineshaft-700 focus:outline-none"
>
<span>{version.tagName}</span>
<div className="flex items-center space-x-2">
{versions?.versions?.[0]?.tagName === version.tagName && (
<span className="text-xs text-primary">(Latest)</span>
)}
</div>
</button>
));
}
return (
<div className="px-3 py-2 text-sm text-bunker-400">
{debouncedToSearch
? `No versions found matching "${debouncedToSearch}"`
: "No versions available"}
</div>
);
})()}
</div>
)}
</div>
</FormControl>
</div>
</div> </div>
<Button <Button