diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts deleted file mode 100644 index d96d80f09..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts deleted file mode 100644 index 29677f8e1..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts +++ /dev/null @@ -1,58 +0,0 @@ -const getHistoryState = () => - window.history.state && typeof window.history.state === "object" - ? (window.history.state as Record) - : {}; - -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(); -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index 0dcdeb4f9..45e59a506 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -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, diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index a88e7eec2..08125d5bf 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -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), }; }; diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts index 026f9767d..df8827ac5 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -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); - }); }); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index cce597c61..1972206f2 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -26,15 +26,15 @@ const MobileBoardViewportContext = createContext({ 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 ( diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 879a3f595..6c929b16e 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -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"); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 2a603c757..dc117fc0c 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -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") diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index df1e917cf..d3d43b213 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -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({ diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts index 34bdf43a9..efddcf136 100644 --- a/packages/api/src/settle-integrations.spec.ts +++ b/packages/api/src/settle-integrations.spec.ts @@ -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, }); }); }); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index d64e866ef..c095c7715 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -20,21 +20,11 @@ interface Options { throwOnAllFailures?: boolean; } -export interface IntegrationQueryProvenance { - failedIntegrationCount: number; - staleIntegrationCount: number; -} - -interface IntegrationQuerySettlement { - results: TResult[]; - failedIntegrationCount: number; -} - -const settleIntegrationQueriesInternal = async ( +export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, options?: Options, -): Promise> => { +): Promise { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); const results: TResult[] = []; const errors: unknown[] = []; @@ -73,32 +63,5 @@ const settleIntegrationQueriesInternal = async result.status === "rejected").length, - }; -}; - -export async function settleIntegrationQueries( - integrations: TIntegration[], - fn: (integration: TIntegration) => Promise, - options?: Options, -): Promise { - return (await settleIntegrationQueriesInternal(integrations, fn, options)).results; -} - -export async function settleIntegrationQueriesWithProvenance< - TIntegration extends IntegrationLike, - TResult extends { isStale?: boolean }, ->( - integrations: TIntegration[], - fn: (integration: TIntegration) => Promise, - options?: Options, -): Promise & IntegrationQueryProvenance> { - const settlement = await settleIntegrationQueriesInternal(integrations, fn, options); - - return { - ...settlement, - staleIntegrationCount: settlement.results.filter((result) => result.isStale === true).length, - }; + return results; } diff --git a/packages/boards/src/context.spec.ts b/packages/boards/src/context.spec.ts index 64a64732e..a4ebfcb25 100644 --- a/packages/boards/src/context.spec.ts +++ b/packages/boards/src/context.spec.ts @@ -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"]); }); diff --git a/packages/boards/src/edit-mode.spec.ts b/packages/boards/src/edit-mode.spec.ts index 8650a3134..25bf237f8 100644 --- a/packages/boards/src/edit-mode.spec.ts +++ b/packages/boards/src/edit-mode.spec.ts @@ -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(); - }); }); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 857427b38..ce5395b30 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -12,27 +12,9 @@ export type BoardEditActionResult = boolean | void; export type BoardEditAction = () => BoardEditActionResult | Promise; 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(); } }; diff --git a/packages/db/collection.ts b/packages/db/collection.ts index 9839dbf06..b70418759 100644 --- a/packages/db/collection.ts +++ b/packages/db/collection.ts @@ -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; diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts index 5346026c0..b9139ace1 100644 --- a/packages/db/migrations/seed.spec.ts +++ b/packages/db/migrations/seed.spec.ts @@ -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 }, diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 18f967582..679db8752 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -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 = {}; 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>(setting.value); - if (settingKey === "board") { - parsedBoardSettings = persistedSettings as Partial; - } 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>(persistedBoardSettings.value) + : {}; + if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); } @@ -133,53 +130,3 @@ export const insertServerSettingByKeyAsync = async ( - db: Database, - key: TKey, - value: ServerSettings[TKey], - patch: Partial, -) => { - 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})`, - }, - }); -}; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index bf2294d6a..85ee34339 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -31,17 +31,6 @@ afterEach(() => { vi.useRealTimers(); }); -const createDeferred = () => { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((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(); - const second = createDeferred(); - 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(); - const second = createDeferred(); - 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" }), - ]); - }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f2e68200f..a4cd8c729 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/common/query-state.tsx b/packages/widgets/src/common/query-state.tsx index 250ab48be..2347a8b57 100644 --- a/packages/widgets/src/common/query-state.tsx +++ b/packages/widgets/src/common/query-state.tsx @@ -43,13 +43,11 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) return ( - {children} {hasWarning && ( - +
)} + {children} ); } diff --git a/packages/widgets/src/rssFeed/sanitize-description.spec.ts b/packages/widgets/src/rssFeed/sanitize-description.spec.ts deleted file mode 100644 index 1f3439274..000000000 --- a/packages/widgets/src/rssFeed/sanitize-description.spec.ts +++ /dev/null @@ -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 = - ''; - - expect(sanitizeFeedDescription(description)).toBe("

News today

"); - }); -}); diff --git a/packages/widgets/src/rssFeed/sanitize-description.ts b/packages/widgets/src/rssFeed/sanitize-description.ts deleted file mode 100644 index 811d5f2b0..000000000 --- a/packages/widgets/src/rssFeed/sanitize-description.ts +++ /dev/null @@ -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, - });