From d5c3aebd2665e5215103543a1c8992a9cb5771cc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:10:16 +0200 Subject: [PATCH] fix: harden widget review follow-ups --- .../src/components/board/items/item-content.tsx | 5 +++-- e2e/custom-widgets-custom-jsx.spec.ts | 13 +++++++------ packages/translation/src/lang/en.json | 12 ++++++++++++ packages/widgets/src/coolify/resource-row.tsx | 7 +++++-- .../widgets/src/coolify/servers-section.tsx | 2 +- packages/widgets/src/custom-api/component.tsx | 10 +++++----- .../network-status/component.tsx | 17 ++++++++++++----- .../widgets/src/paperless-ngx/component.tsx | 7 ++++++- packages/widgets/src/vpn/component.tsx | 5 +++-- packages/widgets/src/vpn/vpn-card.tsx | 12 ++++++++++-- 10 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 969195d60..a572cdbad 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -157,8 +157,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { height: advancedRect.height, "--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`, "--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`, - "--focus-scale-x": activeFocus.sourceRect.width / advancedRect.width, - "--focus-scale-y": activeFocus.sourceRect.height / advancedRect.height, + "--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1, + "--focus-scale-y": + advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1, } as CSSProperties) : undefined } diff --git a/e2e/custom-widgets-custom-jsx.spec.ts b/e2e/custom-widgets-custom-jsx.spec.ts index cba6c0d75..c1f7743ba 100644 --- a/e2e/custom-widgets-custom-jsx.spec.ts +++ b/e2e/custom-widgets-custom-jsx.spec.ts @@ -40,16 +40,17 @@ describe("Custom JSX custom widgets", () => { const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); + const destination = "/manage/custom-widgets/new"; try { - await page.goto(`${baseUrl}/auth/login`); + await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`); await page.getByLabel("Username").fill(adminCredentials.username); await page.locator("#password").fill(adminCredentials.password); - await page.locator("css=button[type='submit']").click(); - await page.waitForURL(baseUrl, { timeout: 15_000 }); - - await page.goto(`${baseUrl}/manage/custom-widgets/new`); - await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 }); + await Promise.all([ + page.waitForURL(`**${destination}`, { timeout: 30_000 }), + page.locator("css=button[type='submit']").click(), + ]); + await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX"); await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`); await page.getByRole("combobox", { name: "Display Type" }).click(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 16f33874c..9151de77c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2567,6 +2567,12 @@ "label": "Show services" } }, + "action": { + "openResource": "Open {name}", + "openLogs": "Open {name} logs", + "showIp": "Show IP address", + "hideIp": "Hide IP address" + }, "empty": { "servers": "No servers found", "applications": "No applications found", @@ -2590,6 +2596,12 @@ "name": "VPN", "description": "Overview of your VPN connections", "serviceUnavailable": "VPN unavailable", + "status": { + "running": "Running", + "notRunning": "Not running", + "unavailable": "Unavailable", + "vpn": "VPN: {status}" + }, "option": {} }, "ups": { diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index fba5279ca..44ca137a1 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -3,6 +3,8 @@ import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; import { IconFileText, IconLink } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + import actionTargetClasses from "../common/action-target.module.css"; import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; @@ -25,6 +27,7 @@ interface ResourceRowProps { } export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { + const t = useScopedI18n("widget.coolify"); const status = parseStatus(item.status ?? ""); const statusColor = getStatusColor(status); @@ -64,7 +67,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow href={cleanFqdn(item.fqdn)} target="_blank" rel="noopener noreferrer" - aria-label={`Open ${item.name}`} + aria-label={t("action.openResource", { name: item.name })} size="xs" variant="subtle" c="dimmed" @@ -79,7 +82,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow href={logsUrl} target="_blank" rel="noopener noreferrer" - aria-label={`Open ${item.name} logs`} + aria-label={t("action.openLogs", { name: item.name })} size="xs" variant="subtle" c="dimmed" diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 6a5da6ef4..24f82852a 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -42,7 +42,7 @@ export function ServersSection({ ; const dataType = widgetData.type as string | undefined; const canTogglePolling = Boolean(data) && dataType !== "actionButton" && dataType !== "disabled"; - const togglePolling = useCallback(() => setPollingPaused((value) => !value), []); + const togglePolling = useCallback(() => { + if (pollingPaused) void refetch(); + setPollingPaused((value) => !value); + }, [pollingPaused, refetch]); useEffect(() => { if (!widgetStateRef) return; @@ -607,10 +610,7 @@ function CustomApiWidgetInner({ aria-label={pollingLabel} variant="subtle" loading={isFetching} - onClick={() => { - if (pollingPaused) void refetch(); - setPollingPaused((value) => !value); - }} + onClick={togglePolling} > {pollingPaused ? : } diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index d931df9fb..3f58108be 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -8,7 +8,7 @@ import objectSupport from "dayjs/plugin/objectSupport"; import relativeTime from "dayjs/plugin/relativeTime"; import { clientApi } from "@homarr/api/client"; -import { useI18n } from "@homarr/translation/client"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { WifiVariant } from "./variants/wifi-variant"; @@ -25,12 +25,17 @@ export default function NetworkControllerNetworkStatusWidget({ width, height, }: WidgetComponentProps<"networkControllerStatus">) { - const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({ + const { + data: summaries, + error, + isPending, + } = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const t = useI18n(); + const t = useScopedI18n("widget.networkControllerStatus"); + const tCommon = useScopedI18n("common"); - const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); + const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]); const isAdvanced = displayMode === "advanced"; const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0); const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0); @@ -38,11 +43,13 @@ export default function NetworkControllerNetworkStatusWidget({ const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0); const useHorizontalStats = !isAdvanced && height < 150 && width >= 200; + if (error && summaries === undefined) throw error; + if (isPending || data.length === 0) { return (
- {isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")} + {isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
); diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index e0d08e49e..ae22402d8 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -79,7 +79,11 @@ export default function PaperlessNgxWidget({ }: WidgetComponentProps<"paperlessNgx">) { const t = useScopedI18n("widget.paperlessNgx"); const tCommon = useScopedI18n("common"); - const { data: stats, isPending } = clientApi.widget.paperlessNgx.getStats.useQuery({ + const { + data: stats, + error, + isPending, + } = clientApi.widget.paperlessNgx.getStats.useQuery({ integrationId: integrationIds[0] ?? "", }); @@ -92,6 +96,7 @@ export default function PaperlessNgxWidget({ ); } + if (error && stats === undefined) throw error; if (!stats) return ; const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal; diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx index 295499d78..d9526923c 100644 --- a/packages/widgets/src/vpn/component.tsx +++ b/packages/widgets/src/vpn/component.tsx @@ -3,7 +3,7 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { VpnIntegrationCard } from "./vpn-card"; @@ -21,6 +21,7 @@ export default function VpnWidget({ }); const t = useScopedI18n("widget.vpn"); const tCommon = useScopedI18n("common"); + const locale = useCurrentLocale(); const dense = displayMode === "compact" && (width < 240 || height < 120); if (isPending || integrations.length === 0) { @@ -56,7 +57,7 @@ export default function VpnWidget({ {!result.error && new Date(result.integration.updatedAt).getTime() > 0 && ( - {new Date(result.integration.updatedAt).toLocaleString()} + {new Date(result.integration.updatedAt).toLocaleString(locale)} )} diff --git a/packages/widgets/src/vpn/vpn-card.tsx b/packages/widgets/src/vpn/vpn-card.tsx index 8ddcfd7fd..aeaa36c0c 100644 --- a/packages/widgets/src/vpn/vpn-card.tsx +++ b/packages/widgets/src/vpn/vpn-card.tsx @@ -102,8 +102,12 @@ function VpnStatusColumn({ compact: boolean; dense: boolean; }) { + const t = useScopedI18n("widget.vpn"); // Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross. const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX; + const statusLabel = t( + vpnStatus === RUNNING_STATUS ? "status.running" : vpnStatus ? "status.notRunning" : "status.unavailable", + ); return ( } > DNS - {status || "unavailable"} + {statusLabel} ); }