Revert "fix: harden automatic mobile boards"

This reverts commit d0b9348b86.
This commit is contained in:
ajnart committed 2026-07-29 20:06:32 +02:00
1 parent 92a2be2b65
commit dba9a250ff
37 files changed
+137 -637

No files matched your search

+2 -2
View File
@@ -81,8 +81,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Update board visual and behavior settings - Update board visual and behavior settings
- Set desktop and mobile home boards for the current user - Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior - Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior
- Configure saved responsive layouts through Homarr's board settings UI when a server uses legacy mobile layout mode
Use the interactive API reference to inspect the exact request body for OpenAPI endpoints under the `boards` and Use the interactive API reference to inspect the exact request body for OpenAPI endpoints under the `boards` and
`settings` tags. Saved responsive layouts are intentionally not exposed as an OpenAPI or MCP replacement operation `settings` tags. Saved responsive layouts are intentionally not exposed as an OpenAPI or MCP replacement operation
because omitting a layout would remove it; configure them through Homarr's board settings UI when a server uses because omitting a layout would remove it.
legacy mobile layout mode.
+2 -2
View File
@@ -34,7 +34,7 @@ Underscores and hyphen-minus are allowed.
### Column count ### Column count
Homarr has a complex drag-and-drop system that can be used from the browser. Homarr has a complex drag and drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to. The column count defines how many horizontal columns are available to drag and drop elements to.
### Public ### Public
@@ -270,7 +270,7 @@ Alternatively, you can also define it via your user profile:
### For whole server ### For whole server
You can also set a global home board that will be used for all users who have not set a home board themselves. You can also set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users. It will also be used for anonymous users.
1. Navigate to the settings: `Management` > `Settings` > `Boards` 1. Navigate to the settings: `Management` > `Settings` > `Boards`
+1 -1
View File
@@ -26,5 +26,5 @@
"**/*.md", "**/*.md",
"**/*.mdx", "**/*.mdx",
], ],
"exclude": ["node_modules", "build", ".docusaurus"], "exclude": ["node_modules"],
} }
-5
View File
@@ -5,7 +5,6 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env"; import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env"; import "@homarr/docker/env";
import path from "node:path";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
@@ -35,10 +34,6 @@ const nextConfig: NextConfig = {
webpackMemoryOptimizations: true, webpackMemoryOptimizations: true,
}, },
turbopack: { turbopack: {
// Keep Turbopack and output-file tracing inside this monorepo. Without an
// explicit root, an unrelated parent lockfile can make Next.js scan the
// entire parent directory and stall production builds.
root: path.join(import.meta.dirname, "../.."),
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// (see vercel/next.js#95125). Suppress until upstream fix lands. // (see vercel/next.js#95125). Suppress until upstream fix lands.
@@ -1,51 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { classifyGuardedNavigation, removeGuardHistoryEntry, replaceGuardedSameDocumentUrl } from "./_guarded-history";
describe("guarded board edit history", () => {
beforeEach(() => {
window.history.replaceState({ page: "board" }, "", "/boards/demo");
});
test("replaces the guard entry for same-document fragment navigation", () => {
const section = document.createElement("section");
section.id = "Media requests";
document.body.appendChild(section);
const scrollIntoView = vi.fn();
Object.defineProperty(section, "scrollIntoView", { value: scrollIntoView });
replaceGuardedSameDocumentUrl(new URL("/boards/demo#Media%20requests", window.location.href), {
key: "__guard",
id: "guard-id",
});
expect(window.location.hash).toBe("#Media%20requests");
expect(window.history.state).toStrictEqual({
page: "board",
__guard: "guard-id",
});
expect(scrollIntoView).toHaveBeenCalledOnce();
section.remove();
});
test("treats an explicit empty fragment as same-document navigation", () => {
const current = new URL(window.location.href);
const destination = new URL("#", current);
expect(classifyGuardedNavigation(destination, current)).toBe("same-document");
});
test("removes the guard while preserving the fragment when editing ends", () => {
window.history.replaceState({ page: "board", __guard: "guard-id" }, "", "/boards/demo#section");
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.history.replaceState({ page: "board" }, "", "/boards/demo");
window.dispatchEvent(new PopStateEvent("popstate"));
});
removeGuardHistoryEntry("http://localhost:3000/boards/demo#section", "__guard");
expect(back).toHaveBeenCalledOnce();
expect(window.location.href).toBe("http://localhost:3000/boards/demo#section");
expect(window.history.state).toStrictEqual({ page: "board" });
});
});
@@ -1,58 +0,0 @@
const getHistoryState = () =>
window.history.state && typeof window.history.state === "object"
? (window.history.state as Record<string, unknown>)
: {};
interface GuardState {
key: string;
id: string;
}
export const classifyGuardedNavigation = (destination: URL, current: URL) => {
if (destination.href === current.href) return "same-url";
if (
destination.origin === current.origin &&
destination.pathname === current.pathname &&
destination.search === current.search
) {
return "same-document";
}
return "other-document";
};
export const replaceGuardedSameDocumentUrl = (destination: URL, guard: GuardState) => {
window.history.replaceState(
{
...getHistoryState(),
[guard.key]: guard.id,
},
document.title,
destination.href,
);
if (!destination.hash) {
window.scrollTo({ top: 0, left: 0 });
return;
}
let targetId = destination.hash.slice(1);
try {
targetId = decodeURIComponent(targetId);
} catch {
// Keep the encoded value when the fragment contains malformed escape sequences.
}
document.getElementById(targetId)?.scrollIntoView();
};
export const removeGuardHistoryEntry = (guardedUrl: string, guardStateKey: string) => {
window.addEventListener(
"popstate",
() => {
const state = { ...getHistoryState() };
delete state[guardStateKey];
window.history.replaceState(state, document.title, guardedUrl);
},
{ once: true },
);
window.history.back();
};
@@ -55,8 +55,6 @@ import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-co
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { classifyGuardedNavigation, removeGuardHistoryEntry, replaceGuardedSameDocumentUrl } from "./_guarded-history";
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -76,9 +74,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return ( return (
<> <>
{isEditMode && <AddMenu />} {isEditMode && !isMobile && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoReadOnly={demoReadOnly} hidden={isMobile} />
{!demoReadOnly && ( {!demoReadOnly && (
<OnboardingTour.Target id="board-settings"> <OnboardingTour.Target id="board-settings">
@@ -103,7 +101,6 @@ const AddMenu = () => {
const { addDynamicSection } = useDynamicSectionActions(); const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions(); const { createItem } = useItemActions();
const t = useI18n(); const t = useI18n();
const canCreateApp = session?.user.permissions.includes("app-create") ?? false;
const handleAddCategory = useCallback( const handleAddCategory = useCallback(
() => () =>
@@ -119,7 +116,7 @@ const AddMenu = () => {
submitLabel: t("section.category.create.submit"), submitLabel: t("section.category.create.submit"),
}, },
{ {
title: (translate) => translate("section.category.create.title"), title: (t) => t("section.category.create.title"),
}, },
), ),
[addCategoryToEnd, openCategoryEditModal, t], [addCategoryToEnd, openCategoryEditModal, t],
@@ -137,9 +134,9 @@ const AddMenu = () => {
options: { appId: app.id }, options: { appId: app.id },
}); });
}, },
withCreate: canCreateApp, withCreate: session?.user.permissions.includes("app-create") ?? false,
}); });
}, [openAppSelectModal, createItem, canCreateApp]); }, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => { const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({}); openIntegrationSelectModal({});
@@ -182,7 +179,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -212,14 +209,16 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, [utils, board.name, close]); }, [utils, board.name, close]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (hidden) return;
if (isEditMode) { if (isEditMode) {
if (demoReadOnly) return discardDemoChanges(); if (demoReadOnly) return discardDemoChanges();
return saveBoard(board); return saveBoard(board);
} }
open(); open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
if (hidden) return null;
return ( return (
<OnboardingTour.Target id="board-edit-mode"> <OnboardingTour.Target id="board-edit-mode">
@@ -567,18 +566,8 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
const target = event.target.closest<HTMLAnchorElement>(anchorSelector); const target = event.target.closest<HTMLAnchorElement>(anchorSelector);
if (!target) return; if (!target) return;
const destination = new URL(target.href, window.location.href);
const current = new URL(window.location.href);
const navigationKind = classifyGuardedNavigation(destination, current);
if (navigationKind === "same-url") return;
if (navigationKind === "same-document") {
event.preventDefault();
guardedUrl = destination.href;
replaceGuardedSameDocumentUrl(destination, { key: guardStateKey, id: guardId });
return;
}
event.preventDefault(); event.preventDefault();
const destination = new URL(target.href, window.location.href);
openLeaveConfirmation(() => { openLeaveConfirmation(() => {
if (destination.origin === window.location.origin) { if (destination.origin === window.location.origin) {
@@ -659,7 +648,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.location.href === guardedUrl && window.location.href === guardedUrl &&
(window.history.state as Record<string, unknown> | null)?.[guardStateKey] === guardId (window.history.state as Record<string, unknown> | null)?.[guardStateKey] === guardId
) { ) {
removeGuardHistoryEntry(guardedUrl, guardStateKey); window.history.back();
} }
}; };
}, [isDirty]); }, [isDirty]);
@@ -18,20 +18,6 @@ describe("resolveMobileItemPresentation", () => {
}); });
}); });
test("does not unmount apps that use the eager default", () => {
expect(
resolveMobileItemPresentation(
{ kind: "app", height: 1 },
{
unmountWhenOffscreen: true,
},
),
).toMatchObject({
eager: true,
unmountWhenOffscreen: false,
});
});
test("defaults widgets to full width without inventing a detail view", () => { test("defaults widgets to full width without inventing a detail view", () => {
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
width: 2, width: 2,
@@ -8,7 +8,6 @@ export const resolveMobileItemPresentation = (
const supportsCompactSummary = mobile?.supportsCompactSummary === true; const supportsCompactSummary = mobile?.supportsCompactSummary === true;
const supportsDetails = mobile?.supportsDetailView === true; const supportsDetails = mobile?.supportsDetailView === true;
const usesGenericSummary = supportsDetails && !supportsCompactSummary; const usesGenericSummary = supportsDetails && !supportsCompactSummary;
const eager = mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks");
return { return {
width: mobile?.width ?? (item.kind === "app" ? 1 : 2), width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
@@ -16,8 +15,8 @@ export const resolveMobileItemPresentation = (
displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const), displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const),
supportsDetails, supportsDetails,
usesGenericSummary, usesGenericSummary,
eager, eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
unmountWhenOffscreen: eager ? false : (mobile?.unmountWhenOffscreen ?? false), unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
}; };
}; };
@@ -12,7 +12,7 @@ describe("resolveMobileBoardViewport", () => {
resolveMobileBoardViewport({ resolveMobileBoardViewport({
deviceClass: "phone", deviceClass: "phone",
matchesMobileWidth: false, matchesMobileWidth: false,
hasMounted: true, hasResolvedClientWidth: true,
}), }),
).toEqual({ ).toEqual({
deviceClass: "phone", deviceClass: "phone",
@@ -29,7 +29,7 @@ describe("resolveMobileBoardViewport", () => {
resolveMobileBoardViewport({ resolveMobileBoardViewport({
deviceClass: "tablet", deviceClass: "tablet",
matchesMobileWidth, matchesMobileWidth,
hasMounted: true, hasResolvedClientWidth: true,
}).isMobile, }).isMobile,
).toBe(expectedIsMobile); ).toBe(expectedIsMobile);
}); });
@@ -39,7 +39,7 @@ describe("resolveMobileBoardViewport", () => {
resolveMobileBoardViewport({ resolveMobileBoardViewport({
deviceClass: "tablet", deviceClass: "tablet",
matchesMobileWidth: false, matchesMobileWidth: false,
hasMounted: false, hasResolvedClientWidth: false,
}).isResolved, }).isResolved,
).toBe(false); ).toBe(false);
}); });
@@ -52,7 +52,7 @@ describe("resolveMobileBoardViewport", () => {
resolveMobileBoardViewport({ resolveMobileBoardViewport({
deviceClass: "desktop", deviceClass: "desktop",
matchesMobileWidth, matchesMobileWidth,
hasMounted: true, hasResolvedClientWidth: true,
}), }),
).toMatchObject({ ).toMatchObject({
isMobile: expectedIsMobile, isMobile: expectedIsMobile,
@@ -73,9 +73,7 @@ describe("resolveIsAutomaticMobileBoard", () => {
expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected); expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected);
}, },
); );
});
describe("shouldShowMobileBoardViewportSkeleton", () => {
test("does not show the tablet resolution skeleton in legacy mode", () => { test("does not show the tablet resolution skeleton in legacy mode", () => {
expect( expect(
shouldShowMobileBoardViewportSkeleton({ shouldShowMobileBoardViewportSkeleton({
@@ -84,13 +82,4 @@ describe("shouldShowMobileBoardViewportSkeleton", () => {
}), }),
).toBe(false); ).toBe(false);
}); });
test("shows a skeleton while an automatic mobile viewport is unresolved", () => {
expect(
shouldShowMobileBoardViewportSkeleton({
enableAutomaticMobileLayout: true,
isResolved: false,
}),
).toBe(true);
});
}); });
@@ -26,15 +26,15 @@ const MobileBoardViewportContext = createContext<MobileBoardViewport>({
export const resolveMobileBoardViewport = ({ export const resolveMobileBoardViewport = ({
deviceClass, deviceClass,
matchesMobileWidth, matchesMobileWidth,
hasMounted, hasResolvedClientWidth,
}: { }: {
deviceClass: MobileBoardDeviceClass; deviceClass: MobileBoardDeviceClass;
matchesMobileWidth: boolean; matchesMobileWidth: boolean;
hasMounted: boolean; hasResolvedClientWidth: boolean;
}): MobileBoardViewport => ({ }): MobileBoardViewport => ({
deviceClass, deviceClass,
isMobile: deviceClass === "phone" || matchesMobileWidth, isMobile: deviceClass === "phone" || matchesMobileWidth,
isResolved: deviceClass !== "tablet" || hasMounted, isResolved: deviceClass !== "tablet" || hasResolvedClientWidth,
}); });
const MobileBoardViewportSkeleton = () => ( const MobileBoardViewportSkeleton = () => (
@@ -73,19 +73,19 @@ export const MobileBoardViewportProvider = ({
children, children,
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { }: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
const { enableAutomaticMobileLayout } = useSettings(); const { enableAutomaticMobileLayout } = useSettings();
const [hasMounted, setHasMounted] = useState(false); const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
getInitialValueInEffect: true, getInitialValueInEffect: true,
}); });
useEffect(() => { useEffect(() => {
setHasMounted(true); setHasResolvedClientWidth(true);
}, []); }, []);
const viewport = resolveMobileBoardViewport({ const viewport = resolveMobileBoardViewport({
deviceClass: initialDeviceClass, deviceClass: initialDeviceClass,
matchesMobileWidth, matchesMobileWidth,
hasMounted, hasResolvedClientWidth,
}); });
return ( return (
+8 -33
View File
@@ -161,40 +161,15 @@ describe("Automatic mobile board", () => {
await setAutomaticMobileLayoutAsync(loginPage, true); await setAutomaticMobileLayoutAsync(loginPage, true);
const storageState = await loginContext.storageState(); const storageState = await loginContext.storageState();
const iPhoneUserAgent = devices["iPhone 13"].userAgent; const mobileViewports = [
const mobileViewports: { { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true },
name: string; { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true },
width: number; { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true },
height: number;
userAgent: string;
phone: boolean;
checksZoomOverflow?: boolean;
checksInteractions?: boolean;
reducedMotion?: boolean;
}[] = [
{
name: "small phone",
width: 320,
height: 568,
userAgent: iPhoneUserAgent,
phone: true,
checksZoomOverflow: true,
},
{
name: "phone",
width: 390,
height: 844,
userAgent: iPhoneUserAgent,
phone: true,
checksInteractions: true,
reducedMotion: true,
},
{ name: "large phone", width: 430, height: 932, userAgent: iPhoneUserAgent, phone: true },
{ {
name: "phone landscape", name: "phone landscape",
width: 844, width: 844,
height: 390, height: 390,
userAgent: iPhoneUserAgent, userAgent: devices["iPhone 13"].userAgent,
phone: true, phone: true,
}, },
{ {
@@ -213,7 +188,7 @@ describe("Automatic mobile board", () => {
userAgent: viewport.userAgent, userAgent: viewport.userAgent,
hasTouch: true, hasTouch: true,
isMobile: viewport.phone, isMobile: viewport.phone,
reducedMotion: viewport.reducedMotion ? "reduce" : "no-preference", reducedMotion: viewport.width === 390 ? "reduce" : "no-preference",
}); });
const page = await context.newPage(); const page = await context.newPage();
await page.goto(`${baseUrl}/boards/${boardName}`); await page.goto(`${baseUrl}/boards/${boardName}`);
@@ -259,7 +234,7 @@ describe("Automatic mobile board", () => {
expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0); expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0);
expect(layoutMetrics.layoutControls, viewport.name).toBe(0); expect(layoutMetrics.layoutControls, viewport.name).toBe(0);
if (viewport.checksZoomOverflow) { if (viewport.width === 320) {
await page.evaluate(() => { await page.evaluate(() => {
document.documentElement.style.fontSize = "200%"; document.documentElement.style.fontSize = "200%";
}); });
@@ -273,7 +248,7 @@ describe("Automatic mobile board", () => {
expect(zoomOverflow.items).toBe(0); expect(zoomOverflow.items).toBe(0);
} }
if (viewport.checksInteractions) { if (viewport.width === 390) {
await page.getByRole("button", { name: `Current board: ${boardName}` }).click(); await page.getByRole("button", { name: `Current board: ${boardName}` }).click();
await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled(); await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
+21 -5
View File
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
import { import {
getServerSettingByKeyAsync, getServerSettingByKeyAsync,
getServerSettingsAsync, getServerSettingsAsync,
mergeServerSettingByKeyAsync, insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync, updateServerSettingByKeyAsync,
} from "@homarr/db/queries"; } from "@homarr/db/queries";
import { boards } from "@homarr/db/schema"; import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings"; import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings"; import { settingsInitSchema } from "@homarr/validation/settings";
@@ -82,9 +82,17 @@ export const serverSettingsRouter = createTRPCRouter({
const current = await getServerSettingByKeyAsync(ctx.db, "board"); const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input }; const next = { ...current, ...input };
await mergeServerSettingByKeyAsync(ctx.db, "board", next, input); const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
return await getServerSettingByKeyAsync(ctx.db, "board"); if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}), }),
saveSettings: permissionRequiredProcedure saveSettings: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -100,7 +108,15 @@ export const serverSettingsRouter = createTRPCRouter({
...current, ...current,
...input.value, ...input.value,
} as ServerSettings[keyof ServerSettings]; } as ServerSettings[keyof ServerSettings];
await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value); const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, input.settingsKey),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
} else {
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
}
}), }),
initSettings: onboardingProcedure initSettings: onboardingProcedure
.requiresStep("settings") .requiresStep("settings")
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { boards, layouts, serverSettings } from "@homarr/db/schema"; import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -116,33 +116,6 @@ describe("saveSettings", () => {
}, },
]); ]);
}); });
test("saveSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.saveSettings({
settingsKey: "analytics",
value: { enableGeneral: false },
}),
caller.saveSettings({
settingsKey: "analytics",
value: { instanceId: "concurrent-instance" },
}),
]);
await expect(caller.getAll()).resolves.toMatchObject({
analytics: {
enableGeneral: false,
instanceId: "concurrent-instance",
},
});
});
}); });
describe("board settings API", () => { describe("board settings API", () => {
@@ -164,12 +137,6 @@ describe("board settings API", () => {
deviceType: undefined, deviceType: undefined,
session: defaultSession, session: defaultSession,
}); });
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 },
]);
const legacyBoardSettings = { const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId, homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId, mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
@@ -181,10 +148,7 @@ describe("board settings API", () => {
value: stringify(legacyBoardSettings), value: stringify(legacyBoardSettings),
}); });
await expect(caller.getBoardSettings()).resolves.toStrictEqual({ await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
}); });
test("updateBoardSettings should insert settings when defaults are not persisted", async () => { test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
@@ -249,25 +213,6 @@ describe("board settings API", () => {
]); ]);
}); });
test("updateBoardSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.updateBoardSettings({ forceDisableStatus: true }),
caller.updateBoardSettings({ enableStatusByDefault: false }),
]);
await expect(caller.getBoardSettings()).resolves.toMatchObject({
forceDisableStatus: true,
enableStatusByDefault: false,
});
});
test("updateBoardSettings should reject private home boards", async () => { test("updateBoardSettings should reject private home boards", async () => {
const db = createDb(); const db = createDb();
const caller = serverSettingsRouter.createCaller({ const caller = serverSettingsRouter.createCaller({
@@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueriesWithProvenance } from "../../settle-integrations"; import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const mediaReleasesProcedure = publicProcedure.concat( const mediaReleasesProcedure = publicProcedure.concat(
@@ -12,7 +12,7 @@ const mediaReleasesProcedure = publicProcedure.concat(
type MediaReleaseIntegration = Parameters<typeof mediaReleaseRequestHandler.handler>[0]; type MediaReleaseIntegration = Parameters<typeof mediaReleaseRequestHandler.handler>[0];
const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => { const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( const results = await settleIntegrationQueries(
integrations, integrations,
async (integration) => { async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -40,7 +40,7 @@ const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseInt
return { return {
items, items,
...provenance, ...getIntegrationQueryProvenance(integrations.length, results),
}; };
}; };
@@ -11,7 +11,7 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueriesWithProvenance } from "../../settle-integrations"; import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const latestRequestsProcedure = publicProcedure const latestRequestsProcedure = publicProcedure
@@ -28,7 +28,7 @@ const getLatestRequestsWithProvenanceAsync = async (
integrations: MediaRequestIntegration[], integrations: MediaRequestIntegration[],
input: MediaRequestListInput, input: MediaRequestListInput,
) => { ) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( const results = await settleIntegrationQueries(
integrations, integrations,
async (integration) => { async (integration) => {
const { data, isStale } = await mediaRequestListRequestHandler const { data, isStale } = await mediaRequestListRequestHandler
@@ -61,12 +61,12 @@ const getLatestRequestsWithProvenanceAsync = async (
return { return {
items, items,
...provenance, ...getIntegrationQueryProvenance(integrations.length, results),
}; };
}; };
const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => { const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( const results = await settleIntegrationQueries(
integrations, integrations,
async (integration) => { async (integration) => {
const { data, isStale } = await mediaRequestStatsRequestHandler const { data, isStale } = await mediaRequestStatsRequestHandler
@@ -92,7 +92,7 @@ const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStat
) )
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration), integrations: results.map((result) => result.integration),
...provenance, ...getIntegrationQueryProvenance(integrations.length, results),
}; };
}; };
+12 -17
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries, settleIntegrationQueriesWithProvenance } from "./settle-integrations"; import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
const integrations = [ const integrations = [
{ id: "first", name: "First", kind: "mock" }, { id: "first", name: "First", kind: "mock" },
@@ -77,23 +77,18 @@ describe("settleIntegrationQueries", () => {
}); });
}); });
describe("settleIntegrationQueriesWithProvenance", () => { describe("getIntegrationQueryProvenance", () => {
it("counts failed queries even when fallbacks preserve the result count", async () => { it("counts dropped and stale integration results independently", () => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
integrations,
vi.fn(async (integration) => {
if (integration.id === "first") throw new Error("offline");
return { integrationId: integration.id, isStale: true };
}),
{
fallback: (integration) => ({ integrationId: integration.id, isStale: false }),
},
);
expect(results).toHaveLength(2);
expect(provenance).toEqual({
failedIntegrationCount: 1, failedIntegrationCount: 1,
staleIntegrationCount: 1, staleIntegrationCount: 2,
});
});
it("does not report failures for complete current results", () => {
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
failedIntegrationCount: 0,
staleIntegrationCount: 0,
}); });
}); });
}); });
+4 -52
View File
@@ -11,24 +11,13 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> { interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult; fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailure?: boolean;
} }
export interface IntegrationQueryProvenance { export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
failedIntegrationCount: number;
staleIntegrationCount: number;
}
interface IntegrationQuerySettlement<TResult> {
results: TResult[];
failedIntegrationCount: number;
}
const settleIntegrationQueriesInternal = async <TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[], integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>, fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>, options?: Options<TIntegration, TResult>,
): Promise<IntegrationQuerySettlement<TResult>> => { ): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: TResult[] = []; const results: TResult[] = [];
const errors: unknown[] = []; const errors: unknown[] = [];
@@ -56,46 +45,9 @@ const settleIntegrationQueriesInternal = async <TIntegration extends Integration
errors.push(result.reason); errors.push(result.reason);
}); });
const firstFailure = settled.find((result) => result.status === "rejected"); if (results.length === 0 && errors.length > 0) {
if (
options?.throwOnAllFailure === true &&
settled.length > 0 &&
settled.every((result) => result.status === "rejected") &&
firstFailure?.status === "rejected"
) {
throw firstFailure.reason;
}
if (options?.throwOnAllFailure !== false && results.length === 0 && errors.length > 0) {
throw errors[0]; throw errors[0];
} }
return { return results;
results,
failedIntegrationCount: settled.filter((result) => result.status === "rejected").length,
};
};
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
return (await settleIntegrationQueriesInternal(integrations, fn, options)).results;
}
export async function settleIntegrationQueriesWithProvenance<
TIntegration extends IntegrationLike,
TResult extends { isStale?: boolean },
>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<IntegrationQuerySettlement<TResult> & IntegrationQueryProvenance> {
const settlement = await settleIntegrationQueriesInternal(integrations, fn, options);
return {
...settlement,
staleIntegrationCount: settlement.results.filter((result) => result.isStale === true).length,
};
} }
-11
View File
@@ -30,17 +30,6 @@ describe("board layout selection", () => {
expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId); expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
}); });
test("legacy mode falls back to the desktop layout before the client viewport is known", () => {
expect(getCurrentLayout(createBoard(), false, undefined)).toBe("large");
});
test("rejects boards without a layout", () => {
const board = { ...createBoard(), layouts: [] };
expect(() => getDesktopLayout(board)).toThrow("Board must have a layout");
expect(() => getCurrentLayout(board, false, 390)).toThrow("Board must have a layout");
});
test("keeps every saved layout available for reversible mutations", () => { test("keeps every saved layout available for reversible mutations", () => {
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]); expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
}); });
-11
View File
@@ -27,15 +27,4 @@ describe("requestBoardEditAction", () => {
void deferredAction?.(); void deferredAction?.();
expect(action).toHaveBeenCalledOnce(); expect(action).toHaveBeenCalledOnce();
}); });
test("reports rejected actions instead of creating an unhandled rejection", async () => {
const error = new Error("sign-out failed");
const reportError = vi.fn();
vi.stubGlobal("reportError", reportError);
requestBoardEditAction(() => Promise.reject(error));
await vi.waitFor(() => expect(reportError).toHaveBeenCalledWith(error));
vi.unstubAllGlobals();
});
}); });
+2 -20
View File
@@ -12,27 +12,9 @@ export type BoardEditActionResult = boolean | void;
export type BoardEditAction = () => BoardEditActionResult | Promise<BoardEditActionResult>; export type BoardEditAction = () => BoardEditActionResult | Promise<BoardEditActionResult>;
export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>; export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>;
const reportBoardEditActionError = (error: unknown) => {
const reportError = (globalThis as { reportError?: (reportedError: unknown) => void }).reportError;
if (reportError) {
reportError(error);
return;
}
console.error("Board edit action failed", error);
};
const runBoardEditAction = (action: BoardEditAction) => {
try {
void Promise.resolve(action()).catch(reportBoardEditActionError);
} catch (error) {
reportBoardEditActionError(error);
}
};
export const requestBoardEditAction = (action: BoardEditAction) => { export const requestBoardEditAction = (action: BoardEditAction) => {
if (typeof document === "undefined") { if (typeof document === "undefined") {
runBoardEditAction(action); void action();
return; return;
} }
@@ -42,7 +24,7 @@ export const requestBoardEditAction = (action: BoardEditAction) => {
}); });
if (document.dispatchEvent(event)) { if (document.dispatchEvent(event)) {
runBoardEditAction(action); void action();
} }
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ import { objectEntries } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { dbEnv } from "@homarr/core/infrastructure/db/env";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver"; import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
import { schema } from "./schema"; import * as schema from "./schema";
type TableKey = { type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never; [K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
-9
View File
@@ -37,15 +37,6 @@ describe("automatic mobile layout upgrade default", () => {
).toBe(false); ).toBe(false);
}); });
test("preserves legacy mode when a board has duplicate canonical layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 0 },
]),
).toBe(false);
});
test.each([ test.each([
{ boardId: "board", name: "Phone", breakpoint: 0 }, { boardId: "board", name: "Phone", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 480 }, { boardId: "board", name: "Base", breakpoint: 480 },
+6 -59
View File
@@ -4,12 +4,8 @@ import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from ".."; import type { Database } from "..";
import { eq, sql } from ".."; import { eq } from "..";
import { isMysql, isPostgresql } from "../collection";
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
import { serverSettings } from "../schema"; import { serverSettings } from "../schema";
import { serverSettings as mysqlServerSettings } from "../schema/mysql";
import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
interface ExistingBoardLayout { interface ExistingBoardLayout {
boardId: string; boardId: string;
@@ -49,7 +45,6 @@ const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
export const getServerSettingsAsync = async (db: Database) => { export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany(); const settings = await db.query.serverSettings.findMany();
let parsedBoardSettings: Partial<ServerSettings["board"]> = {};
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => { const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((candidate) => candidate.settingKey === settingKey); const setting = settings.find((candidate) => candidate.settingKey === settingKey);
@@ -60,9 +55,6 @@ export const getServerSettingsAsync = async (db: Database) => {
} }
const persistedSettings = parse<Record<string, unknown>>(setting.value); const persistedSettings = parse<Record<string, unknown>>(setting.value);
if (settingKey === "board") {
parsedBoardSettings = persistedSettings as Partial<ServerSettings["board"]>;
}
acc[settingKey] = { acc[settingKey] = {
...defaultServerSettings[settingKey], ...defaultServerSettings[settingKey],
...persistedSettings, ...persistedSettings,
@@ -70,6 +62,11 @@ export const getServerSettingsAsync = async (db: Database) => {
return acc; return acc;
}, {} as ServerSettings); }, {} as ServerSettings);
const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board");
const parsedBoardSettings = persistedBoardSettings
? parse<Partial<ServerSettings["board"]>>(persistedBoardSettings.value)
: {};
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
} }
@@ -133,53 +130,3 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
value: stringify(value), value: stringify(value),
}); });
}; };
export const mergeServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
patch: Partial<ServerSettings[TKey]>,
) => {
const serializedValue = stringify(value);
const serializedPatch = stringify(patch);
if (isMysql()) {
await (db as unknown as HomarrDatabaseMysql)
.insert(mysqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onDuplicateKeyUpdate({
set: {
value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`,
},
});
return;
}
if (isPostgresql()) {
await (db as unknown as HomarrDatabasePostgresql)
.insert(postgresqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: postgresqlServerSettings.settingKey,
set: {
value: sql`jsonb_set(
${postgresqlServerSettings.value}::jsonb,
'{json}',
(${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'),
true
)::text`,
},
});
return;
}
await db
.insert(serverSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: serverSettings.settingKey,
set: {
value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`,
},
});
};
+3 -6
View File
@@ -11,9 +11,6 @@ import * as mysqlSchema from "../schema/mysql";
import * as postgresqlSchema from "../schema/postgresql"; import * as postgresqlSchema from "../schema/postgresql";
import * as sqliteSchema from "../schema/sqlite"; import * as sqliteSchema from "../schema/sqlite";
const mysqlTables = { ...mysqlSchema };
const postgresqlTables = { ...postgresqlSchema };
// We need the following three types as there is currently no support for Buffer in mysql & pg and // We need the following three types as there is currently no support for Buffer in mysql & pg and
// so we use a custom type which results in the config beeing different // so we use a custom type which results in the config beeing different
type FixedMysqlConfig = { type FixedMysqlConfig = {
@@ -66,7 +63,7 @@ test("schemas should match", () => {
if (!("uniqueName" in sqliteColumn)) return; if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return; if (!("primary" in sqliteColumn)) return;
const mysqlTable = mysqlTables[tableName]; const mysqlTable = mysqlSchema[tableName];
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object; const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
if (!("isUnique" in mysqlColumn)) return; if (!("isUnique" in mysqlColumn)) return;
@@ -87,7 +84,7 @@ test("schemas should match", () => {
).toEqual(mysqlColumn.primary); ).toEqual(mysqlColumn.primary);
}); });
const mysqlTable = mysqlTables[tableName]; const mysqlTable = mysqlSchema[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[] | SqliteForeignKey[]
| undefined; | undefined;
@@ -147,7 +144,7 @@ test("schemas should match for postgresql", () => {
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => { objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key // keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
// skipcq: JS-E1007 // skipcq: JS-E1007
const postgresqlTable = postgresqlTables[tableName]; const postgresqlTable = postgresqlSchema[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => { Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return; if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return; if (!("uniqueName" in sqliteColumn)) return;
@@ -4,17 +4,6 @@ import { describe, expect, test, vi } from "vitest";
import { createRequestHandler } from "./request-handler"; import { createRequestHandler } from "./request-handler";
const createDeferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
};
describe("createRequestHandler", () => { describe("createRequestHandler", () => {
test("keeps the standard result contract free of cache provenance", async () => { test("keeps the standard result contract free of cache provenance", async () => {
const requestHandler = createRequestHandler({ const requestHandler = createRequestHandler({
@@ -58,47 +47,4 @@ describe("createRequestHandler", () => {
"offline", "offline",
); );
}); });
test("does not let a pre-invalidation request overwrite a newer cached result", async () => {
const first = createDeferred<string>();
const second = createDeferred<string>();
const requestAsync = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const requestHandler = createRequestHandler({ requestAsync });
const handler = requestHandler.handler({ integrationId: "first" });
const oldRequest = handler.getDataWithProvenanceAsync();
requestHandler.invalidateCache();
const newRequest = handler.getDataWithProvenanceAsync();
second.resolve("new");
await expect(newRequest).resolves.toMatchObject({ data: "new" });
first.resolve("old");
await expect(oldRequest).resolves.toMatchObject({ data: "old" });
await expect(handler.getDataWithProvenanceAsync()).resolves.toMatchObject({ data: "new" });
expect(requestAsync).toHaveBeenCalledTimes(2);
});
test("does not let a pre-invalidation request clear a newer inflight request", async () => {
const first = createDeferred<string>();
const second = createDeferred<string>();
const requestAsync = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const requestHandler = createRequestHandler({ requestAsync });
const handler = requestHandler.handler({ integrationId: "first" });
const oldRequest = handler.getDataWithProvenanceAsync();
requestHandler.invalidateCache();
const newRequest = handler.getDataWithProvenanceAsync();
first.resolve("old");
await expect(oldRequest).resolves.toMatchObject({ data: "old" });
const sharedNewRequest = handler.getDataWithProvenanceAsync();
expect(requestAsync).toHaveBeenCalledTimes(2);
second.resolve("new");
await expect(Promise.all([newRequest, sharedNewRequest])).resolves.toEqual([
expect.objectContaining({ data: "new" }),
expect.objectContaining({ data: "new" }),
]);
});
}); });
@@ -22,11 +22,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
) => { ) => {
const cache = new Map<string, CacheEntry<TData>>(); const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<RequestResult<TData>>>(); const inflight = new Map<string, Promise<RequestResult<TData>>>();
let cacheGeneration = 0;
return { return {
invalidateCache: () => { invalidateCache: () => {
cacheGeneration += 1;
cache.clear(); cache.clear();
inflight.clear(); inflight.clear();
}, },
@@ -43,29 +41,21 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
const existing = inflight.get(key); const existing = inflight.get(key);
if (existing) return existing; if (existing) return existing;
const requestGeneration = cacheGeneration; const promise = options
let promise: Promise<RequestResult<TData>>;
const isCurrentRequest = () => requestGeneration === cacheGeneration && inflight.get(key) === promise;
promise = options
.requestAsync(input) .requestAsync(input)
.then((data) => { .then((data) => {
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
if (isCurrentRequest()) { const oldest = cache.keys().next().value;
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache); if (oldest) cache.delete(oldest);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(key, entry);
inflight.delete(key);
} }
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry);
inflight.delete(key);
return { data: entry.data, timestamp: entry.timestamp, isStale: false }; return { data: entry.data, timestamp: entry.timestamp, isStale: false };
}) })
.catch((err) => { .catch((err) => {
if (isCurrentRequest()) inflight.delete(key); inflight.delete(key);
if (options.fallbackToStaleOnError && cached) { if (options.fallbackToStaleOnError && cached) {
return { data: cached.data, timestamp: cached.timestamp, isStale: true }; return { data: cached.data, timestamp: cached.timestamp, isStale: true };
} }
-1
View File
@@ -3295,7 +3295,6 @@
"main": "Media Stats", "main": "Media Stats",
"approved": "Already approved", "approved": "Already approved",
"pending": "Pending approvals", "pending": "Pending approvals",
"pendingCount": "{pending} pending approvals",
"processing": "Being processed", "processing": "Being processed",
"declined": "Already declined", "declined": "Already declined",
"available": "Already Available", "available": "Already Available",
-1
View File
@@ -75,7 +75,6 @@
"@tiptap/starter-kit": "catalog:", "@tiptap/starter-kit": "catalog:",
"clsx": "catalog:", "clsx": "catalog:",
"dayjs": "catalog:", "dayjs": "catalog:",
"isomorphic-dompurify": "catalog:",
"jsonpath-plus": "catalog:", "jsonpath-plus": "catalog:",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"mantine-datatable": "catalog:", "mantine-datatable": "catalog:",
@@ -1,14 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { describe, expect, test } from "vitest";
import { createElement } from "react";
import type { ComponentProps } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vitest";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "./query-state"; import { hasWidgetDataWarning, throwOnInitialQueryError } from "./query-state";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => () => "Some widget data may be stale",
}));
describe("widget query state", () => { describe("widget query state", () => {
test.each([ test.each([
@@ -39,22 +31,4 @@ describe("widget query state", () => {
test("keeps cached data visible after a refresh error", () => { test("keeps cached data visible after a refresh error", () => {
expect(() => throwOnInitialQueryError(new Error("refresh failed"), true)).not.toThrow(); expect(() => throwOnInitialQueryError(new Error("refresh failed"), true)).not.toThrow();
}); });
test("places the warning after widget controls in keyboard focus order", () => {
const markup = renderToStaticMarkup(
createElement(
MantineProvider,
null,
createElement(
WidgetDataState,
{ hasWarning: true } as ComponentProps<typeof WidgetDataState>,
createElement("button", { type: "button" }, "Widget action"),
),
),
);
const document = new DOMParser().parseFromString(markup, "text/html");
const focusableElements = [...document.querySelectorAll("button, [tabindex='0']")];
expect(focusableElements.map((element) => element.tagName)).toStrictEqual(["BUTTON", "OUTPUT"]);
});
}); });
+2 -3
View File
@@ -43,13 +43,11 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps)
return ( return (
<Box pos="relative" h="100%" w="100%"> <Box pos="relative" h="100%" w="100%">
{children}
{hasWarning && ( {hasWarning && (
<Tooltip label={label} position="left" withArrow events={{ hover: true, focus: true, touch: true }}> <Tooltip label={label} position="left" withArrow>
<Center <Center
component="output" component="output"
aria-label={label} aria-label={label}
tabIndex={0}
pos="absolute" pos="absolute"
bottom={4} bottom={4}
w={28} w={28}
@@ -69,6 +67,7 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps)
</Center> </Center>
</Tooltip> </Tooltip>
)} )}
{children}
</Box> </Box>
); );
} }
@@ -24,7 +24,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration"; import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
@@ -60,11 +59,6 @@ export default function MediaMissingWidget({
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId }))); const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0); const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const hasDataWarning = hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: data.length,
});
if (displayMode === "mobileSummary") { if (displayMode === "mobileSummary") {
const primary = options.showMissing const primary = options.showMissing
@@ -77,7 +71,7 @@ export default function MediaMissingWidget({
value={primary.value} value={primary.value}
label={primary.label} label={primary.label}
description={description} description={description}
isStale={hasDataWarning} isStale={Boolean(error)}
/> />
); );
} }
@@ -112,37 +106,35 @@ export default function MediaMissingWidget({
); );
return ( return (
<WidgetDataState hasWarning={hasDataWarning}> <Tabs
<Tabs defaultValue={options.showMissing ? "missing" : "queued"}
defaultValue={options.showMissing ? "missing" : "queued"} h="100%"
h="100%" style={{ display: "flex", flexDirection: "column" }}
style={{ display: "flex", flexDirection: "column" }} >
> <Tabs.List grow>
<Tabs.List grow>
{options.showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{options.showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab>
)}
</Tabs.List>
{options.showMissing && ( {options.showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}> <Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{renderPanel(missing, t("empty.missing"))} {tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Panel> </Tabs.Tab>
)} )}
{options.showQueued && ( {options.showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}> <Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
{renderPanel(queued, t("empty.queued"))} {tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Panel> </Tabs.Tab>
)} )}
</Tabs> </Tabs.List>
</WidgetDataState>
{options.showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{options.showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
)}
</Tabs>
); );
} }
@@ -112,7 +112,7 @@ export default function MediaServerWidget({
<WidgetMobileSummary <WidgetMobileSummary
value={total} value={total}
label={t("titles.stats.total")} label={t("titles.stats.total")}
description={t("titles.stats.pendingCount", { pending })} description={`${pending} ${t("titles.stats.pending")}`}
isStale={hasWarning} isStale={hasWarning}
/> />
); );
+2 -2
View File
@@ -10,7 +10,6 @@ import { useRequiredBoard } from "@homarr/boards/context";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss"; import classes from "./component.module.scss";
import { sanitizeFeedDescription } from "./sanitize-description";
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input); const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input);
@@ -57,11 +56,12 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
</Text> </Text>
{!options.hideDescription && feedEntry.description && ( {!options.hideDescription && feedEntry.description && (
<Text <Text
className={feedEntry.description}
dir={languageDir} dir={languageDir}
c="dimmed" c="dimmed"
size="sm" size="sm"
lineClamp={options.textLinesClamp} lineClamp={options.textLinesClamp}
dangerouslySetInnerHTML={{ __html: sanitizeFeedDescription(feedEntry.description) }} dangerouslySetInnerHTML={{ __html: feedEntry.description }}
/> />
)} )}
@@ -1,12 +0,0 @@
import { describe, expect, test } from "vitest";
import { sanitizeFeedDescription } from "./sanitize-description";
describe("sanitizeFeedDescription", () => {
test("keeps basic formatting while removing executable markup", () => {
const description =
'<p onclick="alert(1)" aria-hidden="true" data-tracking="yes">News <strong>today</strong><script>alert(2)</script><img src="tracker"></p>';
expect(sanitizeFeedDescription(description)).toBe("<p>News <strong>today</strong></p>");
});
});
@@ -1,11 +0,0 @@
import { sanitize } from "isomorphic-dompurify";
const allowedDescriptionTags = ["b", "br", "em", "i", "li", "ol", "p", "s", "strong", "u", "ul"];
export const sanitizeFeedDescription = (description: string) =>
sanitize(description, {
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
ALLOWED_ATTR: [],
ALLOWED_TAGS: allowedDescriptionTags,
});
-3
View File
@@ -2656,9 +2656,6 @@ importers:
dayjs: dayjs:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.11.21 version: 1.11.21
isomorphic-dompurify:
specifier: 'catalog:'
version: 3.18.0(@noble/hashes@2.0.1)
jsonpath-plus: jsonpath-plus:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.4.0 version: 10.4.0