mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-27 06:21:18 -07:00
Chore: full react hooks eslint compliance (#7040)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user