fix: refine dashboard interactions and widget options (#6597)
* fix: refine dashboard item interactions * fix board interactions and widget options * feat: improve bookmark selection * fix: address dashboard review feedback
This commit is contained in:
1 parent
b491d45fdc
commit
4505ce96c9
37 files changed
+824
-503
No files matched your search
@@ -13,8 +13,9 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
||||
|
||||
|
||||
@@ -47,9 +47,15 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select of all your apps and order them as you like.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
defaultValue: "No bookmarks selected",
|
||||
},
|
||||
{
|
||||
name: "Custom links",
|
||||
description: "URLs to display without first creating an App.",
|
||||
values: "List of URLs.",
|
||||
defaultValue: "No custom links",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
preloadEntriesOnStart: false,
|
||||
preloadEntriesOnStart: true,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ClientBoard = () => {
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
@@ -74,7 +74,7 @@ export const ClientBoard = () => {
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
@@ -36,7 +37,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
@@ -95,16 +95,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
);
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||
<Menu.Target>
|
||||
@@ -123,7 +113,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
@@ -149,10 +138,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tItem("action.remove")}
|
||||
confirmationLabel={tItem("remove.message")}
|
||||
onConfirm={() => removeItem({ itemId: item.id })}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -75,14 +75,14 @@ export const ScaledBoardCanvas = ({
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
width: logicalWidth,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
interface BoardRemoveConfirmationMenuItemProps {
|
||||
label: ReactNode;
|
||||
confirmationLabel: ReactNode;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const BoardRemoveConfirmationMenuItem = ({
|
||||
label,
|
||||
confirmationLabel,
|
||||
onConfirm,
|
||||
}: BoardRemoveConfirmationMenuItemProps) => {
|
||||
const [isConfirmationRequired, setIsConfirmationRequired] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
c="red.6"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
closeMenuOnClick={isConfirmationRequired}
|
||||
onBlur={() => setIsConfirmationRequired(false)}
|
||||
onClick={() => {
|
||||
if (isConfirmationRequired) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationRequired(true);
|
||||
}}
|
||||
>
|
||||
{isConfirmationRequired ? confirmationLabel : label}
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelLeft = 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
@@ -101,22 +101,6 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -162,6 +146,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
id={contentId}
|
||||
className={classes.containerBody}
|
||||
h="100%"
|
||||
data-board-container-body
|
||||
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
aria-hidden={isVisuallyCollapsed}
|
||||
inert={isVisuallyCollapsed}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
const menuLeftOffset = parentSection.kind === "container" ? 36 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
@@ -33,14 +32,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
@@ -49,8 +40,8 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
right={menuRightOffset}
|
||||
top={4}
|
||||
left={menuLeftOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
@@ -58,7 +49,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
@@ -69,10 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tContainer("action.remove")}
|
||||
confirmationLabel={tContainer("remove.message")}
|
||||
onConfirm={() => removeContainer({ id: section.id })}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ interface TestPlacement {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: "container" | "item";
|
||||
type: "section" | "item";
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ const placement = (
|
||||
y: number,
|
||||
w = 1,
|
||||
h = 1,
|
||||
kind: TestPlacement["kind"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
|
||||
type: TestPlacement["type"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, type, marker: `metadata:${id}` });
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
@@ -302,6 +302,55 @@ describe("same-grid movement", () => {
|
||||
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
|
||||
});
|
||||
|
||||
test("keeps a container fixed when an item is moved onto it", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0), placement("container", 1, 0, 2, 2, "section")]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test("lets an active container push an item", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "section"), placement("item", 2, 0, 2)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "container" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 2, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "item")).toMatchObject({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test("does not let an active container displace another container", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [
|
||||
placement("active-container", 0, 0, 2, 2, "section"),
|
||||
placement("fixed-container", 2, 0, 2, 2, "section"),
|
||||
]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active-container" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "fixed-container")).toMatchObject({ x: 2, y: 0 });
|
||||
expect(getPlacement(moved.preview, "active-container")).toMatchObject({ x: 2, y: 2 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
|
||||
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
|
||||
@@ -408,9 +457,26 @@ describe("cross-grid movement", () => {
|
||||
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("keeps a destination container fixed when an item enters its grid", () => {
|
||||
const original = state(
|
||||
grid("source", 4, [placement("active", 0, 0)]),
|
||||
grid("target", 4, [placement("container", 1, 0, 2, 2, "section")]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "target",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test("moves a container's owned grid with it and supports commit/cancel", () => {
|
||||
const original = state(
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "section")]),
|
||||
grid("target", 3, []),
|
||||
grid("container-content", 2, [placement("child", 0, 0)], {
|
||||
parentGridId: "source",
|
||||
@@ -421,7 +487,7 @@ describe("cross-grid movement", () => {
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
|
||||
|
||||
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, type: "section" });
|
||||
expect(cancelGridTransaction(moved)).toEqual(original);
|
||||
expect(commitGridTransaction(moved)).toEqual(moved.preview);
|
||||
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
|
||||
@@ -429,8 +495,8 @@ describe("cross-grid movement", () => {
|
||||
|
||||
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "section")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "section")], {
|
||||
parentGridId: "root",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
|
||||
@@ -150,7 +150,8 @@ const previewSameGridMove = <TPlacement extends GridPlacement>(
|
||||
const collisions = grid.placements.filter(
|
||||
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
|
||||
);
|
||||
const displaced = collisions.length === 1 ? collisions[0] : undefined;
|
||||
const displacedCandidate = collisions.length === 1 ? collisions[0] : undefined;
|
||||
const displaced = displacedCandidate && !isContainerPlacement(displacedCandidate) ? displacedCandidate : undefined;
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
@@ -209,8 +210,10 @@ const previewCrossGridMove = <TPlacement extends GridPlacement>(
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the active placement, keeps every non-colliding item at its persisted
|
||||
* coordinates, and pushes only the deterministic collision chain downward.
|
||||
* Keeps stationary containers fixed, resolves the active placement around
|
||||
* those obstacles, and then pushes only the deterministic item collision chain
|
||||
* downward. A container can still be the active placement and push items, but
|
||||
* no active placement can displace another container.
|
||||
*/
|
||||
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
@@ -223,8 +226,24 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
|
||||
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
|
||||
|
||||
const placed: TPlacement[] = [pinned];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
|
||||
const fixedContainers = normalized.filter(
|
||||
(placement) => placement.id !== pinned.id && isContainerPlacement(placement),
|
||||
);
|
||||
const placed: TPlacement[] = [...fixedContainers];
|
||||
let resolvedPinned = pinned;
|
||||
for (;;) {
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolvedPinned, placement));
|
||||
if (collisions.length === 0) break;
|
||||
resolvedPinned = {
|
||||
...resolvedPinned,
|
||||
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
|
||||
};
|
||||
}
|
||||
placed.push(resolvedPinned);
|
||||
|
||||
const candidates = normalized
|
||||
.filter((placement) => placement.id !== pinned.id && !isContainerPlacement(placement))
|
||||
.toSorted(comparePlacements);
|
||||
for (const candidate of candidates) {
|
||||
let resolved = candidate;
|
||||
for (;;) {
|
||||
@@ -245,6 +264,8 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
|
||||
|
||||
const isContainerPlacement = (placement: GridPlacement) => "type" in placement && placement.type === "section";
|
||||
|
||||
const clampMovePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
input: GridMoveInput,
|
||||
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export { getPreferredNestedExitTargetId } from "./targeting";
|
||||
export { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
|
||||
export type { GridTargetGeometry } from "./targeting";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridTargetGeometry } from "./targeting";
|
||||
import { getPreferredNestedExitTargetId } from "./targeting";
|
||||
import { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
@@ -66,3 +66,30 @@ describe("nested grid exit targeting", () => {
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested grid fallback targeting", () => {
|
||||
const grids = [
|
||||
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
|
||||
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
|
||||
];
|
||||
|
||||
test("falls back from a blocked container to its parent canvas", () => {
|
||||
expect(
|
||||
getGridTargetFallbackIds({
|
||||
grids,
|
||||
targetGridId: "container",
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toEqual(["container", "canvas"]);
|
||||
});
|
||||
|
||||
test("does not cross an ineligible hierarchy boundary", () => {
|
||||
expect(
|
||||
getGridTargetFallbackIds({
|
||||
grids,
|
||||
targetGridId: "container",
|
||||
isEligible: (gridId) => gridId !== "canvas",
|
||||
}),
|
||||
).toEqual(["container"]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ export interface GridTargetGeometry {
|
||||
rectangle: RectangleLike;
|
||||
}
|
||||
|
||||
interface GridTargetFallbackInput {
|
||||
grids: readonly Pick<GridTargetGeometry, "id" | "parentGridId">[];
|
||||
targetGridId: string;
|
||||
isEligible: (gridId: string) => boolean;
|
||||
}
|
||||
|
||||
interface PreferredNestedExitTargetInput {
|
||||
grids: readonly GridTargetGeometry[];
|
||||
pointerTargetId: string;
|
||||
@@ -48,6 +54,26 @@ export const getPreferredNestedExitTargetId = ({
|
||||
return current.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tries the visible nested grid first, then its ancestors. A full or too-small
|
||||
* container must not trap an item that can still move in the parent grid.
|
||||
*/
|
||||
export const getGridTargetFallbackIds = ({ grids, targetGridId, isEligible }: GridTargetFallbackInput) => {
|
||||
const byId = new Map(grids.map((grid) => [grid.id, grid]));
|
||||
const ids: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current = byId.get(targetGridId);
|
||||
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
if (!isEligible(current.id)) break;
|
||||
ids.push(current.id);
|
||||
current = current.parentGridId ? byId.get(current.parentGridId) : undefined;
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isSameGridOrAncestor = (
|
||||
candidateId: string,
|
||||
source: GridTargetGeometry,
|
||||
|
||||
@@ -93,6 +93,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-content-mount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||
overflow: clip;
|
||||
}
|
||||
@@ -124,14 +129,52 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-section-kind="empty"][data-rail-placement="main"]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]
|
||||
> [data-board-container-body]
|
||||
> [data-section-id]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]:has(
|
||||
> [data-board-container-body] > [data-section-id] > .board-grid-editor[data-dnd-drop-target="true"]
|
||||
)::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
box-sizing: border-box;
|
||||
content: "";
|
||||
border: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]:has(
|
||||
> [data-board-container-body]
|
||||
> [data-section-id]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"]
|
||||
)::after {
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -143,10 +186,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -155,16 +194,6 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
|
||||
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
@@ -178,8 +207,8 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
|
||||
.board-grid-entry:hover > .board-grid-resize-handle::after,
|
||||
.board-grid-resize-handle:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -270,7 +299,7 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
.board-grid-resize-handle::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
commitGridTransaction,
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getGridTargetFallbackIds,
|
||||
getPreferredNestedExitTargetId,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
@@ -56,7 +56,6 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { observeGridDragHandle } from "./grid-drag-handle";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
@@ -195,7 +194,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
document.body.removeAttribute("data-board-grid-interacting");
|
||||
}, [interactionStore]);
|
||||
|
||||
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
|
||||
useLayoutEffect(() => {
|
||||
clearInteraction();
|
||||
return clearInteraction;
|
||||
}, [board.items, board.sections, clearInteraction, currentLayoutId]);
|
||||
|
||||
const registerGrid = useCallback((grid: RegisteredGrid) => {
|
||||
gridsRef.current.set(grid.id, grid);
|
||||
@@ -348,66 +350,56 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinates = getSnappedGridCoordinates({
|
||||
dragShape: shape,
|
||||
target: targetGrid.element.getBoundingClientRect(),
|
||||
columnCount: targetGrid.columnCount,
|
||||
const fallbackIds = getGridTargetFallbackIds({
|
||||
grids: Array.from(gridsRef.current.values()),
|
||||
targetGridId: targetGrid.id,
|
||||
isEligible: (targetGridId) => isDropTargetEligible(source, targetGridId),
|
||||
});
|
||||
if (!coordinates) {
|
||||
showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:coordinates`);
|
||||
return;
|
||||
}
|
||||
const { x, y } = coordinates;
|
||||
const targetAcceptsSource = canAcceptDrop(source, targetGrid.id);
|
||||
const previewKey = `${targetGrid.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
|
||||
if (active.lastPreviewKey === previewKey) return;
|
||||
if (!targetAcceptsSource) {
|
||||
showInvalidPreview(targetGrid.id, previewKey);
|
||||
return;
|
||||
}
|
||||
let invalidPreviewKey = `invalid:${targetGrid.id}:no-candidate`;
|
||||
|
||||
const result = previewGridMove(active.transaction, { targetGridId: targetGrid.id, x, y });
|
||||
if (
|
||||
result.accepted &&
|
||||
doesPreviewDisplaceNestedGridOwner(
|
||||
active.transaction.snapshot,
|
||||
result.transaction.preview,
|
||||
targetGrid.id,
|
||||
active.transaction.activeId,
|
||||
)
|
||||
) {
|
||||
showInvalidPreview(targetGrid.id, `${previewKey}:nested-owner`);
|
||||
return;
|
||||
}
|
||||
active.lastPreviewKey = previewKey;
|
||||
active.targetGridId = targetGrid.id;
|
||||
active.valid = result.accepted;
|
||||
if (!result.accepted) {
|
||||
for (const candidateId of fallbackIds) {
|
||||
const candidate = gridsRef.current.get(candidateId);
|
||||
if (!candidate) continue;
|
||||
const coordinates = getSnappedGridCoordinates({
|
||||
dragShape: shape,
|
||||
target: candidate.element.getBoundingClientRect(),
|
||||
columnCount: candidate.columnCount,
|
||||
});
|
||||
if (!coordinates) {
|
||||
invalidPreviewKey = `invalid:${candidate.id}:coordinates`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = coordinates;
|
||||
const targetAcceptsSource = canAcceptDrop(source, candidate.id);
|
||||
const previewKey = `${candidate.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
|
||||
invalidPreviewKey = previewKey;
|
||||
if (!targetAcceptsSource) continue;
|
||||
|
||||
const result = previewGridMove(active.transaction, { targetGridId: candidate.id, x, y });
|
||||
if (!result.accepted) continue;
|
||||
if (active.lastPreviewKey === previewKey) return;
|
||||
|
||||
active.lastPreviewKey = previewKey;
|
||||
active.targetGridId = candidate.id;
|
||||
active.valid = true;
|
||||
active.transaction = result.transaction;
|
||||
const targetPlacement = result.transaction.preview.grids
|
||||
.find((grid) => grid.id === candidate.id)
|
||||
?.placements.find((placement) => placement.id === sourceData.placement.id);
|
||||
publishPreview({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: targetGrid.id,
|
||||
targetPlacement: null,
|
||||
state: cancelGridTransaction(active.transaction),
|
||||
targetGridId: candidate.id,
|
||||
targetPlacement: targetPlacement ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "drag",
|
||||
valid: false,
|
||||
valid: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
active.transaction = result.transaction;
|
||||
const targetPlacement = result.transaction.preview.grids
|
||||
.find((grid) => grid.id === targetGrid.id)
|
||||
?.placements.find((placement) => placement.id === sourceData.placement.id);
|
||||
publishPreview({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: targetGrid.id,
|
||||
targetPlacement: targetPlacement ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "drag",
|
||||
valid: true,
|
||||
});
|
||||
showInvalidPreview(targetGrid.id, invalidPreviewKey);
|
||||
},
|
||||
[canAcceptDrop, interactionStore, isDropTargetEligible],
|
||||
);
|
||||
@@ -762,9 +754,9 @@ export default function GridEditor({
|
||||
data: { kind: GRID_TARGET_TYPE, sectionId },
|
||||
collisionDetector: pointerIntersection,
|
||||
collisionPriority: CollisionPriority.Highest + depth,
|
||||
// `accept` only removes the active container's own subtree. Size limits
|
||||
// are validated after collision so an invalid nested target cannot fall
|
||||
// through to an accepting parent grid underneath the pointer.
|
||||
// `accept` removes only the active container's own subtree. Size limits
|
||||
// are evaluated during preview targeting, which can fall back from an
|
||||
// invalid nested grid to an eligible parent.
|
||||
accept: (source) => isDropTargetEligible(source, sectionId),
|
||||
});
|
||||
|
||||
@@ -852,9 +844,8 @@ const DndGridEntryComponent = ({
|
||||
maxRowCount,
|
||||
registerElement,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer } = useBoardGridPortalHost();
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
@@ -908,8 +899,9 @@ const DndGridEntryComponent = ({
|
||||
shellRef.current = element;
|
||||
registerElement(placement.id, element);
|
||||
draggableRef(element);
|
||||
handleRef(element);
|
||||
},
|
||||
[draggableRef, placement.id, registerElement],
|
||||
[draggableRef, handleRef, placement.id, registerElement],
|
||||
);
|
||||
|
||||
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||
@@ -918,35 +910,12 @@ const DndGridEntryComponent = ({
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const container = acquireContainer(placement.id);
|
||||
mount.appendChild(container);
|
||||
mount.replaceChildren(container);
|
||||
|
||||
const handleSelector =
|
||||
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
|
||||
return observeGridDragHandle(container, handleSelector, handleRef);
|
||||
}, [acquireContainer, handleRef, placement.id, placement.type]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shell = shellRef.current;
|
||||
if (placement.type !== "section" || !shell) return;
|
||||
|
||||
const handlePointerDownCapture = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
const grip = event.target.closest("[data-grid-container-drag-handle]");
|
||||
if (grip) {
|
||||
const owner = grip.closest('.board-grid-entry[data-grid-item-type="section"]');
|
||||
setIsSectionChromeActive(owner === shell);
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeHandle = event.target.closest(".board-grid-resize-handle");
|
||||
if (!resizeHandle || resizeHandle.parentElement !== shell) {
|
||||
setIsSectionChromeActive(false);
|
||||
}
|
||||
return () => {
|
||||
if (container.parentElement === mount) container.remove();
|
||||
};
|
||||
|
||||
shell.addEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
}, [placement.type]);
|
||||
}, [acquireContainer, placement.id, structureRevision]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -959,7 +928,6 @@ const DndGridEntryComponent = ({
|
||||
data-grid-y={placement.y}
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
@@ -968,7 +936,6 @@ const DndGridEntryComponent = ({
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
disabled={placement.type === "section" && !isSectionChromeActive}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
beginResize={beginResize}
|
||||
@@ -987,7 +954,6 @@ interface GridResizeHandlesProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
beginResize: BoardGridEditorActionsContextValue["beginResize"];
|
||||
@@ -1003,7 +969,6 @@ const GridResizeHandles = ({
|
||||
sectionId,
|
||||
placement,
|
||||
label,
|
||||
disabled,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
beginResize,
|
||||
@@ -1083,15 +1048,7 @@ const GridResizeHandles = ({
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
|
||||
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
|
||||
if (
|
||||
disabled ||
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
pointerStartRef.current ||
|
||||
!visualScale ||
|
||||
!start(direction)
|
||||
)
|
||||
return;
|
||||
if (!event.isPrimary || event.button !== 0 || pointerStartRef.current || !visualScale || !start(direction)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
@@ -1150,7 +1107,6 @@ const GridResizeHandles = ({
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
data-grid-resize-disabled={disabled ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -1281,40 +1237,17 @@ const getPreferredRegisteredGridTarget = (
|
||||
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
|
||||
};
|
||||
|
||||
const doesPreviewDisplaceNestedGridOwner = (
|
||||
snapshot: TransactionalGridState<SectionGridPlacement>,
|
||||
preview: TransactionalGridState<SectionGridPlacement>,
|
||||
targetGridId: string,
|
||||
activeId: string,
|
||||
) => {
|
||||
const ownerIds = new Set(
|
||||
snapshot.grids
|
||||
.filter((grid) => grid.parentGridId === targetGridId && grid.ownerPlacementId !== activeId)
|
||||
.flatMap((grid) => (grid.ownerPlacementId ? [grid.ownerPlacementId] : [])),
|
||||
);
|
||||
if (ownerIds.size === 0) return false;
|
||||
|
||||
const original = snapshot.grids.find((grid) => grid.id === targetGridId);
|
||||
const candidate = preview.grids.find((grid) => grid.id === targetGridId);
|
||||
if (!original || !candidate) return false;
|
||||
const originalById = new Map(original.placements.map((placement) => [placement.id, placement]));
|
||||
return candidate.placements.some((placement) => {
|
||||
const previous = originalById.get(placement.id);
|
||||
return Boolean(previous && ownerIds.has(placement.id) && !areGridPlacementCoordinatesEqual(previous, placement));
|
||||
});
|
||||
};
|
||||
|
||||
const areGridPlacementCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || target === source.handle) return false;
|
||||
const activationElement = source.handle ?? source.element;
|
||||
if (!(target instanceof Element) || target === activationElement) return false;
|
||||
const targetShell = target.closest(".board-grid-entry");
|
||||
if (targetShell && targetShell !== activationElement) return true;
|
||||
const interactive = target.closest(
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.board-grid-resize-handle',
|
||||
);
|
||||
return Boolean(interactive && source.handle?.contains(interactive));
|
||||
return Boolean(interactive && activationElement?.contains(interactive));
|
||||
};
|
||||
|
||||
const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGridPlacement) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
@@ -23,6 +23,7 @@ interface GridPortalHostContextValue {
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
structureRevision: string;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
@@ -50,6 +51,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
@@ -81,8 +83,9 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
structureRevision,
|
||||
}),
|
||||
[acquireContainer, announce, containers, integrations],
|
||||
[acquireContainer, announce, containers, integrations, structureRevision],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -143,26 +146,23 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = persistedEntry;
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section = persistedSection;
|
||||
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
if (!section || !currentLayout) return null;
|
||||
|
||||
const columnCount = getSectionColumnCount(section, currentLayout);
|
||||
if (columnCount < 1) return null;
|
||||
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
|
||||
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
|
||||
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
|
||||
if (!entry) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
...renderedItems.map((item) => item.yOffset + item.height),
|
||||
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
);
|
||||
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
|
||||
|
||||
@@ -170,8 +170,8 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
items: renderedItems,
|
||||
innerSections: renderedInnerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
@@ -183,23 +183,66 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
);
|
||||
};
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
const getRenderedSection = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
sectionId: string,
|
||||
visited = new Set<string>(),
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind !== "container") return section;
|
||||
if (visited.has(section.id)) return null;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
|
||||
if (!layout) return null;
|
||||
const parentSection = getRenderedSection(
|
||||
board,
|
||||
currentLayout,
|
||||
layout.parentSectionId,
|
||||
new Set(visited).add(section.id),
|
||||
);
|
||||
if (!parentSection) return null;
|
||||
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
|
||||
if (parentColumnCount < 1) return null;
|
||||
const normalized = normalizePortalEntry(layout, parentColumnCount);
|
||||
|
||||
return {
|
||||
...section,
|
||||
...layout,
|
||||
...normalized,
|
||||
type: "section",
|
||||
};
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
) =>
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
|
||||
const normalizePortalEntry = <
|
||||
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
|
||||
>(
|
||||
entry: TEntry,
|
||||
columnCount: number,
|
||||
) => {
|
||||
const placement = normalizeGridPlacement(
|
||||
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
|
||||
columnCount,
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
};
|
||||
|
||||
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
|
||||
const item = board.items.find((candidate) => candidate.id === entryId);
|
||||
if (item) {
|
||||
|
||||
@@ -172,8 +172,10 @@ export const SectionGrid = ({
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
w={logicalWidth}
|
||||
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
|
||||
style={{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
}}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
|
||||
@@ -235,55 +235,6 @@ describe("Onboarding", () => {
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync();
|
||||
|
||||
const competingContext = await browser.newContext();
|
||||
const claimResponse = await competingContext.request.post(
|
||||
`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
|
||||
);
|
||||
expect(claimResponse.status()).toBe(423);
|
||||
await competingContext.close();
|
||||
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
|
||||
@@ -19,13 +19,18 @@ export type ContextSpecificItem = {
|
||||
|
||||
interface SpotlightContextProps {
|
||||
items: ContextSpecificItem[];
|
||||
}
|
||||
|
||||
interface SpotlightRegistrationContextProps {
|
||||
registerItems: (key: string, results: ContextSpecificItem[]) => void;
|
||||
unregisterItems: (key: string) => void;
|
||||
}
|
||||
|
||||
const createSpotlightContext = (displayName: string) => {
|
||||
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
|
||||
const SpotlightRegistrationContext = createContext<SpotlightRegistrationContextProps | null>(null);
|
||||
SpotlightContext.displayName = displayName;
|
||||
SpotlightRegistrationContext.displayName = `${displayName}Registration`;
|
||||
|
||||
const Provider = ({ children }: PropsWithChildren) => {
|
||||
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
|
||||
@@ -51,16 +56,18 @@ const createSpotlightContext = (displayName: string) => {
|
||||
const newItemsMap = new Map(prevItems);
|
||||
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
|
||||
|
||||
return prevItems;
|
||||
return newItemsMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
|
||||
const itemsContext = useMemo(() => ({ items }), [items]);
|
||||
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
|
||||
|
||||
return (
|
||||
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
|
||||
{children}
|
||||
</SpotlightContext.Provider>
|
||||
<SpotlightRegistrationContext.Provider value={registration}>
|
||||
<SpotlightContext.Provider value={itemsContext}>{children}</SpotlightContext.Provider>
|
||||
</SpotlightRegistrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,7 +86,7 @@ const createSpotlightContext = (displayName: string) => {
|
||||
items: ContextSpecificItem[],
|
||||
dependencyArray: DependencyList,
|
||||
) => {
|
||||
const context = useContext(SpotlightContext);
|
||||
const context = useContext(SpotlightRegistrationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -97,7 +104,7 @@ const createSpotlightContext = (displayName: string) => {
|
||||
context.unregisterItems(key);
|
||||
};
|
||||
// We ignore the results
|
||||
}, [...dependencyArray, key]);
|
||||
}, [...dependencyArray, context.registerItems, context.unregisterItems, key]);
|
||||
};
|
||||
|
||||
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
|
||||
|
||||
@@ -2710,7 +2710,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove item",
|
||||
"message": "Are you sure you want to remove this item?"
|
||||
"message": "Are you sure?"
|
||||
},
|
||||
"advancedFocus": {
|
||||
"open": "Open advanced view",
|
||||
@@ -2807,9 +2807,16 @@
|
||||
"withBorder": {
|
||||
"label": "Show border"
|
||||
},
|
||||
"customUrls": {
|
||||
"label": "Custom links",
|
||||
"description": "Add URLs directly without creating an App first"
|
||||
},
|
||||
"items": {
|
||||
"label": "Bookmarks",
|
||||
"add": "Add bookmark"
|
||||
"apps": {
|
||||
"label": "Apps",
|
||||
"placeholder": "Select one or more apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { WidgetOptionOfType, WidgetOptionType } from "../options";
|
||||
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
kind: WidgetKind;
|
||||
property: string;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
|
||||
@@ -114,7 +114,7 @@ async function renderInput() {
|
||||
<WidgetCustomWidgetSelectInput
|
||||
kind="customApi"
|
||||
property="definitionId"
|
||||
options={{ withDescription: false }}
|
||||
options={{ defaultValue: "", withDescription: false }}
|
||||
initialOptions={{}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
|
||||
const defaultLocation = { name: "Paris", latitude: 48.8566, longitude: 2.3522 };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
values: {} as Record<string, unknown>,
|
||||
setFieldValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => {
|
||||
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
|
||||
const Input = ({ label, value }: { label?: ReactNode; value?: string | number }) => (
|
||||
<label>
|
||||
{label}
|
||||
<input value={value ?? ""} readOnly />
|
||||
</label>
|
||||
);
|
||||
|
||||
return {
|
||||
Button: Container,
|
||||
Fieldset: Container,
|
||||
Group: Container,
|
||||
NumberInput: Input,
|
||||
Stack: Container,
|
||||
TextInput: Input,
|
||||
Tooltip: Container,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/modals", () => ({
|
||||
createModal: () => ({ withOptions: () => null }),
|
||||
useModalAction: () => ({ openModal: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useCurrentIntlLocale: () => "en",
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("./common", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useWidgetInputTranslation: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("./form", () => ({
|
||||
useFormContext: () => ({
|
||||
getInputProps: (path: string) => ({ value: mocks.values[path], onChange: vi.fn() }),
|
||||
setFieldValue: mocks.setFieldValue,
|
||||
clearFieldError: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.values = {};
|
||||
mocks.setFieldValue.mockReset();
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const renderInput = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WidgetLocationInput
|
||||
kind="weather"
|
||||
property="location"
|
||||
options={{ defaultValue: defaultLocation }}
|
||||
initialOptions={{}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
describe("location input initialization", () => {
|
||||
it("hydrates a missing location with the default value", async () => {
|
||||
await renderInput();
|
||||
|
||||
expect(mocks.setFieldValue).toHaveBeenCalledOnce();
|
||||
expect(mocks.setFieldValue).toHaveBeenCalledWith("options.location", defaultLocation);
|
||||
});
|
||||
|
||||
it("does not reset a transient empty coordinate after initialization", async () => {
|
||||
mocks.values = {
|
||||
"options.location": defaultLocation,
|
||||
"options.location.name": defaultLocation.name,
|
||||
"options.location.latitude": defaultLocation.latitude,
|
||||
"options.location.longitude": defaultLocation.longitude,
|
||||
};
|
||||
await renderInput();
|
||||
|
||||
mocks.values = {
|
||||
...mocks.values,
|
||||
"options.location": { ...defaultLocation, latitude: "" },
|
||||
"options.location.latitude": "",
|
||||
};
|
||||
await renderInput();
|
||||
|
||||
expect(mocks.setFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -29,14 +29,26 @@ import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
|
||||
export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tLocation = useScopedI18n("widget.common.location");
|
||||
const form = useFormContext();
|
||||
const { openModal } = useModalAction(LocationSearchModal);
|
||||
const inputProps = form.getInputProps(`options.${property}`);
|
||||
const value = inputProps.value as OptionLocation;
|
||||
const fieldPath = `options.${property}`;
|
||||
const inputProps = form.getInputProps(fieldPath);
|
||||
const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue;
|
||||
const nameInputProps = form.getInputProps(`options.${property}.name`);
|
||||
const latitudeInputProps = form.getInputProps(`options.${property}.latitude`);
|
||||
const longitudeInputProps = form.getInputProps(`options.${property}.longitude`);
|
||||
const selectionEnabled = value.name.length > 1;
|
||||
const initializedFieldPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedFieldPathRef.current === fieldPath) return;
|
||||
initializedFieldPathRef.current = fieldPath;
|
||||
if (isOptionLocation(inputProps.value)) return;
|
||||
form.setFieldValue(fieldPath, options.defaultValue);
|
||||
}, [fieldPath, form, inputProps.value, options.defaultValue]);
|
||||
|
||||
const handleChange = inputProps.onChange as LocationOnChange;
|
||||
const unknownLocation = tLocation("unknownLocation");
|
||||
@@ -73,7 +85,12 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack gap="xs">
|
||||
<Group wrap="nowrap" align="end">
|
||||
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
|
||||
<TextInput
|
||||
w="100%"
|
||||
label={tLocation("query")}
|
||||
{...nameInputProps}
|
||||
value={nameInputProps.value ?? value.name}
|
||||
/>
|
||||
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
|
||||
<div>
|
||||
<Button
|
||||
@@ -93,13 +110,15 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
decimalScale={5}
|
||||
label={tLocation("latitude")}
|
||||
hideControls
|
||||
{...form.getInputProps(`options.${property}.latitude`)}
|
||||
{...latitudeInputProps}
|
||||
value={latitudeInputProps.value ?? value.latitude}
|
||||
/>
|
||||
<NumberInput
|
||||
decimalScale={5}
|
||||
label={tLocation("longitude")}
|
||||
hideControls
|
||||
{...form.getInputProps(`options.${property}.longitude`)}
|
||||
{...longitudeInputProps}
|
||||
value={longitudeInputProps.value ?? value.longitude}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -107,6 +126,16 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
);
|
||||
};
|
||||
|
||||
const isOptionLocation = (value: unknown): value is OptionLocation =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"name" in value &&
|
||||
typeof value.name === "string" &&
|
||||
"latitude" in value &&
|
||||
typeof value.latitude === "number" &&
|
||||
"longitude" in value &&
|
||||
typeof value.longitude === "number";
|
||||
|
||||
type LocationOnChange = (
|
||||
location: Pick<OptionLocation, "name"> & {
|
||||
latitude: OptionLocation["latitude"] | "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -18,10 +18,16 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const form = useFormContext();
|
||||
const inputProps = form.getInputProps(`options.${property}`);
|
||||
const values = inputProps.value as string[];
|
||||
const fieldPath = `options.${property}`;
|
||||
const inputProps = form.getInputProps(fieldPath);
|
||||
const values = Array.isArray(inputProps.value) ? (inputProps.value as string[]) : options.defaultValue;
|
||||
const onChange = inputProps.onChange as (values: string[]) => void;
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(inputProps.value)) return;
|
||||
form.setFieldValue(fieldPath, options.defaultValue);
|
||||
}, [fieldPath, form, inputProps.value, options.defaultValue]);
|
||||
|
||||
const handleRemove = (optionIndex: number) => {
|
||||
onChange(values.filter((_, index) => index !== optionIndex));
|
||||
};
|
||||
|
||||
@@ -31,8 +31,14 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
}: CommonWidgetInputProps<"sortableItemList">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
|
||||
const values = form.values.options[property] as TOptionValue[];
|
||||
const fieldPath = `options.${property}`;
|
||||
const initialValues = useMemo(
|
||||
() =>
|
||||
Array.isArray(initialOptions[property]) ? (initialOptions[property] as TOptionValue[]) : options.defaultValue,
|
||||
[initialOptions, options.defaultValue, property],
|
||||
);
|
||||
const currentValue = form.values.options[property];
|
||||
const values = Array.isArray(currentValue) ? (currentValue as TOptionValue[]) : options.defaultValue;
|
||||
const { data, isLoading, error } = options.useData(initialValues);
|
||||
const dataMap = useMemo(
|
||||
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
|
||||
@@ -52,6 +58,11 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
const getIndex = (id: TOptionValue) => values.indexOf(id);
|
||||
const activeIndex = activeId ? getIndex(activeId) : -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(currentValue)) return;
|
||||
form.setFieldValue(fieldPath, options.defaultValue);
|
||||
}, [currentValue, fieldPath, form, options.defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
isFirstAnnouncement.current = true;
|
||||
@@ -69,22 +80,39 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
[tempMap, dataMap],
|
||||
);
|
||||
|
||||
const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
|
||||
form.setFieldValue(`options.${property}`, callback);
|
||||
};
|
||||
const updateItems = useCallback(
|
||||
(callback: (prev: TOptionValue[]) => TOptionValue[]) => {
|
||||
form.setFieldValue(fieldPath, callback);
|
||||
},
|
||||
[fieldPath, form],
|
||||
);
|
||||
|
||||
const addItem = (item: TItem) => {
|
||||
setTempMap((prev) => {
|
||||
prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
|
||||
return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(options.uniqueIdentifier(item) as TOptionValue, item);
|
||||
return next;
|
||||
});
|
||||
updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
|
||||
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
||||
};
|
||||
|
||||
const removeItem = useCallback(
|
||||
(value: TOptionValue) => {
|
||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||
setTempMap((previous) => {
|
||||
if (!previous.has(value)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(value);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[updateItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack>
|
||||
<options.addButton addItem={addItem} values={values} />
|
||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -114,19 +142,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
<React.Fragment>
|
||||
{values.map((value, index) => {
|
||||
const item = getItem(value);
|
||||
const removeItem = () => {
|
||||
form.setValues((previous) => {
|
||||
const previousValues = previous.options?.[property] as TOptionValue[];
|
||||
return {
|
||||
...previous,
|
||||
options: {
|
||||
...previous.options,
|
||||
[property]: previousValues.filter((id) => id !== value),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
@@ -137,7 +152,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
id={value}
|
||||
index={index}
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
removeItem={() => removeItem(value)}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
.clickableCard {
|
||||
position: relative;
|
||||
transition: background-color 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableCard:hover,
|
||||
.clickableCard:focus-visible {
|
||||
.clickableCard:focus-within {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.clickableCard:focus-visible {
|
||||
.clickableCard:focus-within {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cardAction {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clickableCard {
|
||||
transition: none;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -365,14 +365,10 @@ const SystemCard = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={!onClick}
|
||||
padding={size.cardPadding}
|
||||
radius={itemRadius}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
onClick={onClick}
|
||||
className={onClick ? classes.clickableCard : undefined}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
@@ -385,6 +381,14 @@ const SystemCard = ({
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{onClick && (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
className={classes.cardAction}
|
||||
aria-label={system.name}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
<Group gap="xs" mb={2}>
|
||||
<Badge
|
||||
size={size.badgeSize}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.target {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import type { MultiSelectProps } from "@mantine/core";
|
||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { SortableItemListInput } from "../options";
|
||||
import { AppSelectModal } from "./app-select-modal";
|
||||
|
||||
export const BookmarkAddButton: SortableItemListInput<
|
||||
{
|
||||
name: string;
|
||||
description: string | null;
|
||||
id: string;
|
||||
iconUrl: string;
|
||||
href: string | null;
|
||||
pingUrl: string | null;
|
||||
},
|
||||
string
|
||||
>["AddButton"] = ({ addItem, values }) => {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const t = useI18n();
|
||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||
|
||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
||||
addItem,
|
||||
removeItem,
|
||||
values,
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.bookmarks.option.items.apps");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const tSelect = useScopedI18n("app.action.select");
|
||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||
|
||||
const handleChange = (nextValues: string[]) => {
|
||||
const nextIds = new Set(nextValues);
|
||||
for (const value of values) {
|
||||
if (!nextIds.has(value)) removeItem(value);
|
||||
}
|
||||
|
||||
const currentIds = new Set(values);
|
||||
for (const value of nextValues) {
|
||||
if (currentIds.has(value)) continue;
|
||||
const app = appsById.get(value);
|
||||
if (app) addItem(app);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
|
||||
{t("widget.bookmarks.option.items.add")}
|
||||
</Button>
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
placeholder={t("placeholder")}
|
||||
value={values}
|
||||
onChange={handleChange}
|
||||
searchable
|
||||
clearable
|
||||
clearSearchOnChange
|
||||
hidePickedOptions
|
||||
disabled={isPending || Boolean(error)}
|
||||
error={error ? tCommon("error") : undefined}
|
||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={tSelect("notFound")}
|
||||
renderOption={renderAppOption}
|
||||
data={apps.map((app) => ({
|
||||
value: app.id,
|
||||
label: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: 18,
|
||||
};
|
||||
|
||||
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
|
||||
<Group flex="1" gap="xs">
|
||||
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
|
||||
<img width={20} height={20} src={option.iconUrl} alt="" />
|
||||
) : null}
|
||||
{option.label}
|
||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
||||
</Group>
|
||||
);
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
presentAppIds: string[];
|
||||
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface AppSelectFormType {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<AppSelectFormType>();
|
||||
const handleSubmitAsync = async (values: AppSelectFormType) => {
|
||||
const currentApp = apps?.find((app) => app.id === values.id);
|
||||
if (!currentApp) return;
|
||||
setLoading(true);
|
||||
await innerProps.onSelect(currentApp);
|
||||
|
||||
setLoading(false);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||
const currentApp = apps?.find((app) => app.id === form.values.id);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("id")}
|
||||
label={t("app.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
data-autofocus
|
||||
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
||||
nothingFoundMessage={t("app.action.select.notFound")}
|
||||
renderOption={renderSelectOption}
|
||||
limit={5}
|
||||
data={
|
||||
apps
|
||||
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
|
||||
.map((app) => ({
|
||||
label: app.name,
|
||||
value: app.id,
|
||||
iconUrl: app.iconUrl,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={confirmLabel} loading={loading} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.label"),
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: 18,
|
||||
};
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
|
||||
<Group flex="1" gap="xs">
|
||||
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
|
||||
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
|
||||
) : null}
|
||||
{option.label}
|
||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
||||
</Group>
|
||||
);
|
||||
|
||||
interface LeftSectionProps {
|
||||
isPending: boolean;
|
||||
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
|
||||
}
|
||||
|
||||
const size = 20;
|
||||
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
|
||||
if (isPending) {
|
||||
return <Loader size={size} />;
|
||||
}
|
||||
|
||||
if (currentApp) {
|
||||
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const MemoizedLeftSection = memo(LeftSection);
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Anchor,
|
||||
@@ -14,9 +15,9 @@ import {
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
@@ -29,6 +30,14 @@ import classes from "./bookmark.module.css";
|
||||
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
|
||||
export interface BookmarkDisplayItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
iconUrl?: string;
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export interface CompactBookmarkLayout {
|
||||
columns: number;
|
||||
hideHostname: boolean;
|
||||
@@ -67,14 +76,19 @@ export default function BookmarksWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const data =
|
||||
const apps =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
select(selectedApps) {
|
||||
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
}),
|
||||
) ?? [];
|
||||
const customLinks = useMemo(
|
||||
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
|
||||
[options.customUrls],
|
||||
);
|
||||
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
@@ -85,7 +99,7 @@ export default function BookmarksWidget({
|
||||
{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
icon: app.iconUrl ?? IconLink,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
@@ -161,7 +175,7 @@ export default function BookmarksWidget({
|
||||
}
|
||||
|
||||
interface FlexLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
data: BookmarkDisplayItem[];
|
||||
direction: "row" | "column";
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
@@ -235,7 +249,7 @@ const FlexLayout = ({
|
||||
};
|
||||
|
||||
interface AdvancedBookmarksLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
data: BookmarkDisplayItem[];
|
||||
width: number;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
@@ -250,6 +264,19 @@ export const getBookmarkHostname = (href: string | null): string | undefined =>
|
||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
||||
};
|
||||
|
||||
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
|
||||
const normalizedUrl = url.trim();
|
||||
const href = getSafeAppHref(normalizedUrl);
|
||||
if (!href) return null;
|
||||
|
||||
return {
|
||||
id: `custom-link:${normalizedUrl}`,
|
||||
name: getBookmarkHostname(href) ?? normalizedUrl,
|
||||
description: null,
|
||||
href,
|
||||
};
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
data,
|
||||
width,
|
||||
@@ -275,10 +302,9 @@ const AdvancedBookmarksLayout = ({
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
@@ -306,7 +332,7 @@ const AdvancedBookmarksLayout = ({
|
||||
};
|
||||
|
||||
interface GridLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
data: BookmarkDisplayItem[];
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
@@ -392,7 +418,7 @@ const VerticalItem = ({
|
||||
hideHostname,
|
||||
hasIconColor,
|
||||
}: {
|
||||
app: RouterOutputs["app"]["byIds"][number];
|
||||
app: BookmarkDisplayItem;
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
@@ -406,10 +432,9 @@ const VerticalItem = ({
|
||||
</Text>
|
||||
)}
|
||||
{!hideIcon && (
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{
|
||||
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
||||
@@ -437,7 +462,7 @@ const HorizontalItem = ({
|
||||
hideHostname,
|
||||
hasIconColor,
|
||||
}: {
|
||||
app: RouterOutputs["app"]["byIds"][number];
|
||||
app: BookmarkDisplayItem;
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
@@ -446,10 +471,9 @@ const HorizontalItem = ({
|
||||
return (
|
||||
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
|
||||
{!hideIcon && (
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{
|
||||
overflow: "auto",
|
||||
@@ -479,3 +503,23 @@ const HorizontalItem = ({
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface BookmarkIconProps {
|
||||
app: BookmarkDisplayItem;
|
||||
hasColor: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
|
||||
app.iconUrl ? (
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasColor}
|
||||
alt={app.name}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
) : (
|
||||
<IconLink aria-label={app.name} className={className} style={style} />
|
||||
);
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { getSafeAppHref } from "../common/application-url";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { BookmarkAddButton } from "./add-button";
|
||||
@@ -27,7 +29,11 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
withBorder: factory.switch({ defaultValue: false }),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
customUrls: factory.multiText({
|
||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
||||
withDescription: true,
|
||||
}),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
|
||||
import {
|
||||
createCustomBookmark,
|
||||
getAdvancedBookmarkColumns,
|
||||
getBookmarkHostname,
|
||||
getCompactBookmarkLayout,
|
||||
} from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses bounded advanced columns", () => {
|
||||
@@ -30,4 +35,20 @@ describe("bookmark display helpers", () => {
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates standalone bookmarks without app records", () => {
|
||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -755,7 +755,7 @@ export default function DownloadClientsWidget({
|
||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
||||
{statsDisplay.visible && (
|
||||
<GlobalStatsBar
|
||||
key={displayMode}
|
||||
key={`stats-${displayMode}`}
|
||||
queueStats={queueStats}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
@@ -803,7 +803,7 @@ export default function DownloadClientsWidget({
|
||||
|
||||
{(isAdvanced || height >= 96) && (
|
||||
<WidgetFooter
|
||||
key={displayMode}
|
||||
key={`footer-${displayMode}`}
|
||||
clients={clients}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
|
||||
@@ -32,7 +32,11 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
|
||||
CommonInput<TOptionValue[]>,
|
||||
"withDescription"
|
||||
> {
|
||||
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
||||
AddButton: (props: {
|
||||
addItem: (item: TItem) => void;
|
||||
removeItem: (value: TOptionValue) => void;
|
||||
values: TOptionValue[];
|
||||
}) => React.ReactNode;
|
||||
ItemComponent: (props: {
|
||||
item: TItem;
|
||||
removeItem: () => void;
|
||||
|
||||
Reference in new issue
Block a user