Revert "fix: harden automatic mobile boards"
This reverts commit d0b9348b86.
This commit is contained in:
1 parent
967ed8dd73
commit
e93293f8a9
22 files changed
+69
-491
No files matched your search
@@ -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();
|
||||
};
|
||||
@@ -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", () => {
|
||||
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
|
||||
width: 2,
|
||||
|
||||
@@ -8,7 +8,6 @@ export const resolveMobileItemPresentation = (
|
||||
const supportsCompactSummary = mobile?.supportsCompactSummary === true;
|
||||
const supportsDetails = mobile?.supportsDetailView === true;
|
||||
const usesGenericSummary = supportsDetails && !supportsCompactSummary;
|
||||
const eager = mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks");
|
||||
|
||||
return {
|
||||
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
|
||||
@@ -16,8 +15,8 @@ export const resolveMobileItemPresentation = (
|
||||
displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const),
|
||||
supportsDetails,
|
||||
usesGenericSummary,
|
||||
eager,
|
||||
unmountWhenOffscreen: eager ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("resolveMobileBoardViewport", () => {
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "phone",
|
||||
matchesMobileWidth: false,
|
||||
hasMounted: true,
|
||||
hasResolvedClientWidth: true,
|
||||
}),
|
||||
).toEqual({
|
||||
deviceClass: "phone",
|
||||
@@ -29,7 +29,7 @@ describe("resolveMobileBoardViewport", () => {
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "tablet",
|
||||
matchesMobileWidth,
|
||||
hasMounted: true,
|
||||
hasResolvedClientWidth: true,
|
||||
}).isMobile,
|
||||
).toBe(expectedIsMobile);
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe("resolveMobileBoardViewport", () => {
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "tablet",
|
||||
matchesMobileWidth: false,
|
||||
hasMounted: false,
|
||||
hasResolvedClientWidth: false,
|
||||
}).isResolved,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe("resolveMobileBoardViewport", () => {
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "desktop",
|
||||
matchesMobileWidth,
|
||||
hasMounted: true,
|
||||
hasResolvedClientWidth: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
isMobile: expectedIsMobile,
|
||||
@@ -73,9 +73,7 @@ describe("resolveIsAutomaticMobileBoard", () => {
|
||||
expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("shouldShowMobileBoardViewportSkeleton", () => {
|
||||
test("does not show the tablet resolution skeleton in legacy mode", () => {
|
||||
expect(
|
||||
shouldShowMobileBoardViewportSkeleton({
|
||||
@@ -84,13 +82,4 @@ describe("shouldShowMobileBoardViewportSkeleton", () => {
|
||||
}),
|
||||
).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 = ({
|
||||
deviceClass,
|
||||
matchesMobileWidth,
|
||||
hasMounted,
|
||||
hasResolvedClientWidth,
|
||||
}: {
|
||||
deviceClass: MobileBoardDeviceClass;
|
||||
matchesMobileWidth: boolean;
|
||||
hasMounted: boolean;
|
||||
hasResolvedClientWidth: boolean;
|
||||
}): MobileBoardViewport => ({
|
||||
deviceClass,
|
||||
isMobile: deviceClass === "phone" || matchesMobileWidth,
|
||||
isResolved: deviceClass !== "tablet" || hasMounted,
|
||||
isResolved: deviceClass !== "tablet" || hasResolvedClientWidth,
|
||||
});
|
||||
|
||||
const MobileBoardViewportSkeleton = () => (
|
||||
@@ -73,19 +73,19 @@ export const MobileBoardViewportProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
|
||||
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
|
||||
getInitialValueInEffect: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
setHasResolvedClientWidth(true);
|
||||
}, []);
|
||||
|
||||
const viewport = resolveMobileBoardViewport({
|
||||
deviceClass: initialDeviceClass,
|
||||
matchesMobileWidth,
|
||||
hasMounted,
|
||||
hasResolvedClientWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -161,40 +161,15 @@ describe("Automatic mobile board", () => {
|
||||
await setAutomaticMobileLayoutAsync(loginPage, true);
|
||||
const storageState = await loginContext.storageState();
|
||||
|
||||
const iPhoneUserAgent = devices["iPhone 13"].userAgent;
|
||||
const mobileViewports: {
|
||||
name: string;
|
||||
width: number;
|
||||
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 },
|
||||
const mobileViewports = [
|
||||
{ name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{ name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{ name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{
|
||||
name: "phone landscape",
|
||||
width: 844,
|
||||
height: 390,
|
||||
userAgent: iPhoneUserAgent,
|
||||
userAgent: devices["iPhone 13"].userAgent,
|
||||
phone: true,
|
||||
},
|
||||
{
|
||||
@@ -213,7 +188,7 @@ describe("Automatic mobile board", () => {
|
||||
userAgent: viewport.userAgent,
|
||||
hasTouch: true,
|
||||
isMobile: viewport.phone,
|
||||
reducedMotion: viewport.reducedMotion ? "reduce" : "no-preference",
|
||||
reducedMotion: viewport.width === 390 ? "reduce" : "no-preference",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${baseUrl}/boards/${boardName}`);
|
||||
@@ -259,7 +234,7 @@ describe("Automatic mobile board", () => {
|
||||
expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0);
|
||||
expect(layoutMetrics.layoutControls, viewport.name).toBe(0);
|
||||
|
||||
if (viewport.checksZoomOverflow) {
|
||||
if (viewport.width === 320) {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = "200%";
|
||||
});
|
||||
@@ -273,7 +248,7 @@ describe("Automatic mobile board", () => {
|
||||
expect(zoomOverflow.items).toBe(0);
|
||||
}
|
||||
|
||||
if (viewport.checksInteractions) {
|
||||
if (viewport.width === 390) {
|
||||
await page.getByRole("button", { name: `Current board: ${boardName}` }).click();
|
||||
await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
getServerSettingsAsync,
|
||||
mergeServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} 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 { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
@@ -82,9 +82,17 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
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
|
||||
.requiresPermission("admin")
|
||||
@@ -100,7 +108,15 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
...current,
|
||||
...input.value,
|
||||
} 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
|
||||
.requiresStep("settings")
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
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 { 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", () => {
|
||||
@@ -164,12 +137,6 @@ describe("board settings API", () => {
|
||||
deviceType: undefined,
|
||||
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 = {
|
||||
homeBoardId: defaultServerSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
|
||||
@@ -181,10 +148,7 @@ describe("board settings API", () => {
|
||||
value: stringify(legacyBoardSettings),
|
||||
});
|
||||
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual({
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: false,
|
||||
});
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries, settleIntegrationQueriesWithProvenance } from "./settle-integrations";
|
||||
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "mock" },
|
||||
@@ -77,23 +77,18 @@ describe("settleIntegrationQueries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleIntegrationQueriesWithProvenance", () => {
|
||||
it("counts failed queries even when fallbacks preserve the result count", async () => {
|
||||
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance(
|
||||
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({
|
||||
describe("getIntegrationQueryProvenance", () => {
|
||||
it("counts dropped and stale integration results independently", () => {
|
||||
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,21 +20,11 @@ interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
throwOnAllFailures?: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationQueryProvenance {
|
||||
failedIntegrationCount: number;
|
||||
staleIntegrationCount: number;
|
||||
}
|
||||
|
||||
interface IntegrationQuerySettlement<TResult> {
|
||||
results: TResult[];
|
||||
failedIntegrationCount: number;
|
||||
}
|
||||
|
||||
const settleIntegrationQueriesInternal = async <TIntegration extends IntegrationLike, TResult>(
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<IntegrationQuerySettlement<TResult>> => {
|
||||
): Promise<TResult[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
const results: TResult[] = [];
|
||||
const errors: unknown[] = [];
|
||||
@@ -73,32 +63,5 @@ const settleIntegrationQueriesInternal = async <TIntegration extends Integration
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
};
|
||||
return results;
|
||||
}
|
||||
@@ -30,17 +30,6 @@ describe("board layout selection", () => {
|
||||
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", () => {
|
||||
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
|
||||
});
|
||||
|
||||
@@ -27,15 +27,4 @@ describe("requestBoardEditAction", () => {
|
||||
void deferredAction?.();
|
||||
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 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) => {
|
||||
if (typeof document === "undefined") {
|
||||
runBoardEditAction(action);
|
||||
void action();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +24,7 @@ export const requestBoardEditAction = (action: BoardEditAction) => {
|
||||
});
|
||||
|
||||
if (document.dispatchEvent(event)) {
|
||||
runBoardEditAction(action);
|
||||
void action();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
|
||||
import { schema } from "./schema";
|
||||
import * as schema from "./schema";
|
||||
|
||||
type TableKey = {
|
||||
[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);
|
||||
});
|
||||
|
||||
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([
|
||||
{ boardId: "board", name: "Phone", breakpoint: 0 },
|
||||
{ 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 type { Database } from "..";
|
||||
import { eq, sql } from "..";
|
||||
import { isMysql, isPostgresql } from "../collection";
|
||||
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
|
||||
import { eq } from "..";
|
||||
import { serverSettings } from "../schema";
|
||||
import { serverSettings as mysqlServerSettings } from "../schema/mysql";
|
||||
import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
|
||||
|
||||
interface ExistingBoardLayout {
|
||||
boardId: string;
|
||||
@@ -49,7 +45,6 @@ const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
|
||||
|
||||
export const getServerSettingsAsync = async (db: Database) => {
|
||||
const settings = await db.query.serverSettings.findMany();
|
||||
let parsedBoardSettings: Partial<ServerSettings["board"]> = {};
|
||||
|
||||
const mergedSettings = defaultServerSettingsKeys.reduce((acc, 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);
|
||||
if (settingKey === "board") {
|
||||
parsedBoardSettings = persistedSettings as Partial<ServerSettings["board"]>;
|
||||
}
|
||||
acc[settingKey] = {
|
||||
...defaultServerSettings[settingKey],
|
||||
...persistedSettings,
|
||||
@@ -70,6 +62,11 @@ export const getServerSettingsAsync = async (db: Database) => {
|
||||
return acc;
|
||||
}, {} as ServerSettings);
|
||||
|
||||
const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board");
|
||||
const parsedBoardSettings = persistedBoardSettings
|
||||
? parse<Partial<ServerSettings["board"]>>(persistedBoardSettings.value)
|
||||
: {};
|
||||
|
||||
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
|
||||
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
|
||||
}
|
||||
@@ -133,53 +130,3 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
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})`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -31,17 +31,6 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("deduplicates in-flight work and caches completed data", async () => {
|
||||
let calls = 0;
|
||||
@@ -232,47 +221,4 @@ describe("createWidgetRequestHandler", () => {
|
||||
await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4031,7 +4031,6 @@
|
||||
"main": "Media Stats",
|
||||
"approved": "Already approved",
|
||||
"pending": "Pending approvals",
|
||||
"pendingCount": "{pending} pending approvals",
|
||||
"processing": "Being processed",
|
||||
"declined": "Already declined",
|
||||
"available": "Already Available",
|
||||
|
||||
@@ -43,13 +43,11 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps)
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100%" w="100%">
|
||||
{children}
|
||||
{hasWarning && (
|
||||
<Tooltip label={label} position="left" withArrow events={{ hover: true, focus: true, touch: true }}>
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<Center
|
||||
component="output"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
pos="absolute"
|
||||
bottom={4}
|
||||
w={28}
|
||||
@@ -69,6 +67,7 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps)
|
||||
</Center>
|
||||
</Tooltip>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user