fix: harden widget review follow-ups

This commit is contained in:
Thomas Camlong committed 2026-08-02 16:10:16 +02:00
1 parent 7b7045202d
commit d5c3aebd26
10 files changed
+64 -26

No files matched your search

@@ -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
}
+7 -6
View File
@@ -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();
+12
View File
@@ -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": {
@@ -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"
@@ -42,7 +42,7 @@ export function ServersSection({
</Group>
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
aria-label={t(showIp ? "action.hideIp" : "action.showIp")}
size="xs"
variant="subtle"
c="dimmed"
@@ -531,7 +531,10 @@ function CustomApiWidgetInner({
const widgetData = (data ?? {}) as Record<string, unknown>;
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 ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
</ActionIcon>
@@ -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 (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")}
{isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
</Text>
</Center>
);
@@ -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({
</Center>
);
}
if (error && stats === undefined) throw error;
if (!stats) return <WidgetEmptyState />;
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
+3 -2
View File
@@ -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({
<VpnIntegrationCard vpn={result.summary} integrationName={result.integration.name} variant="list" />
{!result.error && new Date(result.integration.updatedAt).getTime() > 0 && (
<Text size="xs" c="dimmed" ta="right">
{new Date(result.integration.updatedAt).toLocaleString()}
{new Date(result.integration.updatedAt).toLocaleString(locale)}
</Text>
)}
</Stack>
+10 -2
View File
@@ -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 (
<Flex
@@ -114,7 +118,7 @@ function VpnStatusColumn({
align="center"
>
<ShieldIcon
aria-label={vpnStatus || "unavailable"}
aria-label={t("status.vpn", { status: statusLabel })}
stroke={2}
color={getStatusColor(vpnStatus)}
size={dense ? 32 : compact ? 44 : 52}
@@ -181,7 +185,11 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol
}
function DnsStatusBadge({ status }: { status: string }) {
const t = useScopedI18n("widget.vpn");
const StatusIcon = status === RUNNING_STATUS ? IconCircleCheckFilled : IconCircleXFilled;
const statusLabel = t(
status === RUNNING_STATUS ? "status.running" : status ? "status.notRunning" : "status.unavailable",
);
return (
<Badge
variant="outline"
@@ -191,7 +199,7 @@ function DnsStatusBadge({ status }: { status: string }) {
leftSection={<StatusIcon aria-hidden size={9} />}
>
DNS
<VisuallyHidden> {status || "unavailable"}</VisuallyHidden>
<VisuallyHidden> {statusLabel}</VisuallyHidden>
</Badge>
);
}