fix(board): harden selection and creation flows
This commit is contained in:
12 files changed
+119
-26
No files matched your search
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
|
||||
Containers as destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||
selection. A move is applied only when every selected item fits in the destination.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -227,11 +230,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
<img
|
||||
src={keyboardTileEditingImage}
|
||||
alt="A focused board tile in keyboard editing mode"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||
|
||||
#### Preview
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ export const FixedGridItem = ({
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
|
||||
@@ -136,6 +136,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
let didMoveAll = false;
|
||||
|
||||
persistBoard((previous) => {
|
||||
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
|
||||
@@ -218,7 +219,8 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
|
||||
}
|
||||
|
||||
if (movedLayouts.size === 0) return previous;
|
||||
if (movedLayouts.size !== idsToMove.size) return previous;
|
||||
didMoveAll = true;
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
@@ -232,9 +234,16 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
};
|
||||
});
|
||||
|
||||
clearSelection();
|
||||
if (didMoveAll) {
|
||||
clearSelection();
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: tSelection("moveUnavailableTitle"),
|
||||
message: tSelection("moveUnavailableMessage"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[clearSelection, currentLayoutId, persistBoard, selectedItemIds],
|
||||
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -188,4 +188,64 @@ describe("BoardSelectionContext", () => {
|
||||
expect(movedB?.xOffset).toBe(2);
|
||||
expect(movedB?.yOffset).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the entire selection in place when the destination cannot fit every item", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
|
||||
.build();
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(selectionContext.isSelected("item-a")).toBe(true);
|
||||
expect(selectionContext.isSelected("item-b")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -34,7 +34,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(directKind ?? null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "form" && !isDirectForm) {
|
||||
@@ -95,6 +95,19 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
if (isIntegrationDataError) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("integration.grid.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -62,13 +62,19 @@ vi.mock("@homarr/modals", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const count = values?.count ?? 0;
|
||||
const translations: Record<string, string> = {
|
||||
"app.action.select.search": "Search apps",
|
||||
"app.action.select.title": "Select application",
|
||||
"app.action.select.selected": "Selected",
|
||||
"app.action.select.toggle": "Click to select",
|
||||
"app.action.select.noResults": "No apps found",
|
||||
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
|
||||
"app.action.select.selectedCount": `${count} selected`,
|
||||
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
|
||||
"app.action.select.customApplication": "Custom application",
|
||||
"app.action.select.application": "Application",
|
||||
"app.action.create.title": "Add custom app",
|
||||
"app.action.create.description": "Configure custom URL and icon",
|
||||
"common.action.discard": "Discard",
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -31,8 +31,6 @@ interface AppSelectModalProps {
|
||||
withCreate: boolean;
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -1674,7 +1674,8 @@
|
||||
"detected": "Detected via Docker",
|
||||
"connected": "{count, plural, one {# connected} other {# connected}}",
|
||||
"tiedWidgets": "Tied widgets",
|
||||
"more": "+{count} more"
|
||||
"more": "+{count} more",
|
||||
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -2792,7 +2793,9 @@
|
||||
"clear": "Clear selection",
|
||||
"clearShortcut": "Clear selection (Escape)",
|
||||
"saveErrorTitle": "Unable to update items",
|
||||
"saveErrorMessage": "The changes could not be saved. The board was refreshed."
|
||||
"saveErrorMessage": "The changes could not be saved. The board was refreshed.",
|
||||
"moveUnavailableTitle": "Unable to move selected items",
|
||||
"moveUnavailableMessage": "The destination does not have enough room for the entire selection."
|
||||
},
|
||||
"moveResize": {
|
||||
"title": "Move / resize item",
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("SelectableCard", () => {
|
||||
expect(host.textContent).not.toContain("This should not appear");
|
||||
});
|
||||
|
||||
it("renders description with fallback when description is empty string", async () => {
|
||||
it("renders an empty description without fallback copy", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
@@ -210,7 +210,8 @@ describe("SelectableCard", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("No description provided");
|
||||
expect(host.textContent).toContain("No Desc Item");
|
||||
expect(host.textContent).not.toContain("No description provided");
|
||||
});
|
||||
|
||||
it("omits header, body, or footer when corresponding props are omitted", async () => {
|
||||
|
||||
@@ -22,11 +22,14 @@ vi.mock("@homarr/definitions", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
"integration.page.list.search": "Search integrations",
|
||||
"common.noResults": "No results found",
|
||||
"integration.grid.noWidgets": "No widgets tied to this service",
|
||||
"integration.grid.tiedWidgets": "Tied Widgets",
|
||||
"integration.grid.connected": `${values?.count ?? 0} connected`,
|
||||
"integration.grid.more": `+${values?.count ?? 0} more`,
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.calendar.name": "Calendar",
|
||||
"widget.downloads.name": "Downloads",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||
import { SelectableCard } from "../components/selectable-card";
|
||||
import { selectGridCols } from "../select-grid-layout";
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||
|
||||
@@ -57,8 +58,6 @@ export interface IntegrationSelectGridProps {
|
||||
integrationData?: { kind: IntegrationKind; name?: string }[];
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
|
||||
export const IntegrationSelectGrid = ({
|
||||
onSelect,
|
||||
enableMockIntegration = false,
|
||||
|
||||
@@ -218,6 +218,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
handleSubmit(event);
|
||||
};
|
||||
|
||||
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
|
||||
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
|
||||
? () => {
|
||||
innerProps.onOpenNewIntegration?.((newId) => {
|
||||
@@ -236,7 +237,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={handleOpenNewIntegration}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user