Compare commits

...
Author SHA1 Message Date
ajnartandCopilot 5d9bfa3978 fix(boards): open tabs before async fetch in bulk action
Pre-open blank tabs synchronously to preserve browser user-activation, then navigate those tab handles after app URLs are fetched. Also close surplus tabs and update tests for the new flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 10:05:57 +02:00
hirrrooo b97856dcb8 docs: mention board-wide open all apps action 2026-06-29 07:46:11 +00:00
hirrrooo d6e22a6150 refactor(boards): extract shared openAppsInNewTabs helper 2026-06-29 07:26:27 +00:00
hirrrooo dceb3ebee7 fix(boards): prevent opener access for opened apps 2026-06-29 07:22:17 +00:00
hirrrooo 7c3b42f3fd fix(boards): de-duplicate open all app IDs 2026-06-29 07:02:56 +00:00
hirrrooo 224648a708 feat(boards): add board-wide open all apps in new tabs action 2026-06-29 06:47:34 +00:00
5 changed files with 153 additions and 18 deletions

No files matched your search

@@ -316,6 +316,7 @@ You can drag and drop items in to this category in the edit mode.
Categories will have a big space before and after them in the edit mode to
enable you to drag and drop items outside the category.
You can also collapse or open categories in edit mode to facilitate your editing process.
Use the category menu to open every app in that category in new tabs, or use the board header action to open every app on the current board layout in new tabs.
:::note
@@ -9,6 +9,7 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconExternalLink,
IconLayoutBoard,
IconPencil,
IconPencilOff,
@@ -21,8 +22,11 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
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";
import { hotkeys } from "@homarr/definitions";
@@ -46,9 +50,34 @@ export const BoardContentHeaderActions = () => {
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
const settings = useSettings();
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)),
settings,
"app",
).map((item) => {
return item.options.appId;
});
await openAppsInNewTabs(appIds, { t, openConfirmModal });
}, [board, t, openConfirmModal, settings]);
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
return (
<>
{!isLoading && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
<SelectBoardsMenu />
</>
);
}
return (
@@ -57,6 +86,12 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu demoMode={demoMode ?? false} />
{!isEditMode && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
@@ -0,0 +1,48 @@
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<AppWithHref[]>;
}
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 openedWindows = distinctAppIds.map(() => window.open("", "_blank", "noopener,noreferrer"));
for (const openedWindow of openedWindows) {
if (!openedWindow) continue;
openedWindow.opener = null;
}
const apps = await fetchAppsByIds(distinctAppIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
const tabsToClose = openedWindows.slice(appsWithUrls.length).filter((openedWindow): openedWindow is Window => Boolean(openedWindow));
for (const tabToClose of tabsToClose) {
tabToClose.close();
}
for (const [index, app] of appsWithUrls.entries()) {
const openedWindow = openedWindows[index];
if (openedWindow) {
openedWindow.location.href = app.href;
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
};
@@ -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 {
@@ -0,0 +1,65 @@
import { describe, expect, test, vi } from "vitest";
import { openAppsInNewTabs } from "../open-apps-in-new-tabs";
describe("openAppsInNewTabs", () => {
test("opens tabs synchronously and navigates them to fetched URLs", async () => {
const openedWindowOne = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const openedWindowTwo = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const openedWindowThree = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const open = vi.spyOn(window, "open").mockReturnValueOnce(openedWindowOne).mockReturnValueOnce(openedWindowTwo).mockReturnValueOnce(openedWindowThree);
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(3);
expect(open).toHaveBeenNthCalledWith(1, "", "_blank", "noopener,noreferrer");
expect(open).toHaveBeenNthCalledWith(2, "", "_blank", "noopener,noreferrer");
expect(open).toHaveBeenNthCalledWith(3, "", "_blank", "noopener,noreferrer");
expect(openedWindowOne.location.href).toBe("https://example.com/one");
expect(openedWindowTwo.location.href).toBe("https://example.com/two");
expect(openedWindowThree.close).toHaveBeenCalledTimes(1);
expect(openConfirmModal).not.toHaveBeenCalled();
});
test("de-duplicates app IDs before fetching", async () => {
const openedWindow = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const open = vi.spyOn(window, "open").mockReturnValue(openedWindow);
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",
});
});
});