feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:48 +02:00
1 parent 6331c88911
commit 1fa345edd1
165 files changed
+3698 -1649

No files matched your search

@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl } from "./application-url";
describe("getSafeApplicationUrl", () => {
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
"rejects unsafe or non-absolute URL %s",
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
);
test("rejects credential-bearing URLs", () => {
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
});
test("allows absolute HTTP and HTTPS URLs", () => {
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
});
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
"https://example.com/story",
);
});
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
expect(getSafeApplicationUrl(value)).toBeUndefined();
});
});
@@ -0,0 +1 @@
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
const sample = new Date("2026-01-02T13:05:09.000Z");
describe("localized widget formatting", () => {
test.each([
["en-US", /Jan/],
["fr-FR", /janv/i],
["de-DE", /Jan/],
["he-IL", /[\u0590-\u05ff]/],
])("formats dates for %s", (locale, expected) => {
expect(
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
).toMatch(expected);
});
test("supports explicit 12-hour and 24-hour clocks", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
});
test("normalizes binary status for translated accessibility labels", () => {
expect(getBinaryStatusKey("enabled")).toBe("enabled");
expect(getBinaryStatusKey("disabled")).toBe("disabled");
expect(getBinaryStatusKey()).toBe("unknown");
});
});
+61
View File
@@ -0,0 +1,61 @@
export type LocalizedDateValue = Date | number | string;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
timeZone?: string;
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
export const formatLocalizedDate = (
value: LocalizedDateValue,
locale: string,
options: Intl.DateTimeFormatOptions,
fallback = "?",
): string => {
const date = toValidDate(value);
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
};
export const formatLocalizedTime = (
value: LocalizedDateValue,
locale: string,
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{
hour: "numeric",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
hour12,
timeZone,
},
fallback,
);
export const formatLocalizedDateTime = (
value: LocalizedDateValue,
locale: string,
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
fallback,
);
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetQueryErrorIndicator", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
});
@@ -0,0 +1,38 @@
"use client";
import { Box, Center, Loader, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
if (!error) return null;
const accessibleLabel = label;
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<Box
component="span"
c="orange"
role="img"
aria-label={accessibleLabel}
tabIndex={0}
style={{ display: "inline-flex", cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</Box>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
return (
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
<Loader size="sm" />
</Center>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
});
test("preserves stale cached data when a refetch fails", () => {
const data = [{ id: "healthy" }];
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
});
test("keeps the initial pending state when neither data nor an error exists", () => {
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
});
});
describe("isInitialWidgetQueryPending", () => {
test("only marks the unresolved first request as initial pending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
describe("hasStaleWidgetQueryError", () => {
test("only marks failures that retain usable cached data", () => {
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
});
});
@@ -0,0 +1,22 @@
interface WidgetQueryState {
data: unknown;
error: unknown;
}
interface PendingWidgetQueryState extends WidgetQueryState {
isPending: boolean;
}
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
error !== null && error !== undefined && data !== undefined;