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
+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", () => {