Revert "fix: harden automatic mobile boards"
This reverts commit d0b9348b86.
This commit is contained in:
1 parent
92a2be2b65
commit
dba9a250ff
37 files changed
+137
-637
No files matched your search
@@ -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.
|
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -26,5 +26,5 @@
|
|||||||
"**/*.md",
|
"**/*.md",
|
||||||
"**/*.mdx",
|
"**/*.mdx",
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "build", ".docusaurus"],
|
"exclude": ["node_modules"],
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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})`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
Generated
-3
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user