feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,11 @@
.root {
min-width: 24px;
min-height: 24px;
}
@media (any-pointer: coarse) {
.root {
min-width: 44px;
min-height: 44px;
}
}
@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl, getSafeAppHref } 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();
});
});
describe("getSafeAppHref", () => {
test.each(["#section", "/boards/home", "./relative", "vscode://file/example.ts", "https://example.com/path"])(
"allows configured app link %s",
(value) => expect(getSafeAppHref(value)).toBe(value),
);
test.each([
"javascript:alert(1)",
"data:text/html,test",
"file:///etc/passwd",
"//example.com/path",
"not a URL",
"https://user:password@example.com/path",
])("rejects unsafe app link %s", (value) => expect(getSafeAppHref(value)).toBeUndefined());
});
@@ -0,0 +1 @@
export { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { addRememberedBrowserAlert, requestBrowserNotificationPermission } from "./browser-alert";
const originalNotification = window.Notification;
afterEach(() => {
if (originalNotification === undefined) {
Reflect.deleteProperty(window, "Notification");
return;
}
Object.defineProperty(window, "Notification", { configurable: true, value: originalNotification });
});
describe("browser alert occurrence history", () => {
it("moves a repeated occurrence to the end without duplicating it", () => {
expect(addRememberedBrowserAlert(["first", "second"], "first")).toEqual(["second", "first"]);
});
it("bounds remembered occurrences", () => {
const values = Array.from({ length: 110 }, (_, index) => String(index));
const result = addRememberedBrowserAlert(values, "next");
expect(result).toHaveLength(100);
expect(result.at(-1)).toBe("next");
});
it("keeps the current permission when the browser rejects a permission request", async () => {
Object.defineProperty(window, "Notification", {
configurable: true,
value: {
permission: "default",
requestPermission: vi.fn().mockRejectedValue(new Error("blocked")),
},
});
await expect(requestBrowserNotificationPermission()).resolves.toBe("default");
});
});
@@ -0,0 +1,87 @@
export type BrowserNotificationPermission = NotificationPermission | "unsupported";
export interface BrowserNotificationOptions {
title: string;
body?: string;
icon?: string;
tag?: string;
}
const maxRememberedOccurrences = 100;
export const getBrowserNotificationPermission = (): BrowserNotificationPermission => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return window.Notification.permission;
};
export const requestBrowserNotificationPermission = async (): Promise<BrowserNotificationPermission> => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
try {
return await window.Notification.requestPermission();
} catch {
return getBrowserNotificationPermission();
}
};
export const showBrowserNotification = ({ title, body, icon, tag }: BrowserNotificationOptions): boolean => {
if (typeof window === "undefined" || !("Notification" in window) || window.Notification.permission !== "granted") {
return false;
}
try {
const notification = new window.Notification(title, { body, icon, tag });
void notification;
return true;
} catch {
return false;
}
};
export const playBrowserAlertSound = (): boolean => {
if (typeof window === "undefined") return false;
const AudioContextConstructor =
window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextConstructor) return false;
try {
const context = new AudioContextConstructor();
const oscillator = context.createOscillator();
const gain = context.createGain();
const start = context.currentTime;
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(880, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.3);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(start);
oscillator.stop(start + 0.32);
oscillator.addEventListener("ended", () => void context.close(), { once: true });
return true;
} catch {
return false;
}
};
export const addRememberedBrowserAlert = (occurrences: string[], occurrence: string): string[] => {
const withoutDuplicate = occurrences.filter((value) => value !== occurrence);
return [...withoutDuplicate, occurrence].slice(-maxRememberedOccurrences);
};
export const claimBrowserAlertOccurrence = (storageKey: string, occurrence: string): boolean => {
if (typeof window === "undefined") return false;
try {
const serialized = window.localStorage.getItem(storageKey);
const parsed: unknown = serialized === null ? [] : JSON.parse(serialized);
const occurrences = Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === "string")
: [];
if (occurrences.includes(occurrence)) return false;
window.localStorage.setItem(storageKey, JSON.stringify(addRememberedBrowserAlert(occurrences, occurrence)));
return true;
} catch {
return false;
}
};
@@ -0,0 +1,74 @@
const SHORT_HEIGHT_BREAKPOINT = 120;
const NARROW_SHORT_WIDTH_BREAKPOINT = 120;
const NARROW_SHORT_ICON_SIZE = 12;
const SHORT_GRID_HORIZONTAL_INSET = 8;
const SHORT_GRID_GAP = 2;
const SHORT_ICON_WIDTH_RATIO = 0.44;
const SHORT_ICON_SIZE_CAP = 40;
export type CompactStatLayoutState = "default" | "short" | "narrowShort";
interface CompactStatLayoutInput {
width: number;
height: number;
visibleCount: number;
/** Compact board display mode, independent of widget-specific compact styling options. */
compactDisplay: boolean;
defaultColumns: number;
defaultIconSize: number;
}
interface CompactStatLayout {
state: CompactStatLayoutState;
columns: number;
iconSize: number;
}
export function getCompactStatLayout({
width,
height,
visibleCount,
compactDisplay,
defaultColumns,
defaultIconSize,
}: CompactStatLayoutInput): CompactStatLayout {
if (!compactDisplay) {
return {
state: "default",
columns: defaultColumns,
iconSize: defaultIconSize,
};
}
if (width < NARROW_SHORT_WIDTH_BREAKPOINT) {
return {
state: "narrowShort",
columns: 1,
iconSize: NARROW_SHORT_ICON_SIZE,
};
}
if (height >= SHORT_HEIGHT_BREAKPOINT) {
return {
state: "default",
columns: defaultColumns,
iconSize: defaultIconSize,
};
}
return {
state: "short",
columns: Math.max(visibleCount, 1),
iconSize: getShortIconSize(width, visibleCount, defaultIconSize),
};
}
function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number {
const statCount = Math.max(visibleCount, 1);
const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP;
const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0);
const availableWidthPerStat = availableWidth / statCount;
const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO);
const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP);
return Math.max(defaultIconSize, cappedIconSize);
}
+2 -2
View File
@@ -3,12 +3,12 @@ import { Center, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export const WidgetEmptyState = () => {
const t = useI18n();
const t = useI18n("widget.common.error");
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("widget.common.error.noData")}
{t("noData")}
</Text>
</Center>
);
@@ -12,11 +12,28 @@
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table {
width: 100% !important;
max-width: 100%;
table-layout: fixed !important;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
}
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
@@ -28,6 +45,13 @@
text-overflow: ellipsis;
}
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
growing it - used to make a per-row icon look bigger without inflating row height, since the
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
.homarr-data-table td.docker-actions-cell {
overflow: visible;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
@@ -40,6 +64,25 @@
transition: opacity 150ms ease-out;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
gap: 0;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
margin: 0;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
display: none;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
width: var(--mantine-font-size-md);
height: var(--mantine-font-size-md);
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
@@ -2,10 +2,26 @@
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import { scrollAreaDefaultProps } from "@homarr/ui";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width === "string") {
const parsedWidth = Number.parseFloat(width);
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
}
return fallbackColumnWidth;
};
type HomarrDataTableProps<T> = DataTableProps<T> & {
columns: DataTableColumn<T>[];
groups?: never;
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
@@ -16,11 +32,78 @@ export function HomarrDataTable<T>({
cellPadding,
rowCursor = "auto",
className,
columns,
defaultColumnProps,
onMouseDownCapture,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
const removeResizeListeners = useRef<() => void>(() => undefined);
useEffect(() => () => removeResizeListeners.current(), []);
const handleMouseDownCapture = useCallback(
(event: React.MouseEvent<HTMLTableElement>) => {
onMouseDownCapture?.(event);
const target = event.target;
if (!(target instanceof Element)) return;
const handle = target.closest(".mantine-datatable-header-resizable-handle");
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
const headerRow = currentCell?.parentElement;
if (!currentCell || !headerRow) return;
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
const currentIndex = cells.indexOf(currentCell);
const nextCell = cells[currentIndex + 1];
const currentAccessor = currentCell.dataset.accessor;
const nextAccessor = nextCell?.dataset.accessor;
if (!nextCell || !currentAccessor || !nextAccessor) return;
const widths = Object.fromEntries(
cells.flatMap((cell) => {
const accessor = cell.dataset.accessor;
if (!accessor) return [];
return [[accessor, cell.getBoundingClientRect().width]];
}),
);
const currentWidth = widths[currentAccessor];
const nextWidth = widths[nextAccessor];
if (currentWidth === undefined || nextWidth === undefined) return;
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
const minimumWidth = fallbackColumnWidth * visualScale;
const startX = event.clientX;
removeResizeListeners.current();
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = Math.min(
nextWidth - minimumWidth,
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
);
setResizePreviewWidths({
...widths,
[currentAccessor]: currentWidth + delta,
[nextAccessor]: nextWidth - delta,
});
};
const handleResizeEnd = () => {
removeResizeListeners.current();
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
};
removeResizeListeners.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("blur", handleResizeEnd);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("blur", handleResizeEnd);
},
[onMouseDownCapture],
);
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
@@ -28,30 +111,51 @@ export function HomarrDataTable<T>({
]
.filter(Boolean)
.join(" ");
const fittedColumns = useMemo(() => {
const visibleColumns = columns.filter(({ hidden }) => !hidden);
const getRenderedWidth = (column: DataTableColumn<T>) =>
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
if (totalWidth <= 0) return columns;
return columns.map((column) => {
if (column.hidden) return column;
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
const fittedWidth = { width, minWidth: width, maxWidth: width };
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
return {
...column,
titleStyle: [column.titleStyle, fittedWidth],
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
};
});
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
return (
<DataTable<T>
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
striped={false}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: !isEditMode,
resizable: !isEditMode,
draggable: true,
resizable: true,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
/>
);
}
@@ -0,0 +1,32 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface IntegrationErrorIndicatorProps {
results: readonly IntegrationResult[];
}
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
const t = useI18n("common");
const failed = results.filter((result) => Boolean(result.error));
if (failed.length === 0) return null;
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
const label = `${t("error")}: ${names}`;
return (
<Tooltip label={label} position="left" withArrow>
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
);
}
@@ -0,0 +1,39 @@
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("keeps date-only values on the requested calendar day", () => {
expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3");
});
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");
});
});
+72
View File
@@ -0,0 +1,72 @@
export type LocalizedDateValue = Date | number | string;
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
timeZone?: string;
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date =
value instanceof Date
? value
: new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
export const formatLocalizedDate = (
value: LocalizedDateValue,
locale: string,
options: Intl.DateTimeFormatOptions,
fallback = "?",
): string => {
const date = toValidDate(value);
if (!date) return fallback;
const normalizedOptions =
typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined
? { ...options, timeZone: "UTC" }
: options;
return new Intl.DateTimeFormat(locale, normalizedOptions).format(date);
};
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,49 @@
// @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 { WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
if (key === "loading") return "Loading widget data";
return key;
},
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetQueryLoadingState", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState))));
expect(host.querySelector("output")?.textContent).toContain("Loading widget data");
});
});
@@ -0,0 +1,14 @@
"use client";
import { Center, VisuallyHidden } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export function WidgetQueryLoadingState() {
const t = useI18n("common.widgetQuery");
return (
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
<VisuallyHidden>{t("loading")}</VisuallyHidden>
</Center>
);
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, 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 access-denied refetches instead of rendering stale cached data", () => {
const error = { data: { code: "FORBIDDEN" } };
expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error);
});
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);
});
});
@@ -0,0 +1,21 @@
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
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 (isTrpcForbiddenError(query.error)) throw query.error;
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;
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseLocalWidgetState, serializeLocalWidgetState } from "./use-local-widget-state";
const isCounterState = (value: unknown): value is { count: number } =>
typeof value === "object" && value !== null && "count" in value && typeof value.count === "number";
describe("local widget state serialization", () => {
it("round trips validated state", () => {
const serialized = serializeLocalWidgetState(1, { count: 3 });
expect(parseLocalWidgetState(serialized, 1, isCounterState)).toEqual({ count: 3 });
});
it("rejects malformed and invalid state", () => {
expect(parseLocalWidgetState("not-json", 1, isCounterState)).toBeUndefined();
expect(parseLocalWidgetState('{"version":1,"value":{"count":"3"}}', 1, isCounterState)).toBeUndefined();
});
it("uses the supplied migration for old versions", () => {
const serialized = serializeLocalWidgetState(1, { oldCount: 4 });
expect(
parseLocalWidgetState(serialized, 2, isCounterState, (value) => {
if (typeof value !== "object" || value === null || !("oldCount" in value)) return undefined;
return { count: Number(value.oldCount) };
}),
).toEqual({ count: 4 });
});
});
@@ -0,0 +1,146 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface StoredWidgetState {
version: number;
value: unknown;
}
interface UseLocalWidgetStateOptions<T> {
key: string | undefined;
version: number;
defaultValue: T | (() => T);
validate?: (value: unknown) => value is T;
migrate?: (value: unknown, storedVersion: number) => T | undefined;
}
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
typeof value === "function" ? (value as () => T)() : value;
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
export const parseLocalWidgetState = <T>(
serialized: string,
version: number,
validate?: (value: unknown) => value is T,
migrate?: (value: unknown, storedVersion: number) => T | undefined,
): T | undefined => {
try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
return undefined;
}
const stored = parsed as StoredWidgetState;
if (typeof stored.version !== "number") return undefined;
if (stored.version !== version) {
const migrated = migrate?.(stored.value, stored.version);
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
return migrated;
}
if (validate && !validate(stored.value)) return undefined;
return stored.value as T;
} catch {
return undefined;
}
};
export const useLocalWidgetState = <T>({
key,
version,
defaultValue,
validate,
migrate,
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
defaultValueRef.current = defaultValue;
validateRef.current = validate;
migrateRef.current = migrate;
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
const [value, setValueState] = useState<T>(createDefaultValue);
const valueRef = useRef(value);
const setMemoryValue = useCallback((nextValue: T) => {
valueRef.current = nextValue;
setValueState(nextValue);
}, []);
const removeStoredValue = useCallback(() => {
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [key]);
useEffect(() => {
if (!key) {
setMemoryValue(createDefaultValue());
return;
}
try {
const serialized = window.localStorage.getItem(key);
if (serialized === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
if (parsed === undefined) {
window.localStorage.removeItem(key);
setMemoryValue(createDefaultValue());
return;
}
setMemoryValue(parsed);
} catch {
setMemoryValue(createDefaultValue());
}
}, [createDefaultValue, key, setMemoryValue, version]);
useEffect(() => {
if (!key) return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== key) return;
if (event.newValue === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
if (parsed !== undefined) setMemoryValue(parsed);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [createDefaultValue, key, setMemoryValue, version]);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(nextValue) => {
const resolved =
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
if (Object.is(resolved, valueRef.current)) return;
setMemoryValue(resolved);
if (key) {
try {
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
} catch {
// Keep the in-memory state usable when persistence is unavailable.
}
}
},
[key, setMemoryValue, version],
);
const resetValue = useCallback(() => {
removeStoredValue();
setMemoryValue(createDefaultValue());
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
return [value, setValue, resetValue];
};
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import type { DataTableColumn } from "mantine-datatable";
import { useDataTableColumns } from "mantine-datatable";
import { isRecord } from "@homarr/common";
interface TableLayoutOptions extends Record<string, unknown> {
columnOrder?: string;
columnWidths?: string;
@@ -93,7 +95,7 @@ export const parseColumnWidths = (value: string, columnAccessors: readonly strin
if (!value) return {};
try {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
if (!isRecord(parsed)) return {};
const allowed = new Set(columnAccessors);
return Object.fromEntries(
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getNextWidgetTickDelay } from "./use-widget-now";
describe("getNextWidgetTickDelay", () => {
it("aligns second and minute ticks", () => {
const now = new Date(2026, 7, 20, 12, 34, 45, 250);
expect(getNextWidgetTickDelay(now, "second")).toBe(750);
expect(getNextWidgetTickDelay(now, "minute")).toBe(14_750);
});
it("aligns day ticks to the next local midnight", () => {
const now = new Date(2026, 7, 20, 23, 59, 59, 500);
expect(getNextWidgetTickDelay(now, "day")).toBe(500);
});
});
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type WidgetClockPrecision = "second" | "minute" | "day";
const minimumDelayMs = 10;
export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecision): number => {
let next: number;
if (precision === "second") {
next = now.getTime() - now.getMilliseconds() + 1_000;
} else if (precision === "minute") {
next = now.getTime() - now.getSeconds() * 1_000 - now.getMilliseconds() + 60_000;
} else {
next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
}
return Math.max(minimumDelayMs, next - now.getTime());
};
export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => {
const [now, setNow] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
if (timeoutRef.current === undefined) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}, []);
const scheduleTick = useCallback(() => {
cancelTick();
const current = new Date();
setNow(current);
timeoutRef.current = setTimeout(scheduleTick, getNextWidgetTickDelay(current, precision));
}, [cancelTick, precision]);
useEffect(() => {
scheduleTick();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") scheduleTick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
cancelTick();
};
}, [cancelTick, scheduleTick]);
return now;
};