Chore: full react hooks eslint compliance (#7040)

This commit is contained in:
shamoon
2026-08-21 14:00:02 -07:00
committed by GitHub
parent 7c26cf6d97
commit 4c361422ca
111 changed files with 1072 additions and 867 deletions
+116 -98
View File
@@ -15,6 +15,59 @@ const MOBILE_BUTTON_POSITIONS = {
"bottom-right": "bottom-4 right-4",
};
function getSearchResults({
hideVisitURL,
searchDescriptions,
searchProvider,
searchString,
searchSuggestions,
servicesAndBookmarks,
t,
url,
}) {
if (searchString.trim().length === 0) return [];
const results = servicesAndBookmarks.flatMap((result) => {
const nameMatch = result.name.toLowerCase().includes(searchString);
const descriptionMatch = searchDescriptions && result.description?.toLowerCase().includes(searchString);
if (!nameMatch && !descriptionMatch) return [];
return [{ ...result, ...(searchDescriptions && { priority: nameMatch ? 2 : +descriptionMatch }) }];
});
if (searchDescriptions) {
results.sort((a, b) => b.priority - a.priority);
}
if (searchProvider) {
results.push({
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
type: "search",
});
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl && searchSuggestions[1]) {
results.push(
...searchSuggestions[1].map((suggestion) => ({
href: searchProvider.url + encodeURIComponent(suggestion),
name: suggestion,
type: "searchSuggestion",
})),
);
}
}
if (!hideVisitURL && url) {
results.unshift({
href: url.toString(),
name: `${t("quicklaunch.visit")} URL`,
type: "url",
});
}
return results;
}
export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) {
const { t } = useTranslation();
@@ -23,7 +76,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
const searchField = useRef();
const [results, setResults] = useState([]);
const [currentItemIndex, setCurrentItemIndex] = useState(null);
const [url, setUrl] = useState(null);
const [searchSuggestions, setSearchSuggestions] = useState([]);
@@ -62,7 +114,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
: null;
function openCurrentItem(newWindow) {
const result = results[currentItemIndex];
const result = results[activeItemIndex];
window.open(
result.href,
newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"),
@@ -81,6 +133,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
function handleSearchChange(event) {
const rawSearchString = event.target.value;
setCurrentItemIndex(null);
try {
if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url
let urlString = rawSearchString;
@@ -103,18 +156,19 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
} else if (event.key === "Enter" && results.length) {
closeAndReset();
openCurrentItem(event.metaKey);
} else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) {
setCurrentItemIndex(currentItemIndex + 1);
} else if (event.key === "ArrowDown" && results[activeItemIndex + 1]) {
setCurrentItemIndex(activeItemIndex + 1);
event.preventDefault();
} else if (event.key === "ArrowUp" && currentItemIndex > 0) {
setCurrentItemIndex(currentItemIndex - 1);
} else if (event.key === "ArrowUp" && activeItemIndex > 0) {
setCurrentItemIndex(activeItemIndex - 1);
event.preventDefault();
} else if (
event.key === "ArrowRight" &&
results[currentItemIndex] &&
results[currentItemIndex].type === "searchSuggestion"
results[activeItemIndex] &&
results[activeItemIndex].type === "searchSuggestion"
) {
setSearchString(results[currentItemIndex].name);
setCurrentItemIndex(null);
setSearchString(results[activeItemIndex].name);
}
}
@@ -138,88 +192,51 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
}
useEffect(() => {
const abortController = new AbortController();
if (searchString.trim().length === 0) setResults([]);
else {
let newResults = servicesAndBookmarks.filter((r) => {
const nameMatch = r.name.toLowerCase().includes(searchString);
let descriptionMatch;
if (searchDescriptions) {
descriptionMatch = r.description?.toLowerCase().includes(searchString);
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch;
}
return nameMatch || descriptionMatch;
});
if (searchDescriptions) {
newResults = newResults.sort((a, b) => b.priority - a.priority);
}
if (searchProvider) {
newResults.push({
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
type: "search",
});
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl) {
if (searchString.trim() !== searchSuggestions[0]?.trim()) {
fetch(
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
searchProvider.name ?? "Custom"
}`,
{ signal: abortController.signal },
)
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
if (newSearchSuggestions[1].length > 4) {
newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
}
setSearchSuggestions(newSearchSuggestions);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
}
if (searchSuggestions[1]) {
newResults = newResults.concat(
searchSuggestions[1].map((suggestion) => ({
href: searchProvider.url + encodeURIComponent(suggestion),
name: suggestion,
type: "searchSuggestion",
})),
);
}
}
}
if (!hideVisitURL && url) {
newResults.unshift({
href: url.toString(),
name: `${t("quicklaunch.visit")} URL`,
type: "url",
});
}
setResults(newResults);
if (newResults.length) {
setCurrentItemIndex(0);
}
if (
searchString.trim().length === 0 ||
!searchProvider?.showSearchSuggestions ||
!searchProvider.suggestionUrl ||
searchString.trim() === searchSuggestions[0]?.trim()
) {
return undefined;
}
return () => {
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchSuggestions, searchProvider, url]);
const abortController = new AbortController();
fetch(
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
searchProvider.name ?? "Custom"
}`,
{ signal: abortController.signal },
)
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
setCurrentItemIndex(null);
setSearchSuggestions([newSearchSuggestions[0], newSearchSuggestions[1].slice(0, 4)]);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
return () => abortController.abort();
}, [searchProvider, searchString, searchSuggestions]);
const results = getSearchResults({
hideVisitURL,
searchDescriptions,
searchProvider,
searchString,
searchSuggestions,
servicesAndBookmarks,
t,
url,
});
const activeItemIndex = currentItemIndex ?? (results.length ? 0 : null);
const [hidden, setHidden] = useState(true);
useEffect(() => {
function handleBackdropClick(event) {
if (event.target?.tagName === "DIV") closeAndReset();
@@ -228,14 +245,11 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
if (isOpen) {
searchField.current.focus();
document.body.addEventListener("click", handleBackdropClick);
setHidden(false);
} else {
document.body.removeEventListener("click", handleBackdropClick);
searchField.current.blur();
setTimeout(() => {
setHidden(true);
}, 300); // disable on close
}
return () => document.body.removeEventListener("click", handleBackdropClick);
}, [isOpen, closeAndReset]);
function highlightText(text) {
@@ -259,13 +273,17 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
<>
<div
className={classNames(
"relative z-40 ease-in-out duration-300 transition-opacity",
hidden && !isOpen && "hidden",
!hidden && isOpen && "opacity-100",
!isOpen && "opacity-0",
"relative z-40 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0 pointer-events-none",
)}
style={{
transitionProperty: "opacity, visibility",
transitionDuration: "300ms, 0s",
transitionDelay: isOpen ? "0s, 0s" : "0s, 300ms",
}}
role="dialog"
aria-modal="true"
aria-hidden={!isOpen}
>
<div className="fixed inset-0 bg-gray-500 opacity-50" />
<div className="fixed inset-0 z-20 overflow-y-auto">
@@ -297,7 +315,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
onKeyDown={handleItemKeyDown}
className={classNames(
"flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200",
i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
i === activeItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
)}
>
<div className="flex flex-row items-center mr-4 pointer-events-none">
+6 -11
View File
@@ -1,9 +1,11 @@
import { useTranslation } from "next-i18next/pages";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import Container from "../widget/container";
import Raw from "../widget/raw";
import useCurrentTime from "utils/hooks/use-current-time";
const textSizes = {
"4xl": "text-4xl",
"3xl": "text-3xl",
@@ -18,17 +20,10 @@ const textSizes = {
export default function DateTime({ options }) {
const { text_size: textSize, locale, format } = options;
const { i18n } = useTranslation();
const [date, setDate] = useState("");
const dateLocale = locale ?? i18n.language;
useEffect(() => {
const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format });
setDate(dateFormat.format(new Date()));
const interval = setInterval(() => {
setDate(dateFormat.format(new Date()));
}, 1000);
return () => clearInterval(interval);
}, [date, setDate, dateLocale, format]);
const currentTime = useCurrentTime(1000);
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(dateLocale, { ...format }), [dateLocale, format]);
const date = currentTime === null ? "" : dateFormatter.format(new Date(currentTime));
return (
<Container options={options} additionalClassNames="information-widget-datetime">
+2 -2
View File
@@ -7,9 +7,9 @@ import Resource from "../widget/resource";
export default function Network({ options, refresh = 1500 }) {
const { t } = useTranslation();
if (options.network === true) options.network = "default";
const network = options.network === true ? "default" : options.network;
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${network}`, {
refreshInterval: refresh,
});
@@ -22,10 +22,12 @@ describe("components/widgets/resources/network", () => {
it("normalizes options.network=true to default interfaceName in the request", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
const options = Object.freeze({ network: true });
render(<Network options={{ network: true }} />);
render(<Network options={options} />);
expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object));
expect(options.network).toBe(true);
});
it("renders rates and usage percentage when data is present", () => {
+37 -43
View File
@@ -11,7 +11,7 @@ import {
} from "@headlessui/react";
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { Fragment, useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { BiLogoBing } from "react-icons/bi";
import { FiSearch } from "react-icons/fi";
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
@@ -79,24 +79,32 @@ export function getStoredProvider() {
return null;
}
function subscribeToStoredProvider(onStoreChange) {
const handleStorage = (event) => {
if (event.key === localStorageKey) onStoreChange();
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}
export default function Search({ options }) {
const { t } = useTranslation();
// options is a fresh object each render, so memo on provider itself
const availableProviderIds = useMemo(() => getAvailableProviderIds(options.provider) ?? [], [options.provider]);
const storedProvider = useSyncExternalStore(subscribeToStoredProvider, getStoredProvider, () => null);
const storedProviderId = Object.keys(searchProviders).find(
(providerId) => searchProviders[providerId] === storedProvider,
);
const initialProvider = availableProviderIds.includes(storedProviderId)
? storedProvider
: searchProviders[availableProviderIds[0] ?? "google"];
const [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
const [providerOverride, setProviderOverride] = useState(null);
const [searchSuggestions, setSearchSuggestions] = useState([]);
useEffect(() => {
const storedProvider = getStoredProvider();
let storedProviderKey = null;
storedProviderKey = Object.keys(searchProviders).find((pkey) => searchProviders[pkey] === storedProvider);
if (storedProvider && availableProviderIds.includes(storedProviderKey)) {
setSelectedProvider(storedProvider);
}
}, [availableProviderIds]);
const selectedProvider = providerOverride ?? initialProvider;
useEffect(() => {
const abortController = new AbortController();
@@ -130,8 +138,6 @@ export default function Search({ options }) {
};
}, [selectedProvider, options, query, searchSuggestions]);
let currentSuggestion;
function doSearch(value) {
const q = encodeURIComponent(value);
const { url } = selectedProvider;
@@ -142,13 +148,11 @@ export default function Search({ options }) {
}
setQuery("");
currentSuggestion = null;
}
const handleSearchKeyDown = (event) => {
const useSuggestion = searchSuggestions.length && currentSuggestion;
if (event.key === "Enter") {
doSearch(useSuggestion ? currentSuggestion : event.target.value);
if (event.key === "Enter" && !searchSuggestions[1]?.length) {
doSearch(event.target.value);
}
};
@@ -157,7 +161,7 @@ export default function Search({ options }) {
}
const onChangeProvider = (provider) => {
setSelectedProvider(provider);
setProviderOverride(provider);
localStorage.setItem(localStorageKey, provider.name);
};
@@ -166,7 +170,7 @@ export default function Search({ options }) {
<Raw>
<div className="flex-col relative h-8 my-4 min-w-fit z-20">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<Combobox value={query}>
<Combobox value={query} onChange={doSearch}>
<ComboboxInput
type="text"
className="
@@ -250,30 +254,20 @@ export default function Search({ options }) {
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
<ComboboxOption key={query} value={query} />
{searchSuggestions[1].map((suggestion) => (
<ComboboxOption
key={suggestion}
value={suggestion}
onMouseDown={() => {
doSearch(suggestion);
}}
className="flex w-full"
>
{({ active }) => {
if (active) currentSuggestion = suggestion;
return (
<div
className={classNames(
"px-2 py-1 rounded-md w-full flex-nowrap",
active ? "bg-theme-300/20 dark:bg-white/10" : "",
)}
>
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
<span className="mr-4 whitespace-pre opacity-50">
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
);
}}
<ComboboxOption key={suggestion} value={suggestion} className="flex w-full">
{({ active }) => (
<div
className={classNames(
"px-2 py-1 rounded-md w-full flex-nowrap",
active ? "bg-theme-300/20 dark:bg-white/10" : "",
)}
>
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
<span className="mr-4 whitespace-pre opacity-50">
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
)}
</ComboboxOption>
))}
</div>
+15 -2
View File
@@ -9,6 +9,7 @@ import { renderWithProviders } from "test-utils/render-with-providers";
vi.mock("@headlessui/react", async () => {
const React = await import("react");
const { Fragment, createContext, useContext } = React;
const ComboboxContext = createContext(null);
const ListboxContext = createContext(null);
function passthrough({ as: As = "div", children, ...props }) {
@@ -18,9 +19,21 @@ vi.mock("@headlessui/react", async () => {
}
return {
Combobox: passthrough,
Combobox: ({ onChange, children }) => (
<ComboboxContext.Provider value={{ onChange }}>
<div>{children}</div>
</ComboboxContext.Provider>
),
ComboboxInput: (props) => <input {...props} />,
ComboboxOption: passthrough,
ComboboxOption: ({ as: As = "div", value, children, ...props }) => {
const ctx = useContext(ComboboxContext);
const content = typeof children === "function" ? children({ active: false }) : children;
return (
<As value={value} onMouseDown={() => ctx?.onChange?.(value)} {...props}>
{content}
</As>
);
},
ComboboxOptions: passthrough,
Listbox: ({ value, onChange, children, ...props }) => (
<ListboxContext.Provider value={{ value, onChange }}>
@@ -14,11 +14,11 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Widget({ options }) {
const { t } = useTranslation();
options.service_group = options.service_name = "unifi_console";
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
const widgetOptions = { ...options, service_group: "unifi_console", service_name: "unifi_console" };
const { data: statsData, error: statsError } = useWidgetAPI(widgetOptions, "stat/sites", { index: options.index });
if (statsError) {
return <Error options={options} />;
return <Error options={widgetOptions} />;
}
const defaultSite = options.site
@@ -27,27 +27,27 @@ export default function Widget({ options }) {
if (!defaultSite) {
return (
<Container options={options} additionalClassNames="information-widget-unifi-console">
<Container options={widgetOptions} additionalClassNames="information-widget-unifi-console">
<PrimaryText>{t("unifi.wait")}</PrimaryText>
<WidgetIcon icon={SiUbiquiti} />
</Container>
);
}
const wan = defaultSite.health.find((h) => h.subsystem === "wan");
const lan = defaultSite.health.find((h) => h.subsystem === "lan");
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
[wan, lan, wlan].forEach((s) => {
s.up = s.status === "ok";
s.show = s.status !== "unknown";
});
const getHealth = (subsystem) => {
const health = defaultSite.health.find((item) => item.subsystem === subsystem) ?? { status: "unknown" };
return { ...health, up: health.status === "ok", show: health.status !== "unknown" };
};
const wan = getHealth("wan");
const lan = getHealth("lan");
const wlan = getHealth("wlan");
const name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);
return (
<Container options={options} additionalClassNames="information-widget-unifi-console">
<Container options={widgetOptions} additionalClassNames="information-widget-unifi-console">
<Raw>
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col">
@@ -40,32 +40,41 @@ describe("components/widgets/unifi_console", () => {
it("renders a wait state when no site is available yet", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const options = Object.freeze({ index: 0 });
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
renderWithProviders(<UnifiConsole options={options} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.wait")).toBeInTheDocument();
expect(useWidgetAPI).toHaveBeenCalledWith(
{ index: 0, service_group: "unifi_console", service_name: "unifi_console" },
"stat/sites",
{ index: 0 },
);
expect(options).toEqual({ index: 0 });
});
it("renders site name and uptime when data is available", () => {
const data = {
data: [
{
name: "default",
desc: "Home",
health: [
{
subsystem: "wan",
status: "ok",
gw_name: "Router",
"gw_system-stats": { uptime: 172800 },
},
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
};
const originalData = structuredClone(data);
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{
subsystem: "wan",
status: "ok",
gw_name: "Router",
"gw_system-stats": { uptime: 172800 },
},
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
data,
error: undefined,
});
@@ -75,6 +84,7 @@ describe("components/widgets/unifi_console", () => {
// common.number is mocked to return the numeric value as a string.
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("unifi.days")).toBeInTheDocument();
expect(data).toEqual(originalData);
});
it("selects a site by description when options.site is set", () => {