refactor: use suspense query instead of serverdata for app widget (#1143)

* refactor: use suspense query instead of serverdata for app widget

* chore: add missing translation for loading tooltip
This commit is contained in:
Meier Lukas
2024-09-17 19:30:14 +02:00
committed by GitHub
parent 003cc5160c
commit fc317840a7
11 changed files with 129 additions and 269 deletions

View File

@@ -1,58 +1,37 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Box, Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconDeviceDesktopX } from "@tabler/icons-react";
import { Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, serverData, isEditMode, width }: WidgetComponentProps<"app">) {
const t = useScopedI18n("widget.app");
const isQueryEnabled = Boolean(options.appId);
const {
data: app,
isPending,
isError,
} = clientApi.app.byId.useQuery(
export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const [app] = clientApi.app.byId.useSuspenseQuery(
{
id: options.appId,
},
{
initialData:
// We need to check if the id's match because otherwise the same initialData for a changed id will be used
serverData?.app?.id === options.appId ? serverData.app : undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: isQueryEnabled,
},
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null);
const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
clientApi.widget.app.updatedPing.useSubscription(
{ url: parseAppHrefWithVariablesClient(app?.href ?? "") },
{
enabled: shouldRunPing,
onData(data) {
setPingResult(data);
},
retry: false,
},
);
useRegisterSpotlightActions(
`app-${options.appId}`,
app?.href
app.href
? [
{
id: `app-${options.appId}`,
@@ -69,44 +48,21 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
[app, options.appId, options.openInNewTab],
);
if (isPending && isQueryEnabled) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
if (isError || !isQueryEnabled) {
return (
<Tooltip.Floating label={t("error.notFound.tooltip")}>
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1rem"} />
{width >= 96 && (
<Text ta="center" size="sm">
{t("error.notFound.label")}
</Text>
)}
</Stack>
</Tooltip.Floating>
);
}
return (
<AppLink
href={parseAppHrefWithVariablesClient(app?.href ?? "")}
href={parseAppHrefWithVariablesClient(app.href ?? "")}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
enabled={Boolean(app.href) && !isEditMode}
>
<Tooltip.Floating
label={app?.description}
label={app.description}
position="right-start"
multiline
disabled={!options.showDescriptionTooltip || !app?.description}
disabled={!options.showDescriptionTooltip || !app.description}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
className={combineClasses("app-flex-wrapper", app?.name, app?.id)}
className={combineClasses("app-flex-wrapper", app.name, app.id)}
h="100%"
w="100%"
direction="column"
@@ -115,14 +71,22 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
align="center"
>
{options.showTitle && (
<Text className="app-title" fw={700} size="12.5cqmin">
{app?.name}
<Text className="app-title" fw={700} ta="center" size="12.5cqmin">
{app.name}
</Text>
)}
<img src={app?.iconUrl} alt={app?.name} className={combineClasses(classes.appIcon, "app-icon")} />
<img src={app.iconUrl} alt={app.name} className={combineClasses(classes.appIcon, "app-icon")} />
</Flex>
</Tooltip.Floating>
{shouldRunPing && <PingIndicator pingResult={pingResult} />}
{options.pingEnabled && app.href ? (
<Suspense
fallback={
<PingDot color="blue" tooltip={t("common.rtl", { symbol: "…", value: t("common.action.loading") })} />
}
>
<PingIndicator href={app.href} />
</Suspense>
) : null}
</AppLink>
);
}
@@ -141,31 +105,3 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
) : (
children
);
interface PingIndicatorProps {
pingResult: RouterOutputs["widget"]["app"]["ping"] | null;
}
const PingIndicator = ({ pingResult }: PingIndicatorProps) => {
return (
<Box bottom={4} right={4} pos="absolute">
<Tooltip
label={pingResult && "statusCode" in pingResult ? pingResult.statusCode : pingResult?.error}
disabled={!pingResult}
>
<Box
style={{
borderRadius: "100%",
backgroundColor: !pingResult
? "orange"
: "error" in pingResult || pingResult.statusCode >= 500
? "red"
: "green",
}}
w={16}
h={16}
></Box>
</Tooltip>
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import { IconApps } from "@tabler/icons-react";
import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -12,6 +12,11 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
showDescriptionTooltip: factory.switch({ defaultValue: false }),
pingEnabled: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
errors: {
NOT_FOUND: {
icon: IconDeviceDesktopX,
message: (t) => t("widget.app.error.notFound.label"),
hideLogsLink: true,
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,24 @@
import type { MantineColor } from "@mantine/core";
import { Box, Tooltip } from "@mantine/core";
interface PingDotProps {
color: MantineColor;
tooltip: string;
}
export const PingDot = ({ color, tooltip }: PingDotProps) => {
return (
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
<Tooltip label={tooltip}>
<Box
bg={color}
style={{
borderRadius: "100%",
}}
w="10cqmin"
h="10cqmin"
></Box>
</Tooltip>
</Box>
);
};

View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { PingDot } from "./ping-dot";
interface PingIndicatorProps {
href: string;
}
export const PingIndicator = ({ href }: PingIndicatorProps) => {
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
{
url: parseAppHrefWithVariablesClient(href),
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
clientApi.widget.app.updatedPing.useSubscription(
{ url: parseAppHrefWithVariablesClient(href) },
{
onData(data) {
setPingResult(data);
},
},
);
return (
<PingDot
color={"error" in pingResult || pingResult.statusCode >= 500 ? "red" : "green"}
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
/>
);
};

View File

@@ -1,28 +0,0 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
if (!options.appId) {
return { app: null, pingResult: null };
}
try {
const app = await api.app.byId({ id: options.appId });
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
if (app.href && options.pingEnabled) {
pingResult = await api.widget.app.ping({
url: parseAppHrefWithVariablesServer(app.href),
});
}
return { app, pingResult };
} catch {
return { app: null, pingResult: null };
}
}

View File

@@ -1,132 +0,0 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common";
import type { WidgetProps } from "../../definition";
import getServerDataAsync from "../serverData";
const mockApp = (override: Partial<RouterOutputs["app"]["byId"]>) =>
({
id: "1",
name: "Mock app",
iconUrl: "https://some.com/icon.png",
description: null,
href: "https://google.ch",
...override,
}) satisfies RouterOutputs["app"]["byId"];
vi.mock("@homarr/api/server", () => ({
api: {
app: {
byId: () => null,
},
widget: {
app: {
ping: () => null,
},
},
},
}));
vi.mock("@homarr/common/server", () => ({
parseAppHrefWithVariablesServer: () => "http://localhost",
}));
describe("getServerDataAsync should load app and ping result", () => {
test("when appId is empty it should return null for app and pingResult", async () => {
// Arrange
const options = {
appId: "",
pingEnabled: true,
};
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.app).toBeNull();
expect(result.pingResult).toBeNull();
});
test("when app exists and ping is disabled it should return existing app and pingResult null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: false,
};
const mockedApp = mockApp({});
spy.mockImplementation(() => Promise.resolve(mockedApp));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
});
test("when app exists without href and ping enabled it should return existing app and pingResult null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: true,
};
const mockedApp = mockApp({ href: null });
spy.mockImplementation(() => Promise.resolve(mockedApp));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
});
test("when app does not exist it should return for both null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: true,
};
spy.mockImplementation(() =>
Promise.reject(
new TRPCError({
code: "NOT_FOUND",
}),
),
);
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
expect(result.app).toBeNull();
});
test("when app found and ping enabled it should return existing app and pingResult", async () => {
// Arrange
const spyById = vi.spyOn(api.app, "byId");
const spyPing = vi.spyOn(api.widget.app, "ping");
const options = {
appId: "1",
pingEnabled: true,
};
const mockedApp = mockApp({});
const pingResult = { statusCode: 200, url: "http://localhost" };
spyById.mockImplementation(() => Promise.resolve(mockedApp));
spyPing.mockImplementation(() => Promise.resolve(pingResult));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBe(pingResult);
expect(result.app).toBe(mockedApp);
});
});