Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9bfa3978 | ||
|
|
b97856dcb8 | ||
|
|
d6e22a6150 | ||
|
|
dceb3ebee7 | ||
|
|
7c3b42f3fd | ||
|
|
224648a708 |
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
|
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.
|
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.
|
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
|
:::note
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconBox,
|
IconBox,
|
||||||
IconBoxAlignTop,
|
IconBoxAlignTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconExternalLink,
|
||||||
IconLayoutBoard,
|
IconLayoutBoard,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
@@ -21,8 +22,11 @@ import {
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/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 { 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 { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
@@ -46,9 +50,34 @@ export const BoardContentHeaderActions = () => {
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
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) {
|
if (!hasChangeAccess || isLoading) {
|
||||||
return <SelectBoardsMenu />;
|
return (
|
||||||
|
<>
|
||||||
|
{!isLoading && (
|
||||||
|
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
|
||||||
|
<IconExternalLink stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
)}
|
||||||
|
<SelectBoardsMenu />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +86,12 @@ export const BoardContentHeaderActions = () => {
|
|||||||
|
|
||||||
<EditModeMenu demoMode={demoMode ?? false} />
|
<EditModeMenu demoMode={demoMode ?? false} />
|
||||||
|
|
||||||
|
{!isEditMode && (
|
||||||
|
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
|
||||||
|
<IconExternalLink stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{!demoMode && (
|
{!demoMode && (
|
||||||
<OnboardingTour.Target id="board-settings">
|
<OnboardingTour.Target id="board-settings">
|
||||||
<HeaderButton href={`/boards/${board.name}/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 { useCallback } from "react";
|
||||||
|
|
||||||
import { fetchApi } from "@homarr/api/client";
|
|
||||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
@@ -8,6 +7,7 @@ import { useSettings } from "@homarr/settings";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||||
|
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
|
||||||
import { useCategoryActions } from "./category-actions";
|
import { useCategoryActions } from "./category-actions";
|
||||||
import { CategoryEditModal } from "./category-edit-modal";
|
import { CategoryEditModal } from "./category-edit-modal";
|
||||||
import { filterByItemKind } from "./filter";
|
import { filterByItemKind } from "./filter";
|
||||||
@@ -115,21 +115,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
return item.options.appId;
|
return item.options.appId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const apps = await fetchApi.app.byIds.query(appIds);
|
await openAppsInNewTabs(appIds, { t, openConfirmModal });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}, [category, board, t, openConfirmModal, settings]);
|
}, [category, board, t, openConfirmModal, settings]);
|
||||||
|
|
||||||
return {
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user