chore: reconcile grid layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-14 11:07:07 +02:00
1 parent 16982aa178
commit 6bfa9c33d0
9 files changed
+54 -166

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# Product
## Register
product
## Users
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work.
@@ -1,86 +0,0 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: { id: "new", name: "" },
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{ title: (translate) => translate("section.category.create.title") },
),
[addCategoryToEnd, openCategoryEditModal, t],
);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal({ boardId: board.id })}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: session?.user.permissions.includes("app-create") ?? false,
})
}
>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -4,6 +4,7 @@ import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
@@ -20,6 +21,7 @@ export default function BoardEditActions() {
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
@@ -28,7 +30,7 @@ const AddMenu = () => {
const t = useI18n();
const handleSelectItem = () => {
openItemSelectModal();
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
@@ -1,67 +0,0 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type { ItemMoveModalProps } from "./item-move-modal";
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
const loadItemMoveModal = () => {
if (itemMoveModalPromise) return itemMoveModalPromise;
const currentPromise = import("./item-move-modal");
itemMoveModalPromise = currentPromise;
void currentPromise.catch(() => {
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
});
return currentPromise;
};
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
interface LazyItemMoveModalContentProps {
actions: { closeModal: () => void };
innerProps: ItemMoveModalProps;
}
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
const { ItemMoveModal } = use(loadItemMoveModal());
const Component = ItemMoveModal.component;
return <Component {...props} />;
};
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
itemMoveModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyItemMoveModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle(t) {
return t("item.moveResize.title");
},
size: modalSizeForm,
});
@@ -5,8 +5,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { createPortal } from "react-dom";
import type { RouterOutputs } from "@homarr/api";
import { useIntegrations } from "@homarr/auth/client";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
@@ -33,7 +35,11 @@ const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(n
*/
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const integrations = useIntegrations();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
@@ -12,8 +12,18 @@ const mocks = vi.hoisted(() => ({
},
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
integration: {
all: {
useQuery: () => ({ data: undefined }),
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useIntegrations: () => undefined,
useSession: () => ({ data: { user: { id: "user" } } }),
}));
vi.mock("@homarr/boards/context", () => ({
@@ -21,6 +31,10 @@ vi.mock("@homarr/boards/context", () => ({
useRequiredBoard: () => mocks.board,
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [true],
}));
describe("board grid portal host", () => {
let container: HTMLDivElement;
let root: Root;
@@ -1,13 +1,14 @@
import { useCallback, useMemo } from "react";
import { use, useCallback, useMemo } from "react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "@homarr/widgets/definition";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { Item } from "~/app/[locale]/boards/_types";
import { filterByItemKind } from "./filter-by-item-kind";
interface SectionItemLayout {
layoutId: string;
@@ -79,10 +80,27 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
const settings = useSettings();
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const appDefinition = use(loadWidgetDefinition("app"));
const appIds = useMemo(() => {
const items: Item[] = getSectionItemsForLayout(board, sectionId, currentLayoutId);
return Array.from(new Set(filterByItemKind(items, settings, "app").map((item) => item.options.appId)));
}, [board, currentLayoutId, sectionId, settings]);
return Array.from(
new Set(
items
.filter((item) => item.kind === "app")
.map(
(item) =>
(
reduceWidgetOptionsWithDefinition(
appDefinition,
settings,
item.options,
) as WidgetComponentProps<"app">["options"]
).appId,
)
.filter((appId): appId is string => typeof appId === "string"),
),
);
}, [appDefinition, board, currentLayoutId, sectionId, settings]);
const { data: apps = [], isLoading } = clientApi.app.byIds.useQuery(appIds, {
enabled: enabled && appIds.length > 0,
staleTime: 30_000,
+1 -4
View File
@@ -5,10 +5,7 @@ interface ResponsiveLayout {
breakpoint: number;
}
export const getLayoutIdForViewportWidth = (
layouts: readonly ResponsiveLayout[],
viewportWidth: number,
): string => {
export const getLayoutIdForViewportWidth = (layouts: readonly ResponsiveLayout[], viewportWidth: number): string => {
if (layouts.length === 0) {
throw new Error("Expected the board to contain a layout");
}
@@ -2347,7 +2347,7 @@
}
},
"uniqueConstraints": {},
"checkConstraints": {}
"checkConstraint": {}
}
},
"views": {},