From d6e22a6150a1fabb128c224ddd847b25a3722b92 Mon Sep 17 00:00:00 2001 From: hirrrooo <99264185+hirrrooo@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:26:27 +0000 Subject: [PATCH] refactor(boards): extract shared openAppsInNewTabs helper --- .../boards/(content)/_header-actions.tsx | 38 ++++--------- .../components/board/open-apps-in-new-tabs.ts | 38 +++++++++++++ .../category/category-menu-actions.tsx | 18 +----- .../board/test/open-apps-in-new-tabs.spec.ts | 57 +++++++++++++++++++ 4 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 apps/nextjs/src/components/board/open-apps-in-new-tabs.ts create mode 100644 apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 7c12db865..74f2a2d3a 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -20,11 +20,12 @@ import { IconSettings, } from "@tabler/icons-react"; -import { clientApi, fetchApi } from "@homarr/api/client"; +import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; +import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs"; import { filterByItemKind } from "~/components/board/sections/category/filter"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; @@ -55,34 +56,15 @@ export const BoardContentHeaderActions = () => { const openAllInNewTabs = useCallback(async () => { const currentLayoutId = getCurrentLayout(board); - const appIds = [ - ...new Set( - filterByItemKind( - board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)), - settings, - "app", - ).map((item) => { - return item.options.appId; - }), - ), - ]; + const appIds = filterByItemKind( + board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)), + settings, + "app", + ).map((item) => { + return item.options.appId; + }); - const apps = await fetchApi.app.byIds.query(appIds); - const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); - - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined, "_blank", "noopener,noreferrer"); - if (openedWindow) { - openedWindow.opener = null; - continue; - } - - openConfirmModal({ - title: t("section.category.openAllInNewTabs.title"), - children: t("section.category.openAllInNewTabs.text"), - }); - break; - } + await openAppsInNewTabs(appIds, { t, openConfirmModal }); }, [board, t, openConfirmModal, settings]); if (!hasChangeAccess || isLoading) { diff --git a/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts b/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts new file mode 100644 index 000000000..9e0614607 --- /dev/null +++ b/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts @@ -0,0 +1,38 @@ +import { fetchApi } from "@homarr/api/client"; + +type AppWithHref = { + href: string | null; +}; + +type TranslationKey = "section.category.openAllInNewTabs.title" | "section.category.openAllInNewTabs.text"; + +interface OpenAppsInNewTabsOptions { + t: (key: TranslationKey) => string; + openConfirmModal: (options: { title: string; children: string }) => void; + fetchAppsByIds?: (appIds: string[]) => Promise; +} + +export const openAppsInNewTabs = async ( + appIds: string[], + { t, openConfirmModal, fetchAppsByIds = fetchApi.app.byIds.query }: OpenAppsInNewTabsOptions, +) => { + if (appIds.length === 0) return; + + const distinctAppIds = [...new Set(appIds)]; + const apps = await fetchAppsByIds(distinctAppIds); + const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); + + for (const app of appsWithUrls) { + const openedWindow = window.open(app.href ?? undefined, "_blank", "noopener,noreferrer"); + if (openedWindow) { + openedWindow.opener = null; + continue; + } + + openConfirmModal({ + title: t("section.category.openAllInNewTabs.title"), + children: t("section.category.openAllInNewTabs.text"), + }); + break; + } +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..09bf91c53 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -1,6 +1,5 @@ import { useCallback } from "react"; -import { fetchApi } from "@homarr/api/client"; import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; @@ -8,6 +7,7 @@ import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { CategorySection } from "~/app/[locale]/boards/_types"; +import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs"; import { useCategoryActions } from "./category-actions"; import { CategoryEditModal } from "./category-edit-modal"; import { filterByItemKind } from "./filter"; @@ -115,21 +115,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { return item.options.appId; }); - const apps = await fetchApi.app.byIds.query(appIds); - const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); - - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined); - if (openedWindow) { - continue; - } - - openConfirmModal({ - title: t("section.category.openAllInNewTabs.title"), - children: t("section.category.openAllInNewTabs.text"), - }); - break; - } + await openAppsInNewTabs(appIds, { t, openConfirmModal }); }, [category, board, t, openConfirmModal, settings]); return { diff --git a/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts b/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts new file mode 100644 index 000000000..989f85abf --- /dev/null +++ b/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from "vitest"; + +import { openAppsInNewTabs } from "../open-apps-in-new-tabs"; + +describe("openAppsInNewTabs", () => { + test("opens fetched apps with URLs in new tabs with noopener", async () => { + const open = vi.spyOn(window, "open").mockReturnValue({ opener: null } as unknown as Window); + const fetchAppsByIds = vi + .fn() + .mockResolvedValue([{ href: "https://example.com/one" }, { href: null }, { href: "https://example.com/two" }]); + const openConfirmModal = vi.fn(); + + await openAppsInNewTabs(["app-one", "app-two", "app-three"], { + t: (key) => key, + openConfirmModal, + fetchAppsByIds, + }); + + expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one", "app-two", "app-three"]); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenNthCalledWith(1, "https://example.com/one", "_blank", "noopener,noreferrer"); + expect(open).toHaveBeenNthCalledWith(2, "https://example.com/two", "_blank", "noopener,noreferrer"); + expect(openConfirmModal).not.toHaveBeenCalled(); + }); + + test("de-duplicates app IDs before fetching", async () => { + const open = vi.spyOn(window, "open").mockReturnValue({ opener: null } as unknown as Window); + const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]); + const openConfirmModal = vi.fn(); + + await openAppsInNewTabs(["app-one", "app-one", "app-one"], { + t: (key) => key, + openConfirmModal, + fetchAppsByIds, + }); + + expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one"]); + expect(open).toHaveBeenCalledTimes(1); + }); + + test("shows confirm modal when popup is blocked", async () => { + vi.spyOn(window, "open").mockReturnValue(null); + const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]); + const openConfirmModal = vi.fn(); + + await openAppsInNewTabs(["app-one"], { + t: (key) => key, + openConfirmModal, + fetchAppsByIds, + }); + + expect(openConfirmModal).toHaveBeenCalledWith({ + title: "section.category.openAllInNewTabs.title", + children: "section.category.openAllInNewTabs.text", + }); + }); +});