refactor(widgets): stabilize widget components after useQuery migration
Add WidgetEmptyState component for widgets that need a loading fallback. Disable refetch-on-mount/focus/reconnect and retry for integration widgets so cached data is preserved. Ensure hooks are never called conditionally.
This commit is contained in:
1 parent
f7f6d1e382
commit
f49ae79c99
17 files changed
+59
-20
No files matched your search
@@ -14,6 +14,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import type { AnchorNotePermission } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
@@ -264,7 +265,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
);
|
||||
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note, noteId, refetch, t, updateNoteAsync]);
|
||||
|
||||
if (!note) return null;
|
||||
if (!note) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="xs" p="sm">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -13,6 +13,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
@@ -55,7 +56,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
[app, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (!app) return null;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
@@ -131,7 +132,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { IconCheck, IconMinus, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { PingDot } from "./ping-dot";
|
||||
|
||||
@@ -9,6 +10,7 @@ interface PingIndicatorProps {
|
||||
}
|
||||
|
||||
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
|
||||
{ id: appId },
|
||||
{ refetchOnMount: false, refetchOnWindowFocus: false },
|
||||
@@ -24,7 +26,7 @@ export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||
},
|
||||
);
|
||||
|
||||
if (!pingResult) return null;
|
||||
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
|
||||
|
||||
const isError = "error" in pingResult || pingResult.statusCode >= 500;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Center, Text } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const WidgetEmptyState = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Center h="100%" w="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("widget.common.error.noData")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { ResourceAccordionItem } from "./resource-accordion-item";
|
||||
@@ -56,7 +57,7 @@ export const ClusterHealthMonitoring = ({
|
||||
},
|
||||
);
|
||||
|
||||
if (!healthData) return null;
|
||||
if (!healthData) return <WidgetEmptyState />;
|
||||
|
||||
const activeNodes = healthData.nodes.reduce(running, 0);
|
||||
const activeVMs = healthData.vms.reduce(running, 0);
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
import { CpuTempRing } from "./rings/cpu-temp-ring";
|
||||
import { GpuRing } from "./rings/gpu-ring";
|
||||
@@ -86,6 +87,8 @@ export const SystemHealthMonitoring = ({
|
||||
|
||||
const isTiny = width < 256;
|
||||
|
||||
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -29,7 +30,7 @@ export default function ImmichAlbumCarouselWidget({
|
||||
return <NoAlbumSelected />;
|
||||
}
|
||||
|
||||
if (!album) return null;
|
||||
if (!album) return <WidgetEmptyState />;
|
||||
|
||||
if (album.assets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -20,7 +21,7 @@ export default function ImmichServerStatsWidget({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
|
||||
if (!stats) return null;
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%" p="md">
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MediaRequestStatus } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
|
||||
@@ -19,7 +20,7 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const { data: mediaRequests = [], isFetched } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
|
||||
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
@@ -58,7 +59,7 @@ export default function MediaServerWidget({
|
||||
},
|
||||
);
|
||||
|
||||
if (!isFetched) return null;
|
||||
if (!mediaRequests) return <WidgetEmptyState />;
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { RequestStats } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
@@ -45,7 +46,7 @@ export default function MediaServerWidget({
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
if (!requestStats) return null;
|
||||
if (!requestStats) return <WidgetEmptyState />;
|
||||
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
const data = [
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
@@ -53,7 +54,7 @@ export default function MediaTranscodingWidget({
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
|
||||
if (!transcodingData) return null;
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
const isTiny = width < 256;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
@@ -22,7 +23,7 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
||||
});
|
||||
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
|
||||
|
||||
if (!result) return null;
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { mergeStats } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
@@ -86,6 +87,7 @@ export default function SpeedtestTrackerWidget({
|
||||
{t("noSectionsEnabled")}
|
||||
</Text>
|
||||
)}
|
||||
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
function round(value: number) {
|
||||
@@ -26,7 +27,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
|
||||
const theme = useMantineTheme();
|
||||
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
|
||||
|
||||
if (!result) return null;
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
const stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
@@ -49,7 +50,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
||||
|
||||
const lastItem = data.at(-1);
|
||||
|
||||
if (!lastItem) return null;
|
||||
if (!lastItem) return <WidgetEmptyState />;
|
||||
const { fileSystem, smart } = lastItem.healthInfo;
|
||||
|
||||
if (fileSystem.length === 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AreaChartSeries } from "@mantine/charts";
|
||||
import { AreaChart, LineChart } from "@mantine/charts";
|
||||
import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
|
||||
@@ -85,8 +85,13 @@ export const CommonChart = ({
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"}>
|
||||
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||
|
||||
@@ -29,7 +30,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
|
||||
onData: (data) => utils.widget.weather.atLocation.setData(input, data),
|
||||
});
|
||||
|
||||
if (!weather) return null;
|
||||
if (!weather) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
Reference in new issue
Block a user