Compare commits

...
Author SHA1 Message Date
Thomas Camlong 50bd56edb9 fix(board): harden selection and creation flows 2026-08-21 02:55:47 +02:00
Thomas Camlong 71d307a9cd feat(board): show multi-select hint 2026-08-21 02:47:05 +02:00
Thomas Camlong 49c2846d68 test(board): update selection mocks 2026-08-21 02:42:45 +02:00
Thomas Camlong 8b69688640 fix(board): persist batch item actions 2026-08-21 02:31:13 +02:00
Thomas Camlong 3a7cad9e2f feat(board): add multi-item selection actions 2026-08-21 02:15:57 +02:00
Thomas Camlong f7acf18b58 feat(board): ensure Cmd/Ctrl click correctly selects items in grid editor 2026-08-21 01:10:33 +02:00
Thomas Camlong c94dc7a2e8 feat(board): add Cmd/Ctrl multi-item selection, floating action bar, and procedural delete audio synthesis 2026-08-21 01:01:09 +02:00
Thomas Camlong 439cb7f497 fix(review): address coderabbit review findings across modals, widgets, ui, and tests 2026-08-21 00:56:16 +02:00
Thomas Camlong 858af3a48a fix(ci): add css module types and resolve typecheck/lint warnings in UI and modals 2026-08-21 00:48:39 +02:00
Thomas Camlong 7a7d4449c1 feat(apps): enhance multi-select app addition with modifier keys and helper tip 2026-08-21 00:31:19 +02:00
Thomas Camlong 1615f88a22 feat(widgets): allow creating and auto-selecting supported integrations directly from widget edit modal 2026-08-21 00:28:03 +02:00
Thomas Camlong 81ea412721 refactor(board): restore classic Mantine Menu for board add actions and remove universalCreate 2026-08-21 00:25:25 +02:00
Thomas Camlong f36f3e4d8a refactor(widgets): revert widget edit presentation from sidebar inspector to standard modal 2026-08-21 00:21:31 +02:00
Thomas Camlong e0bc1d757c refactor(create): modernize UniversalCreateModal with SelectableCard and search 2026-08-21 00:18:35 +02:00
Thomas Camlong 7690e5e542 refactor(board): restore simple add item workflow with SelectableCard and clean modal flow 2026-08-21 00:12:32 +02:00
Thomas Camlong 3ba731da2c refactor(board): remove demo / mock data badge from preview header 2026-08-21 00:07:29 +02:00
Thomas Camlong 689403b323 refactor(integrations): remove use demo service button and bottom-right badge from integration select grid 2026-08-21 00:06:56 +02:00
Thomas Camlong 36a1f8afc2 feat(board): harden Advanced Add App feature and add comprehensive test suites 2026-08-21 00:05:26 +02:00
Thomas Camlong 90bf830e34 feat(board): update widget preview to 5x3 and harden error boundaries 2026-08-20 23:59:53 +02:00
Thomas Camlong b787f21e68 refactor(apps): remove redundant new badge from app select modal create card 2026-08-20 23:43:18 +02:00
Thomas Camlong 829f628fec feat(board): implement advanced add app modal with live preview and remove today widget 2026-08-20 23:42:24 +02:00
60 changed files with 3791 additions and 1839 deletions

No files matched your search

+8 -9
View File
@@ -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
-34
View File
@@ -1,34 +0,0 @@
---
title: "Today"
description: "Displays the current date and progress through the calendar."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { todayWidget } from ".";
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
unambiguous.
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={todayWidget.configuration} />
-43
View File
@@ -1,43 +0,0 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconCalendarWeek } from "@tabler/icons-react";
export const todayWidget: WidgetDefinition = {
icon: IconCalendarWeek,
name: "Today",
description: "Displays the current date and progress through the calendar.",
path: "../../widgets/today",
configuration: {
items: [
{
name: "Week numbering",
description: "Use locale-specific week numbering or ISO 8601 week numbers",
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
defaultValue: "Use locale rules",
},
{
name: "Show week number",
description: "Display the current week number when the widget has room",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show day of year",
description: "Display the current day number within the year",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show quarter",
description: "Display the current calendar quarter",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show year progress",
description: "Display progress through the current year",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
-1
View File
@@ -2106,7 +2106,6 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
.join(" ");
const content = (
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<BoardSelectionProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionProvider>
);
if (representativeWidth === null) return content;
@@ -2,14 +2,19 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
@@ -29,29 +38,69 @@ const AddMenu = () => {
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
addWidget();
openItemSelectModal({ boardId: board.id });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [addWidget, board.id, pathname, router, searchParams]);
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return (
<HeaderButton
aria-label={t("board.action.addContent")}
onClick={() =>
openModal({
boardActions: {
widget: addWidget,
app: addApp,
integration: canConnectService ? connectService : undefined,
container: addContainer,
},
})
}
>
<IconPlus stroke={1.5} />
</HeaderButton>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -0,0 +1,123 @@
/**
* Procedural UI sounds for Homarr using the Web Audio API.
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
*/
let audioContext: AudioContext | null = null;
const getAudioContext = (): AudioContext | null => {
if (typeof window === "undefined") return null;
try {
if (!audioContext) {
const AudioContextClass =
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) audioContext = new AudioContextClass();
}
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
return audioContext;
} catch {
return null;
}
};
/**
* Creates a white noise audio buffer for percussive textures
*/
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
};
/**
* Procedural trash / delete crunch sound.
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
*/
export const playTrashSound = (volume = 0.35) => {
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
// Master Gain
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(volume, now);
masterGain.connect(ctx.destination);
// 1. Downward sweep sub-oscillator (physical thud)
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(260, now);
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
oscGain.gain.setValueAtTime(0.7, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(oscGain);
oscGain.connect(masterGain);
osc.start(now);
osc.stop(now + 0.2);
// 2. High-crunch noise burst (crush / crumple texture)
try {
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = "bandpass";
noiseFilter.frequency.setValueAtTime(1400, now);
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
noiseFilter.Q.setValueAtTime(2.2, now);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.9, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(now);
noiseSource.stop(now + 0.22);
} catch {
// The noise layer is optional; retain the oscillator when buffers are constrained.
}
} catch {
// Sound feedback must never interrupt deleting board items.
}
};
/**
* Procedural select / pop sound.
*/
export const playPopSound = (volume = 0.25) => {
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(460, now);
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.07);
} catch {
// Sound feedback must never interrupt selecting board items.
}
};
@@ -11,6 +11,19 @@
border-color: var(--border-color) !important;
}
.settingsButton {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
:global([data-type="item"]:hover) .settingsButton,
:global([data-type="item"]:focus-within) .settingsButton,
.settingsButton[data-menu-open="true"] {
opacity: 1;
pointer-events: auto;
}
.editModeInertContent {
display: contents;
}
@@ -39,3 +52,9 @@
opacity: 1;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.settingsButton {
transition: none;
}
}
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
size={24}
radius="sm"
pos="absolute"
top={12}
right={8}
top={4}
right={4}
style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
@@ -8,95 +10,254 @@ import {
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set(["sonarr" as const]);
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
it("reports widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
describe("ready status", () => {
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("returns 'ready' when all supported integrations are in availableKinds", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "plex"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds,
connectionOptional: true,
}),
).toBe("ready");
});
});
it("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
describe("needsSetup status", () => {
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr", "sabNzbd"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
});
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set<IntegrationKind>(),
connectionOptional: false,
}),
).toBe("needsSetup");
});
});
it.each([
{ supportedIntegrations: [], connectionOptional: false },
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
"noConnectionRequired",
);
describe("noConnectionRequired status", () => {
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: [],
availableKinds,
connectionOptional: false,
}),
).toBe("noConnectionRequired");
expect(
getWidgetConnectionStatus({
supportedIntegrations: [],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr", "jellyfin"],
availableKinds,
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
});
});
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
describe("selection concurrency locks", () => {
it("acquires lock on unlocked ref and sets current to true", () => {
const lock = { current: false };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(true);
expect(lock.current).toBe(true);
});
it("rejects lock acquisition on an already locked ref", () => {
const lock = { current: true };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(false);
expect(lock.current).toBe(true);
});
it("unlocks lock and allows subsequent acquisition", () => {
const lock = { current: false };
expect(tryLockSelection(lock)).toBe(true);
expect(tryLockSelection(lock)).toBe(false);
unlockSelection(lock);
expect(lock.current).toBe(false);
expect(tryLockSelection(lock)).toBe(true);
});
it("handles repeated unlock safely without throwing", () => {
const lock = { current: false };
expect(() => {
unlockSelection(lock);
unlockSelection(lock);
}).not.toThrow();
expect(lock.current).toBe(false);
});
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
const lock = { current: false };
const results: boolean[] = [];
// Simulate 5 simultaneous selection attempts
for (let i = 0; i < 5; i++) {
results.push(tryLockSelection(lock));
}
expect(results).toEqual([true, false, false, false, false]);
});
});
describe("resolveMatchingIntegrationsAsync", () => {
it("waits for the shared integration query before a cold integration-backed selection", async () => {
const ensureDataAsync = vi.fn(async () => [
{ id: "matching", kind: "sonarr" as const },
{ id: "other", kind: "radarr" as const },
]);
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
});
it("does not wait for integrations when the widget has no integration support", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: [],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([]);
expect(result).toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
];
const ensureDataAsync = vi.fn();
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData,
ensureDataAsync,
});
expect(result).toEqual([currentData[0], currentData[1]]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "sonarr" as IntegrationKind },
{ id: "2", kind: "radarr" as IntegrationKind },
{ id: "3", kind: "sabNzbd" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["radarr", "sabNzbd"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
const currentData = [
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
];
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
});
expect(result).toEqual([currentData[0], currentData[2]]);
});
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "plex" as IntegrationKind },
{ id: "2", kind: "jellyfin" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("propagates error when ensureDataAsync rejects", async () => {
const ensureDataAsync = vi.fn(async () => {
throw new Error("Network timeout");
});
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([currentData[0]]);
).rejects.toThrow("Network timeout");
});
});
@@ -1,20 +0,0 @@
.action {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.action {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record<string, string> = {
"item.create.connectionStatus.ready": "Ready",
"item.create.connectionStatus.needsSetup": "Needs setup",
"item.create.connectionStatus.noConnectionRequired": "No connection required",
"item.create.standalone": "Standalone",
"widget.mediaServer.name": "Media Server",
"widget.mediaServer.description": "Stream your media",
"widget.clock.name": "Clock",
"widget.clock.description": "Displays current time",
};
return translations[key] ?? key;
},
useScopedI18n: () => (key: string) => key,
}));
vi.mock("@homarr/definitions", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
return {
...actual,
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
};
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("Advanced Add App - Modal Subcomponents", () => {
describe("ConnectionStatusBadge", () => {
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="ready" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Ready");
const badge = host.querySelector("span, div");
expect(badge).not.toBeNull();
});
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="needsSetup" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Needs setup");
});
it("renders null when status is 'noConnectionRequired'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="noConnectionRequired" />
</MantineProvider>,
),
);
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
});
});
describe("SupportedIntegrations", () => {
it("renders up to 5 avatars for supported integrations", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// Avatars should be rendered for the 3 items
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
expect(host.textContent).not.toContain("+");
});
it("renders overflow indicator when 6 or more integrations are supported", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// 6 integrations total: 4 shown, +2 in badge
expect(host.textContent).toContain("+2");
});
it("renders Standalone label when integrations list is empty", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={[]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Standalone");
});
});
describe("WidgetItem", () => {
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
it("renders widget card with icon, title, description, and connection status", async () => {
const onSelect = vi.fn();
const item = {
kind: "mediaServer" as WidgetKind,
name: "Media Server",
description: "Stream your media collection effortlessly",
icon: FakeIcon as never,
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(host.textContent).toContain("Media Server");
expect(host.textContent).toContain("Stream your media collection effortlessly");
expect(host.textContent).toContain("Ready");
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
await act(async () => {
button?.click();
});
expect(onSelect).toHaveBeenCalledOnce();
});
it("does not mark widget cards as selected", async () => {
const item = {
kind: "clock" as WidgetKind,
name: "Clock",
description: "Displays current time",
icon: FakeIcon as never,
supportedIntegrations: [] as IntegrationKind[],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem
item={item}
disabled={false}
loading={false}
connectionStatus="noConnectionRequired"
onSelect={vi.fn()}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.hasAttribute("data-selected")).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Avatar,
Badge,
@@ -6,16 +6,18 @@ import {
Button,
Center,
Divider,
Flex,
Group,
Image,
Input,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import {
getIconUrl,
getBoardLaneColumnCount,
getIconUrl,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { SelectableCard } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
initialSearch?: string;
}
export interface WidgetItemData {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
icon: TablerIcon;
name: string;
description: string;
}
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
const ItemSelectModalContent = ({
actions,
integrationData,
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
? targetSectionId
: (placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
useEffect(() => {
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
setTargetSectionId(placementOptions[0]?.value ?? null);
}, [placementOptions, targetSectionId]);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings();
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
[integrationData],
);
const items = useMemo(
const items = useMemo<WidgetItemData[]>(
() =>
widgetKinds
.filter((kind) => kind !== "customApi")
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: targetSectionId ?? undefined,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: targetSectionId ?? undefined,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
};
return (
<Flex gap="md" align="stretch">
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
ariaLabel={t("item.create.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
}
}}
>
{placementOptions.length > 1 && (
<Box style={{ gridColumn: "1 / -1" }}>
<Select
label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
data={placementOptions}
value={targetSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
/>
</Box>
)}
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
<Stack gap="md">
{/* Top Search & Destination Selector */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("item.create.search")}...`}
aria-label={t("item.create.search")}
data-autofocus
onKeyDown={(event) => {
if (
event.key === "Enter" &&
loadingSelection === null &&
filteredItems.length === 1 &&
filteredItems[0]
) {
void handleAdd(filteredItems[0].kind);
}
}}
/>
</Box>
{placementOptions.length > 1 && (
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
<Select
label={t("item.create.destination.label")}
data={placementOptions}
value={effectiveSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
size="xs"
/>
))}
</Box>
)}
</Group>
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
{/* Grid of Widgets */}
<SimpleGrid cols={selectGridCols} spacing="sm">
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
{isAdmin && (
<>
<Divider
label={
<Group gap="xs">
<IconApi size={14} />
<Text size="xs" fw={600}>
{t("customWidget.page.list.title")}
</Text>
</Group>
}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<SelectableCard
key={def.id}
onClick={() => void handleAddCustomWidget(def)}
aria-label={def.name}
icon={
def.iconUrl ? (
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
) : (
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<IconApi size={20} />
</ThemeIcon>
)
}
title={def.name}
description={def.description ?? ""}
footerLeft={
<Text size="xs" c="dimmed">
{t("item.create.customWidget")}
</Text>
}
/>
{filteredCustomWidgets.map((def) => (
<CatalogItem
key={def.id}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
))}
))}
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
</SimpleGrid>
<Box style={{ gridColumn: "1 / -1" }}>
{/* Opens in a new tab so unsaved board changes survive the detour. */}
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
</Box>
</Flex>
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
);
};
// =========================================================================
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
// =========================================================================
export const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: WidgetItemData;
onSelect: () => void;
onFocus?: () => void;
onPointerEnter?: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
return (
<SelectableCard
disabled={disabled}
loading={loading}
onClick={onSelect}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
aria-label={item.name}
icon={
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<item.icon size={20} />
</ThemeIcon>
}
title={item.name}
topRight={<ConnectionStatusBadge status={connectionStatus} />}
description={item.description}
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
/>
);
};
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
if (status === "noConnectionRequired") return null;
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
const t = useI18n();
if (integrations.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("item.create.standalone")}
</Text>
);
}
const countToShow = integrations.length >= 6 ? 4 : 5;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<Stack gap={2}>
{integrations.slice(countToShow).map((integration) => (
<Text key={integration} size="xs">
{getIntegrationName(integration)}
</Text>
))}
</Stack>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)}
</Group>
</Tooltip.Group>
</Group>
);
};
const ItemSelectModalFrame = ({
actions,
innerProps,
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
name: string;
description: string;
icon: TablerIcon;
};
onSelect: () => void;
onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return (
<CatalogItem
height={selectGridCardHeight}
label={item.name}
status={statusLabel}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
onSelect={onSelect}
disabled={disabled}
busy={loading}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{item.name}
</Text>
</Group>
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
</Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
</CatalogItem>
);
};
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
if (integrations.length === 0) {
return null;
}
const countToShow = integrations.length >= 8 ? 6 : 7;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<>
{integrations.slice(countToShow).map((integration) => (
<div key={integration}>{getIntegrationName(integration)}</div>
))}
</>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)}
</Group>
</Tooltip.Group>
</Group>
);
};
@@ -1,16 +1,19 @@
"use client";
import { Suspense, use } from "react";
import { Suspense, use, useCallback, useMemo, useState } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component;
const utils = clientApi.useUtils();
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
const supportedKinds = useMemo(() => {
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
}, [props.innerProps.definition]);
const handleOpenNewIntegration = useCallback(
(onCreated?: (id: string) => void) => {
openIntegrationModal({
allowedKinds: supportedKinds,
onSuccess: (result) => {
if (result?.integration) {
setCreatedIntegrations((prev) => [...prev, result.integration]);
void utils.integration.all.invalidate();
onCreated?.(result.integration.id);
}
},
});
},
[openIntegrationModal, supportedKinds, utils],
);
const combinedIntegrationData = useMemo(() => {
const existing = props.innerProps.integrationData;
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
return [...existing, ...additional];
}, [props.innerProps.integrationData, createdIntegrations]);
return (
<Component
{...props}
innerProps={{
...props.innerProps,
integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}}
/>
);
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -17,6 +17,7 @@ import {
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
@@ -48,6 +49,16 @@ export const FixedGridItem = ({
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id);
const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
event.stopPropagation();
toggleSelectItem(item.id);
};
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item);
@@ -178,6 +189,8 @@ export const FixedGridItem = ({
return (
<Box
{...semantics}
onClickCapture={handleClickCapture}
data-board-item-selected={selected ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditorActive ? undefined : item.id}
@@ -187,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"
@@ -32,6 +32,27 @@
opacity: 0.42;
}
.board-grid-entry[data-board-item-selected="true"] {
z-index: 15;
opacity: 0.56;
filter: saturate(0.72) brightness(0.82);
transition:
opacity 140ms ease,
filter 140ms ease;
}
.board-grid-entry[data-board-item-selected="true"]::after {
position: absolute;
inset: 0;
z-index: 100;
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
border: 0.125rem dashed var(--mantine-primary-color-filled);
border-radius: var(--board-item-radius, var(--mantine-radius-md));
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
pointer-events: none;
}
.board-grid-drag-overlay[data-dnd-dragging] {
position: fixed !important;
z-index: 10000;
@@ -177,138 +198,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-handle {
position: absolute;
z-index: 20;
display: block;
z-index: 25;
display: grid;
padding: 0;
color: var(--mantine-color-dimmed);
appearance: none;
background: transparent;
border: 0;
background: var(--mantine-color-body);
border: 0.0625rem solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
opacity: 0;
scale: 0.88;
place-items: center;
touch-action: none;
transition:
opacity 120ms ease,
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 120ms ease;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
.board-grid-resize-handle::before {
position: absolute;
inset: -0.375rem;
content: "";
}
.board-grid-resize-handle > svg {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
width: 12px;
height: 3px;
content: "";
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 100ms ease;
}
.board-grid-entry:hover > .board-grid-resize-handle::after,
.board-grid-resize-handle:focus-visible::after {
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
border-color: var(--mantine-color-dimmed);
opacity: 1;
}
.board-grid-resize-handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -8px;
scale: 1;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
left: 50%;
width: 1.75rem;
height: 1rem;
border-radius: var(--mantine-radius-xl);
translate: -50% 0;
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: 0;
top: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: 0;
bottom: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
top: 50%;
width: 1rem;
height: 1.75rem;
border-radius: var(--mantine-radius-xl);
translate: 0 -50%;
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: 0;
right: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
width: 3px;
height: 12px;
left: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
.board-grid-resize-handle[data-grid-resize-handle="se"],
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
width: 1.875rem;
height: 1.875rem;
border-radius: var(--board-item-radius, var(--mantine-radius-md));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: 0;
right: 0;
top: -0.625rem;
right: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: 0;
bottom: 0;
right: -0.625rem;
bottom: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: 0;
left: 0;
bottom: -0.625rem;
left: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: 0;
left: 0;
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
transform: translate(-50%, -50%) rotate(45deg);
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle::after {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.board-grid-entry,
.board-grid-placeholder,
.board-grid-resize-outline,
.board-grid-resize-handle::after {
.board-grid-resize-handle {
transition: none;
}
}
@@ -32,6 +32,7 @@ import type {
DragStartEvent,
} from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
}: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { isSelected } = useBoardSelection();
const board = useRequiredBoard();
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
<div
ref={setShellRef}
className="board-grid-entry"
style={getLogicalItemStyle(placement) as CSSProperties}
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -930,6 +940,7 @@ const DndGridEntryComponent = ({
data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
<GridResizeHandles
@@ -1102,19 +1113,27 @@ const GridResizeHandles = ({
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
/>
));
return RESIZE_DIRECTIONS.map((direction) => {
const isHorizontalSide = direction === "n" || direction === "s";
const isVerticalSide = direction === "e" || direction === "w";
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
return (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
>
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
</span>
);
});
};
const getProjectedDragShape = (
@@ -1239,6 +1258,7 @@ const getPreferredRegisteredGridTarget = (
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
if (event.metaKey || event.ctrlKey) return true;
const target = event.target;
const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false;
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
announce: vi.fn(),
commitSectionGrid: vi.fn(),
isSelected: vi.fn(() => false),
toggleSelectItem: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode],
}));
vi.mock("~/components/board/selection/board-selection-context", () => ({
useBoardSelection: () => ({
isSelected: mocks.isSelected,
toggleSelectItem: mocks.toggleSelectItem,
}),
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout",
}));
@@ -0,0 +1,270 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Button, Group, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
interface BoardSelectionContextValue {
selectedItemIds: Set<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { updateAndPersistBoard } = usePersistBoard(board);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: tSelection("saveErrorTitle"),
message: tSelection("saveErrorMessage"),
});
},
});
},
[tSelection, updateAndPersistBoard],
);
// Clear selection if leaving edit mode
useEffect(() => {
if (!isEditMode) {
setSelectedItemIds(new Set());
}
}, [isEditMode]);
// Clear selection on Escape key
useEffect(() => {
if (selectedItemIds.size === 0) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedItemIds(new Set());
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedItemIds.size]);
const toggleSelectItem = useCallback((id: string) => {
setSelectedItemIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
playPopSound();
}, []);
const clearSelection = useCallback(() => {
setSelectedItemIds(new Set());
}, []);
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
const removeSelectedItems = useCallback(() => {
if (selectedItemIds.size === 0) return;
const idsToRemove = Array.from(selectedItemIds);
const count = idsToRemove.length;
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
playTrashSound();
clearSelection();
persistBoard((previous) => ({
...previous,
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
}));
const notificationId = `batch-remove-${Date.now()}`;
showSuccessNotification({
id: notificationId,
title: t("common.success"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{tSelection("removed", { count })}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
persistBoard((previous) => ({
...previous,
items: [...previous.items, ...removedItems],
}));
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
let didMoveAll = false;
persistBoard((previous) => {
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
if (!targetSection || !boardLayout) return previous;
const containerLayout =
targetSection.kind === "container"
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
: undefined;
const columnCount =
targetSection.kind === "container"
? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections
.filter((section): section is ContainerSection => section.kind === "container")
.flatMap((section) => {
const layout = section.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
);
return layout
? [
{
id: section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const existingItems = previous.items.flatMap((item) => {
const layout = item.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
);
return layout
? [
{
id: item.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const occupiedPositions = [...existingContainers, ...existingItems];
const movedLayouts = new Map<string, ItemLayout>();
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!currentItemLayout) continue;
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
const previousPosition =
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
const { width, height } = currentItemLayout;
const emptyPosition =
width <= columnCount && height <= rowCount
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
: undefined;
if (!emptyPosition) {
if (previousPosition) occupiedPositions.push(previousPosition);
continue;
}
const nextLayout: ItemLayout = {
...currentItemLayout,
sectionId: targetSectionId,
xOffset: emptyPosition.xOffset,
yOffset: emptyPosition.yOffset,
};
movedLayouts.set(item.id, nextLayout);
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
}
if (movedLayouts.size !== idsToMove.size) return previous;
didMoveAll = true;
return {
...previous,
items: previous.items.map((item) => {
const movedLayout = movedLayouts.get(item.id);
if (!movedLayout) return item;
return {
...item,
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
};
}),
};
});
if (didMoveAll) {
clearSelection();
} else {
showErrorNotification({
title: tSelection("moveUnavailableTitle"),
message: tSelection("moveUnavailableMessage"),
});
}
},
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
);
const value = useMemo(
() => ({
selectedItemIds,
toggleSelectItem,
clearSelection,
isSelected,
removeSelectedItems,
moveSelectedItemsToSection,
}),
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
);
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
};
export const useBoardSelection = () => {
const context = useContext(BoardSelectionContext);
if (!context) {
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
}
return context;
};
@@ -0,0 +1,255 @@
"use client";
import {
ActionIcon,
Avatar,
Button,
Divider,
Group,
HoverCard,
Menu,
Paper,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useBoardSelection } from "./board-selection-context";
const MAX_AVATARS_DISPLAYED = 5;
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
enabled: hasAppWidgets,
});
if (!isEditMode || selectedItemIds.size === 0) return null;
const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
// Sort items so apps appear first
const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
if (itemA.kind === "app" && itemB.kind !== "app") return -1;
if (itemA.kind !== "app" && itemB.kind === "app") return 1;
return 0;
});
const getItemDetails = (item: (typeof selectedItems)[number]) => {
let displayName = item.advancedOptions.title?.trim();
let iconUrl: string | undefined = undefined;
const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
if (item.kind === "app") {
const appId = (item.options as { appId?: string })?.appId;
const app = appId ? appMap.get(appId) : undefined;
if (!displayName) {
displayName = app?.name || t("widget.app.name");
}
if (app?.iconUrl) {
iconUrl = app.iconUrl;
}
}
if (!displayName) {
displayName = t(`widget.${item.kind}.name`);
}
return { displayName, iconUrl, IconComponent, kind: item.kind };
};
const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
const overflowCount = sortedSelectedItems.length - visibleItems.length;
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
];
return (
<Paper
withBorder
shadow="xl"
radius="xl"
p="xs"
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
style={{
position: "fixed",
bottom: 28,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
width: "max-content",
maxWidth: "calc(100vw - 1rem)",
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
backdropFilter: "blur(14px)",
border: "1px solid var(--mantine-color-default-border)",
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
<Group gap="xs" wrap="nowrap">
{/* Avatar Group with HoverCard Popover showing all items */}
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
<HoverCard.Target>
<UnstyledButton
display="inline-flex"
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
>
<Avatar.Group>
{visibleItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Avatar
key={item.id}
src={iconUrl}
radius="xl"
size={32}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={16} />}
</Avatar>
);
})}
{overflowCount > 0 && (
<Avatar radius="xl" size={32} color="gray" variant="filled">
+{overflowCount}
</Avatar>
)}
</Avatar.Group>
</UnstyledButton>
</HoverCard.Target>
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
<Stack gap={4}>
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
{tSelection("selected", { count: sortedSelectedItems.length })}
</Text>
<ScrollArea.Autosize mah={176} type="auto">
<Stack gap={2}>
{sortedSelectedItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
<Avatar
src={iconUrl}
radius="sm"
size={22}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={12} />}
</Avatar>
<Text size="xs" fw={500} truncate>
{displayName}
</Text>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
{t("app.action.select.multiSelectTip")}
</Text>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
{tSelection("move")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
{tSelection("delete")}
</Button>
<Tooltip label={tSelection("clearShortcut")} withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={clearSelection}
aria-label={tSelection("clear")}
>
<IconX size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,251 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
vi.mock("../audio/board-sounds", () => ({
playPopSound: vi.fn(),
playTrashSound: vi.fn(),
}));
let currentBoardState: Board;
let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
vi.mock("@homarr/boards/context", () => ({
useRequiredBoard: () => currentBoardState,
useCurrentLayout: () => "layout-1",
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [true, vi.fn()],
}));
vi.mock("@homarr/boards/updater", () => ({
usePersistBoard: () => ({
updateAndPersistBoard: (updater: (prev: Board) => Board) => {
mockUpdateBoard(updater);
},
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType<typeof useBoardSelection>) => void }) => {
const selection = useBoardSelection();
onReady(selection);
return <div>Selection Ready</div>;
};
describe("BoardSelectionContext", () => {
it("toggles item selection and clears on clearSelection", async () => {
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
mockUpdateBoard = vi.fn();
let selectionContext!: ReturnType<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
expect(selectionContext.isSelected("item-1")).toBe(false);
await act(async () => {
selectionContext.toggleSelectItem("item-1");
});
expect(selectionContext.isSelected("item-1")).toBe(true);
await act(async () => {
selectionContext.clearSelection();
});
expect(selectionContext.isSelected("item-1")).toBe(false);
});
it("moves selected items to the first available positions in a container section", 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: 4, height: 4 })
.build();
// Existing item inside the container at (0, 0) with 2x2
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
.build();
// Two items to move from emptyRoot
const itemA = new ItemMockBuilder({ id: "item-a" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
.build();
const itemB = new ItemMockBuilder({ id: "item-b" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
.build();
currentBoardState = new BoardMockBuilder()
.addLayout(layout)
.addSection(emptyRoot)
.addSection(container)
.addItem(existingContainerItem)
.addItem(itemA)
.addItem(itemB)
.build();
let updatedBoard: Board = 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");
});
const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
expect(movedA?.sectionId).toBe("container-1");
expect(movedB?.sectionId).toBe("container-1");
// Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
// itemA (2x1) is placed at first empty spot: (2, 0)
expect(movedA?.xOffset).toBe(2);
expect(movedA?.yOffset).toBe(0);
// itemB (2x1) is placed at next first empty spot: (2, 1)
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);
});
});
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
onError() {
showErrorNotification({
title: t("common.error"),
message: t("universalCreate.analyticsError"),
message: t("common.analyticsError"),
});
},
});
@@ -1,77 +0,0 @@
import { describe, expect, test } from "vitest";
import type { RankedUniversalCreateAction } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
describe("getUniversalCreateActionDefinitions", () => {
test("only exposes actions allowed without a board context", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: false,
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
});
test("places globally-created apps in the library group", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
});
test("keeps board content actions available without global create permissions", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
});
test("exposes the complete surface to administrators on a board", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: true,
permissions: ["admin", "app-create", "integration-create", "board-create"],
});
expect(actions.map(({ key }) => key)).toEqual([
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
]);
});
});
describe("filterAndRankUniversalCreateActions", () => {
const actions = [
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
{
key: "integration",
group: "library",
priority: 80,
name: "Connect service",
description: "Create an integration",
keywords: ["provider"],
},
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
] satisfies RankedUniversalCreateAction[];
test("uses product priority when no search is entered", () => {
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
"widget",
"integration",
"board",
]);
});
test("ranks a name prefix ahead of a description or keyword match", () => {
const results = filterAndRankUniversalCreateActions(actions, "create");
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
});
test("matches keywords and removes unrelated actions", () => {
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
});
});
@@ -1,120 +0,0 @@
import type { GroupPermissionKey } from "@homarr/definitions";
export const universalCreateActionKeys = [
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
] as const;
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
export interface UniversalCreateContext {
hasBoardContext: boolean;
permissions: readonly GroupPermissionKey[];
}
interface UniversalCreateActionDefinition {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
isVisible: (context: UniversalCreateContext) => boolean;
}
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
context.permissions.includes(permission);
const definitions: readonly UniversalCreateActionDefinition[] = [
{
key: "widget",
group: "currentBoard",
priority: 100,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "app",
group: "currentBoard",
priority: 90,
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
},
{
key: "integration",
group: "library",
priority: 80,
isVisible: (context) => hasPermission(context, "integration-create"),
},
{
key: "container",
group: "currentBoard",
priority: 70,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "board",
group: "boards",
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
priority: 40,
isVisible: (context) => hasPermission(context, "admin"),
},
{
key: "customWidget",
group: "library",
priority: 30,
isVisible: (context) => hasPermission(context, "admin"),
},
];
export interface RankedUniversalCreateAction {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
name: string;
description: string;
keywords?: readonly string[];
}
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
definitions
.filter((definition) => definition.isVisible(context))
.map((definition) =>
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
);
const normalize = (value: string) => value.trim().toLocaleLowerCase();
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
actions: readonly TAction[],
query: string,
) => {
const normalizedQuery = normalize(query);
return actions
.map((action) => {
const name = normalize(action.name);
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
const relevance =
normalizedQuery.length === 0
? 0
: name === normalizedQuery
? 300
: name.startsWith(normalizedQuery)
? 200
: searchableText.includes(normalizedQuery)
? 100
: -1;
return { action, relevance };
})
.filter(({ relevance }) => relevance >= 0)
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
.map(({ action }) => action);
};
@@ -1,21 +0,0 @@
.action {
width: 100%;
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-sm);
text-align: start;
}
@media (hover: hover) {
.action:hover {
background: var(--mantine-color-default-hover);
}
}
.action:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.actionIcon {
flex: 0 0 auto;
}
@@ -1,5 +0,0 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -1,162 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
IconCode,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
IconSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import classes from "./universal-create-modal.module.css";
import type {
RankedUniversalCreateAction,
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
type BoardActionKey = "widget" | "app" | "integration" | "container";
export interface UniversalCreateModalProps {
boardActions?: Partial<Record<BoardActionKey, () => void>>;
entryPoint?: SetupMetricEntryPoint;
}
const actionIcons = {
widget: IconApps,
app: IconBox,
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
const { data: session } = useSession();
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
const trackSetup = useSetupAnalytics();
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
const definitions = getUniversalCreateActionDefinitions({
hasBoardContext: boardActions !== undefined,
permissions,
});
useEffect(() => {
if (trackedOpen.current) return;
trackedOpen.current = true;
trackSetup("surface-opened", {
entryPoint,
hasBoardContext: boardActions !== undefined,
});
}, [boardActions, entryPoint, trackSetup]);
const invokeAction = (key: UniversalCreateActionKey) => {
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
actions.closeModal();
trackSetup("intent-selected", {
entryPoint,
intent: key,
outcome: "continued",
hasBoardContext: boardActions !== undefined,
});
if (boardAction) {
boardAction();
return;
}
switch (key) {
case "app":
router.push("/manage/apps/new");
break;
case "integration":
router.push("/manage/integrations/new");
break;
case "board":
openAddBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;
case "customWidget":
router.push("/manage/custom-widgets/new");
break;
case "widget":
case "container":
break;
}
};
const resolvedActions = definitions.map(
(definition) =>
({
...definition,
name: t(`action.${definition.key}.label`),
description: t(`action.${definition.key}.description`),
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
{groups.map((group) => {
const groupActions = orderedActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})}
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("universalCreate.title"),
size: "lg",
});
@@ -1,35 +0,0 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { UniversalCreateModal } from "./universal-create-modal";
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
export const UniversalCreateSpotlightResult = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
const item = useMemo(
() => ({
id: "universal-create",
name: t("title"),
description: t("trigger.description"),
icon: IconPlus,
alwaysVisible: true,
interaction: () => ({
type: "javaScript" as const,
onSelect: () => {
openModal({ entryPoint: "spotlight" });
},
}),
}),
[openModal, t],
);
useRegisterSpotlightContextResults("universal-create", [item], [item]);
return null;
};
@@ -1,49 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
import { UniversalCreateModal } from "./universal-create-modal";
export const UniversalCreateHeaderAction = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
return (
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
export const UniversalCreateQueryGate = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { openModal } = useModalAction(UniversalCreateModal);
const handled = useRef(false);
const shouldOpen = searchParams.get("create") === "true";
useEffect(() => {
if (!shouldOpen) {
handled.current = false;
return;
}
if (handled.current) return;
handled.current = true;
openModal({ entryPoint: "spotlight" });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("create");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [openModal, pathname, router, searchParams, shouldOpen]);
return null;
};
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
import { useEffect, useState } from "react";
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";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const directKind =
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null);
const isDirectForm = Boolean(directKind);
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, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
useEffect(() => {
if (step === "form" && !isDirectForm) {
actions.setCloseInterceptor?.(() => {
setStep("select");
setSelectedKind(null);
return false;
});
} else {
actions.setCloseInterceptor?.(null);
}
return () => {
actions.setCloseInterceptor?.(null);
};
}, [step, isDirectForm, actions]);
const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind);
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select");
setSelectedKind(null);
};
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
};
if (step === "form" && selectedKind) {
let HeaderIcon = IconArrowLeft;
let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
return (
<ScrollArea.Autosize mah="80vh">
@@ -79,11 +95,25 @@ 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}
enableMockIntegration={innerProps.enableMockIntegration}
allowedKinds={innerProps.allowedKinds}
integrationData={integrationData}
/>
);
}).withOptions({
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <UniversalCreateHeaderAction />}
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
@@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight />
</AppShellHeader>
);
@@ -336,7 +336,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/today"
| "/docs/widgets/air-quality"
| "/docs/widgets/countdown"
| "/docs/widgets/timer"
@@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
today: "today",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
-2
View File
@@ -1,7 +1,6 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
@@ -63,7 +62,6 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
@@ -0,0 +1,438 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppSelectModal } from "./app-select-modal";
const AppSelectModalComponent = AppSelectModal.component;
const sampleApps = [
{
id: "app-1",
name: "Sonarr",
description: "Manage TV series downloads",
iconUrl: "https://example.com/sonarr.png",
href: "http://sonarr.local",
},
{
id: "app-2",
name: "Radarr",
description: "Manage movie collections",
iconUrl: "https://example.com/radarr.png",
href: "http://radarr.local",
},
{
id: "app-3",
name: "Plex",
description: "Stream your media collection",
iconUrl: "https://example.com/plex.png",
href: "http://plex.local",
},
];
const mockUseQuery = vi.fn(() => ({
data: sampleApps,
isPending: false,
}));
const mockOpenQuickAddAppModal = vi.fn();
vi.mock("@homarr/api/client", () => ({
clientApi: {
app: {
selectable: {
useQuery: () => mockUseQuery(),
},
},
},
}));
vi.mock("@homarr/modals", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
return {
...actual,
useModalAction: () => ({
openModal: mockOpenQuickAddAppModal,
}),
};
});
vi.mock("@homarr/translation/client", () => ({
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",
"common.action.add": "Add",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
vi.clearAllMocks();
mockUseQuery.mockReturnValue({
data: sampleApps,
isPending: false,
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("AppSelectModal", () => {
describe("single selection mode", () => {
it("renders app cards with name, description, and icon", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("Plex");
expect(host.textContent).toContain("Manage TV series downloads");
});
it("selects an app and closes modal when card is clicked", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
expect(sonarrCard).toBeDefined();
await act(async () => {
sonarrCard?.click();
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
});
describe("multi selection mode", () => {
it("toggles multiple app selections and submits batch on button click", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
// Modal stays open, 1 app selected
expect(closeModal).not.toHaveBeenCalled();
expect(host.textContent).toContain("1 app selected");
// Select Radarr
await act(async () => {
radarrCard?.click();
});
expect(host.textContent).toContain("2 apps selected");
// Submit multi select
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
expect(submitBtn).toBeDefined();
await act(async () => {
submitBtn?.click();
});
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("unselects an app when clicked a second time", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
// Unselect Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).not.toContain("1 app selected");
});
it("clears selection when Discard button is clicked", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Discard"),
);
await act(async () => {
discardBtn?.click();
});
expect(host.textContent).not.toContain("app selected");
});
});
describe("search and filtering", () => {
it("filters apps in real-time by search query (case-insensitive)", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "plex"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "plex");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("Plex");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("selects the single matching app when Enter key is pressed in search input", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "sonarr");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("displays empty state message when search yields no results", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "non-matching-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No apps found");
});
});
describe("custom app creation card", () => {
it("renders custom app creation card when withCreate is true", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Add custom app");
expect(host.textContent).toContain("Configure custom URL and icon");
});
it("omits custom app creation card when withCreate is false", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).not.toContain("Add custom app");
});
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Add custom app"),
);
expect(createCard).toBeDefined();
await act(async () => {
createCard?.click();
});
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
// Trigger the onClose callback passed to QuickAddAppModal
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
const newCustomApp = {
id: "custom-app-1",
name: "Custom Home Assistant",
description: "Local instance",
iconUrl: "https://example.com/ha.png",
href: "http://ha.local",
};
await act(async () => {
options.onClose(newCustomApp);
});
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
expect(closeModal).toHaveBeenCalledOnce();
});
});
});
@@ -1,12 +1,25 @@
import { useMemo, useState } from "react";
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
import {
Badge,
Button,
Center,
Group,
Image,
Input,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
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 { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
const handleSelect = (app: SelectableApp) => {
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
innerProps.onSelect(app);
actions.closeModal();
return;
}
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
else next.add(app.id);
return next;
});
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleSubmit = () => {
if (multiSelect) {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
return;
}
const [first] = filteredApps;
if (first) {
innerProps.onSelect?.(first);
actions.closeModal();
}
};
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
});
};
const handleMultiSubmit = () => {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
};
return (
<>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("app.action.select.search")}...`}
ariaLabel={t("app.action.select.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
}}
>
{innerProps.withCreate && (
<CatalogItem
height={selectGridCardHeight}
label={t("app.action.create.title")}
status={t("app.action.create.action")}
onSelect={handleAddNewApp}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.create.title")}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{t("app.action.create.description")}
</Text>
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.create.action")}
</Text>
</Stack>
</CatalogItem>
)}
{filteredApps.map((app) => (
<CatalogItem
key={app.id}
height={selectGridCardHeight}
label={app.name}
status={
multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })
<Stack gap="md">
{/* Top Search Input & Multi-Select Tip */}
<Stack gap={6}>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("app.action.select.search")}...`}
aria-label={t("app.action.select.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
onSelect={() => handleSelect(app)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{app.name}
</Text>
{multiSelect && selectedAppIds.has(app.id) && (
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
)}
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
</Text>
</Tooltip>
<Text size="xs" c="blue" fw={500} mt="auto">
{multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })}
</Text>
</Stack>
</CatalogItem>
))}
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
}}
/>
{multiSelect && (
<Group justify="space-between" px={2}>
<Text size="xs" c="dimmed">
{t("app.action.select.multiSelectTip")}
</Text>
{selectedAppIds.size > 0 && (
<Badge variant="light" color="primaryColor" size="xs">
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
</Badge>
)}
</Group>
)}
</SelectGridLayout>
{multiSelect && (
<Group justify="flex-end" mt="md">
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
</Button>
</Group>
</Stack>
{/* Scrollable Container with App Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{innerProps.withCreate && (
<SelectableCard
onClick={handleAddNewApp}
style={{ borderStyle: "dashed" }}
icon={
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
<IconPlus size={20} />
</ThemeIcon>
}
title={t("app.action.create.title")}
description={t("app.action.create.description")}
footerLeft={
<Text size="xs" c="dimmed">
{t("app.action.select.customApplication")}
</Text>
}
/>
)}
{filteredApps.map((app) => (
<AppCard
key={app.id}
app={app}
isSelected={selectedAppIds.has(app.id)}
multiSelect={multiSelect}
onSelect={handleSelect}
/>
))}
</SimpleGrid>
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
{/* Multi-Select Action Footer */}
{multiSelect && selectedApps.length > 0 && (
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{t("app.action.select.appsSelected", { count: selectedApps.length })}
</Text>
<Group gap="xs">
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
{t("common.action.discard")}
</Button>
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
{t("common.action.add")} ({selectedApps.length})
</Button>
</Group>
</Group>
</Paper>
)}
</>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
size: modalSizeSelect,
});
// =========================================================================
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
// =========================================================================
const AppCard = ({
app,
isSelected,
multiSelect,
onSelect,
}: {
app: SelectableApp;
isSelected: boolean;
multiSelect: boolean;
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={(event) => onSelect(app, event)}
aria-label={app.name}
selected={isSelected}
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
title={app.name}
topRight={
isSelected ? (
<Badge variant="dot" color="primaryColor" size="xs">
{t("app.action.select.selected")}
</Badge>
) : null
}
description={app.description}
footerLeft={
<Text size="xs" c="dimmed">
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
</Text>
}
/>
);
};
+8 -2
View File
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
content: presentationClasses.inspectorContent,
});
const handleClose = useCallback(() => {
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
if (canClose) {
handleCloseModal();
}
}, [handleCloseModal, modal.reference]);
return (
<Modal
key={modal.id}
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
{...otherModalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={opened}
onClose={handleCloseModal}
onClose={handleClose}
>
{modal.reference.content}
</Modal>
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
return {
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
// void actually is undefined
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
context.openModalInner({ modal, innerProps, options: options ?? {} });
},
};
+11
View File
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props;
let closeInterceptor: (() => boolean) | null = null;
const FullModal = () => {
const context = useContext(ModalContext);
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
innerProps={innerProps}
actions={{
closeModal: () => context.closeModal(modal.id),
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
closeInterceptor = interceptor;
},
}}
/>
);
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
return {
modalProps: rest,
content: <FullModal />,
onCloseRequest: () => {
if (closeInterceptor) {
return closeInterceptor();
}
return true;
},
};
};
+6 -4
View File
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { ModalPresentation } from "./modal-presentation";
export type ModalComponent<TInnerProps> = (props: {
actions: { closeModal: () => void };
innerProps: TInnerProps;
}) => ReactNode;
export interface ModalActions {
closeModal: () => void;
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
}
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
export type CreateModalOptions = Pick<
ModalOptions<unknown>,
+28 -101
View File
@@ -236,61 +236,6 @@
}
}
},
"universalCreate": {
"title": "Create",
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
"trigger": {
"label": "Create something",
"description": "Add content, connect a service, or create a board from one place."
},
"search": {
"label": "Search create actions",
"placeholder": "What do you want to create?",
"noResults": "No matching actions"
},
"group": {
"currentBoard": "Current board",
"library": "Apps and services",
"boards": "Boards"
},
"action": {
"widget": {
"label": "Add widget",
"description": "Add live information or controls to this board.",
"keywords": "item dashboard content"
},
"app": {
"label": "Add or create app",
"description": "Place an app shortcut on this board or create one for your library.",
"keywords": "shortcut bookmark application"
},
"integration": {
"label": "Connect service",
"description": "Connect a service so widgets can use its data.",
"keywords": "integration provider connection"
},
"container": {
"label": "Add container",
"description": "Group related board content into a resizable section.",
"keywords": "section group layout"
},
"board": {
"label": "Create board",
"description": "Start a new dashboard and add its first widget.",
"keywords": "dashboard page"
},
"workshop": {
"label": "Browse Workshop",
"description": "Discover community-built widgets and install them.",
"keywords": "community discover install"
},
"customWidget": {
"label": "Create custom widget",
"description": "Build a widget tailored to your own workflow.",
"keywords": "code jsx developer"
}
}
},
"user": {
"title": "Users",
"name": "User",
@@ -876,7 +821,12 @@
"title": "Select an app to add to this board",
"toggle": "Click to select",
"selected": "Selected",
"multiple": "Add {count}"
"multiple": "Add {count}",
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
"customApplication": "Custom application",
"application": "Application"
},
"create": {
"title": "Create new app",
@@ -1721,7 +1671,11 @@
"grid": {
"widgetCount": "{count} widgets",
"noWidgets": "No widgets",
"detected": "Detected via Docker"
"detected": "Detected via Docker",
"connected": "{count, plural, one {# connected} other {# connected}}",
"tiedWidgets": "Tied widgets",
"more": "+{count} more",
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
},
"category": {
"dnsHole": "DNS",
@@ -2135,6 +2089,7 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
"analyticsError": "Setup analytics could not be recorded.",
"action": {
"add": "Add",
"apply": "Apply",
@@ -2810,6 +2765,8 @@
"needsSetup": "Needs setup",
"noConnectionRequired": "No connection required"
},
"customWidget": "Custom widget",
"standalone": "Standalone",
"missingIntegration": {
"title": "Connect a service first",
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
@@ -2826,6 +2783,20 @@
}
}
},
"selection": {
"summary": "{count, plural, one {# selected item} other {# selected items}}",
"selected": "{count, plural, one {# selected} other {# selected}}",
"removed": "{count, plural, one {# item removed} other {# items removed}}",
"move": "Move",
"moveTo": "Move selected items",
"delete": "Delete",
"clear": "Clear selection",
"clearShortcut": "Clear selection (Escape)",
"saveErrorTitle": "Unable to update items",
"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",
"entryLabel": "{name}, column {column}, row {row}",
@@ -3465,50 +3436,6 @@
"notificationsDenied": "Notifications are blocked in this browser."
}
},
"today": {
"name": "Today",
"description": "Displays the current date and progress through the calendar.",
"option": {
"weekConvention": {
"label": "Week numbering",
"options": {
"locale": "Use locale rules",
"iso": "ISO 8601"
}
},
"showWeekNumber": {
"label": "Show week number"
},
"showDayOfYear": {
"label": "Show day of year"
},
"showQuarter": {
"label": "Show quarter"
},
"showYearProgress": {
"label": "Show year progress"
}
},
"weekNumber": "Week {week}",
"dayOfYear": "Day {day} of {total}",
"quarter": "Quarter {quarter}",
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
"weekProgressLabel": "Week progress",
"monthProgressLabel": "Month progress",
"yearProgressLabel": "Year progress",
"yearProgress": "Year progress: {progress}%",
"weekConvention": {
"locale": "Week numbers follow your locale",
"iso": "ISO 8601 week numbers"
},
"advanced": {
"week": "Week number",
"dayOfYear": "Day of year",
"quarter": "Quarter",
"quarterValue": "Q{quarter}",
"daysRemaining": "Days after today"
}
},
"minecraftServerStatus": {
"name": "Minecraft Server Status",
"description": "Displays the status of a Minecraft server",
+7
View File
@@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge";
export { CatalogItem } from "./catalog-item";
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
@@ -0,0 +1,7 @@
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
@@ -0,0 +1,79 @@
.root {
cursor: pointer;
text-align: start;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
min-height: 175px;
height: 100%;
padding: var(--mantine-spacing-sm);
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
overflow: hidden;
font-family: inherit;
color: inherit;
text-decoration: none;
}
.root:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
border-color: var(--mantine-primary-color-filled);
}
.root:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.root[data-selected] {
border-color: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.root:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.header {
border-bottom: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-bottom: 0;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
}
.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
min-height: 0;
}
.footer {
border-top: 1px dashed var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-top: auto;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
min-height: 40px;
display: flex;
align-items: center;
}
@@ -0,0 +1,7 @@
declare const classes: {
readonly root: string;
readonly header: string;
readonly body: string;
readonly footer: string;
};
export default classes;
@@ -0,0 +1,370 @@
// @vitest-environment jsdom
import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { Badge, MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SelectableCard } from "./selectable-card";
import classes from "./selectable-card.module.css";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("SelectableCard", () => {
describe("rendering slots", () => {
it("renders as a native button with default button type and accessible slots", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
aria-label="Sonarr Card"
icon={<span data-testid="card-icon">Icon</span>}
title="Sonarr"
topRight={<Badge data-testid="card-topright">Media</Badge>}
description="Manage TV series downloads"
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
onClick={onClick}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(button?.type).toBe("button");
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
expect(host.textContent).toContain("Sonarr");
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
expect(host.textContent).toContain("Manage TV series downloads");
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
await act(async () => button?.click());
expect(onClick).toHaveBeenCalledOnce();
});
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
icon={<span data-testid="only-icon">I</span>}
title="Only Header"
topRight={<span data-testid="only-topright">TR</span>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
});
it("renders footer when only footerLeft or only footerRight is provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
});
it("applies Styles API classes for root, header, body, and footer", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Plex"
description="Media Server"
footerLeft={<span>Footer</span>}
classNames={{
root: "custom-root",
header: "custom-header",
body: "custom-body",
footer: "custom-footer",
}}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.classList.contains(classes.root)).toBe(true);
expect(button?.classList.contains("custom-root")).toBe(true);
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
expect(header?.classList.contains("custom-header")).toBe(true);
const body = button?.querySelector(`.${classes.body}`);
expect(body).not.toBeNull();
expect(body?.classList.contains("custom-body")).toBe(true);
const footer = button?.querySelector(`.${classes.footer}`);
expect(footer).not.toBeNull();
expect(footer?.classList.contains("custom-footer")).toBe(true);
});
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Very Long Title Name That Should Not Wrap"
topRight={<Badge>Category Badge</Badge>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
const titleText = header?.querySelector("p, span, div");
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title={<span data-testid="custom-title-node">Custom Title Element</span>}
topRight={<span data-testid="custom-top-right">Top Right</span>}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
});
it("renders custom children in the body slot over description", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" description="This should not appear">
<div data-testid="custom-body-children">Custom Child Content</div>
</SelectableCard>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
expect(host.textContent).toContain("Custom Child Content");
expect(host.textContent).not.toContain("This should not appear");
});
it("renders an empty description without fallback copy", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="No Desc Item" description="" />
</MantineProvider>,
),
);
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 () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
});
});
describe("states", () => {
it("handles selected state via data-selected attribute", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Selected Item" selected />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-selected")).toBe("true");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Unselected Item" selected={false} />
</MantineProvider>,
),
);
expect(button?.hasAttribute("data-selected")).toBe(false);
});
it("handles disabled state and prevents click handlers", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Loading Item" loading onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
});
describe("interactions and accessibility", () => {
it("supports focus and click activation", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Interactive Card" onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
button?.focus();
expect(document.activeElement).toBe(button);
await act(async () => {
button?.click();
});
expect(onClick).toHaveBeenCalledOnce();
});
it("forwards ref to the underlying HTMLButtonElement", async () => {
const ref = createRef<HTMLButtonElement>();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard ref={ref} title="Ref Card" />
</MantineProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
expect(ref.current?.tagName).toBe("BUTTON");
});
it("resolves radius CSS variables via varsResolver", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card" radius="lg" />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card Sm" radius="sm" />
</MantineProvider>,
),
);
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
});
it("passes arbitrary HTML attributes down to the button element", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Custom Props Card"
data-testid="my-selectable-card"
data-custom="custom-value"
tabIndex={0}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
expect(button?.getAttribute("data-custom")).toBe("custom-value");
expect(button?.tabIndex).toBe(0);
});
});
});
@@ -0,0 +1,177 @@
"use client";
import type { ReactNode } from "react";
import {
Box,
createVarsResolver,
factory,
Group,
LoadingOverlay,
Text,
Tooltip,
useProps,
useStyles,
} from "@mantine/core";
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
import classes from "./selectable-card.module.css";
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
export type SelectableCardCssVariables = {
root: "--selectable-card-radius";
};
export interface SelectableCardProps
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
icon?: ReactNode;
title?: ReactNode;
topRight?: ReactNode;
description?: string | null;
footerLeft?: ReactNode;
footerRight?: ReactNode;
selected?: boolean;
loading?: boolean;
disabled?: boolean;
radius?: MantineRadius;
children?: ReactNode;
}
export type SelectableCardFactory = Factory<{
props: SelectableCardProps;
ref: HTMLButtonElement;
stylesNames: SelectableCardStylesNames;
vars: SelectableCardCssVariables;
}>;
const defaultProps: Partial<SelectableCardProps> = {
type: "button",
};
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
root: {
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
},
}));
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
const props = useProps("SelectableCard", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
icon,
title,
topRight,
description,
footerLeft,
footerRight,
selected,
loading = false,
disabled = false,
children,
type = "button",
...others
} = props;
const getStyles = useStyles<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Box
component="button"
{...others}
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</Text>
) : (
title
)}
</Group>
{topRight && (
<Box
style={{
minWidth: 0,
flexShrink: 0,
}}
>
{topRight}
</Box>
)}
</Group>
</Box>
)}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description}
</Text>
</Tooltip>
</Box>
) : null}
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;
@@ -0,0 +1,279 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "./integration-select-grid";
vi.mock("@homarr/definitions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@homarr/definitions")>();
return {
...actual,
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
if (kind === "wud") return [];
return actual.getWidgetKindsForIntegration(kind);
},
};
});
vi.mock("@homarr/translation/client", () => ({
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",
"integration.category.mediaService": "Media Service",
"integration.category.downloadClient": "Download Client",
"integration.category.calendar": "Calendar",
"integration.category.smartHomeServer": "Smart Home",
"integration.category.usenet": "Usenet",
"integration.category.torrent": "Torrent",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("IntegrationSelectGrid", () => {
it("renders a 4-column responsive grid of selectable integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
</MantineProvider>,
),
);
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(4);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("SABnzbd");
});
it("renders tied widgets with icons and labels inside integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Tied Widgets");
// Jellyfin is tied to Media Server widget
expect(host.textContent).toContain("Media Server");
});
it("displays fallback message when integration has no tied widgets", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
</MantineProvider>,
),
);
expect(host.querySelector("button")).not.toBeNull();
expect(host.textContent).toContain("No widgets tied to this service");
});
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
const onSelect = vi.fn();
const integrationData = [
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
];
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid
onSelect={onSelect}
allowedKinds={["jellyfin", "radarr"]}
integrationData={integrationData}
/>
</MantineProvider>,
),
);
// Jellyfin has 2 connected
expect(host.textContent).toContain("2 connected");
// Radarr has 0 connected, so it doesn't show "0 connected"
expect(host.textContent).not.toContain("0 connected");
});
it("filters integrations dynamically by search input", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "jelly"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(1);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("displays empty state when no integrations match search query", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No results found");
expect(host.querySelectorAll("button").length).toBe(0);
});
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith("jellyfin");
});
it("calls onSelect when an integration card is clicked", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
</MantineProvider>,
),
);
const cardButton = host.querySelector("button");
await act(async () => cardButton?.click());
expect(onSelect).toHaveBeenCalledWith("sonarr");
});
it("includes mock integration when enableMockIntegration is true", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Mock");
expect(host.textContent).toContain("Sonarr");
});
it("excludes mock integration when enableMockIntegration is false", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
</MantineProvider>,
),
);
const buttons = Array.from(host.querySelectorAll("button"));
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
expect(mockButton).toBeUndefined();
});
});
@@ -1,27 +1,68 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import {
IconAd,
IconApps,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconGraphFilled,
IconHeartRateMonitor,
IconHourglass,
IconMessage,
IconMovie,
IconNotes,
IconPuzzle,
IconReportSearch,
IconRobot,
IconRocket,
IconRss,
IconSearch,
IconServer2,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import classes from "./integration-select-grid.module.css";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
integrationData?: { kind: IntegrationKind; name?: string }[];
}
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
integrationData,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
ariaLabel={t("integration.page.list.search")}
>
{filtered.map((integration) => (
<UnstyledButton
className={classes.choice}
key={integration.kind}
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
>
<Card h={selectGridCardHeight} className={classes.card} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<IntegrationGridMeta integration={integration} />
</Stack>
</Card>
</UnstyledButton>
))}
{filtered.length === 0 && (
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
const t = useI18n();
return (
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((category) => (
<Badge key={category} variant="light" size="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
<Stack gap="md">
{/* Top Search Input */}
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("integration.page.list.search")}...`}
aria-label={t("integration.page.list.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
onSelect(filtered[0].kind);
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
}}
/>
{/* Scrollable Container with Integration Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{filtered.map((integration) => (
<IntegrationCard
key={integration.kind}
integration={integration}
onSelect={onSelect}
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
/>
))}
</SimpleGrid>
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
};
// =========================================================================
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
// =========================================================================
const IntegrationCard = ({
integration,
onSelect,
connectedCount = 0,
}: {
integration: IntegrationGridItem;
onSelect: (kind: IntegrationKind) => void;
connectedCount?: number;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={integration.name}
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
{t("integration.grid.connected", { count: connectedCount })}
</Badge>
) : null
}
>
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
{t("integration.grid.tiedWidgets")}
</Text>
<IntegrationTiedWidgets widgets={integration.widgets} />
</SelectableCard>
);
};
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
};
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
const t = useI18n();
if (widgets.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("integration.grid.noWidgets")}
</Text>
);
}
const items = limit ? widgets.slice(0, limit) : widgets;
const moreCount = limit ? widgets.length - limit : 0;
return (
<Group gap={4} wrap="wrap">
{items.map((widgetKind) => {
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
return (
<Badge
key={widgetKind}
variant="default"
radius="xs"
size="xs"
leftSection={<WidgetIcon size={12} />}
style={{ fontWeight: 500 }}
>
{t(`widget.${widgetKind}.name`)}
</Badge>
);
})}
{moreCount > 0 && (
<Badge variant="subtle" color="gray" size="xs" radius="xs">
{t("integration.grid.more", { count: moreCount })}
</Badge>
)}
</Group>
);
};
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
+3 -1
View File
@@ -13,7 +13,9 @@
},
"exports": {
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./inputs": "./src/_inputs/index.ts",
"./inputs/form": "./src/_inputs/form.ts",
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
@@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
<Text component="span" size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
here: renderHereLink,
})}
</Text>
}
+2 -1
View File
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,6 +50,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab],
);
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
-2
View File
@@ -15,7 +15,6 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
@@ -60,7 +59,6 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
-2
View File
@@ -63,7 +63,6 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as today from "./today";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
@@ -98,7 +97,6 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
today,
airQuality,
countdown,
timer,
-2
View File
@@ -24,7 +24,6 @@ export type WidgetResources = {
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
today: () => import("./today") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>,
@@ -89,7 +88,6 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
today: () => import("./today/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
@@ -58,6 +58,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
}
export const getSelectedWidgetIntegrations = (
@@ -217,6 +218,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
handleSubmit(event);
};
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
? () => {
innerProps.onOpenNewIntegration?.((newId) => {
if (newId) {
form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
}
});
}
: undefined;
const widgetFormContent = (
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
@@ -225,6 +237,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
@@ -413,6 +426,5 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
-223
View File
@@ -1,223 +0,0 @@
"use client";
import {
Badge,
Box,
Group,
Paper,
Progress,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../common/locale";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { getTodayLayout, getTodayStats } from "./date-stats";
const formatProgress = (value: number) => Math.round(value);
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
const now = useWidgetNow("minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.today");
if (now === null) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const stats = getTodayStats(now, options.weekConvention, locale);
if (displayMode === "advanced") {
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
}
const layout = getTodayLayout(width, height);
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
const month = formatLocalizedDate(now, locale, { month: "long" });
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
if (layout.tiny) {
return (
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday} · {month}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
{now.getDate()}
</Text>
</Stack>
);
}
return (
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
</Text>
</Stack>
{options.showYearProgress && (
<Box>
<RingProgress
aria-hidden
size={54}
thickness={5}
sections={[{ value: stats.yearProgress, color: "blue" }]}
label={
<Text size="xs" ta="center" fw={700}>
{formatProgress(stats.yearProgress)}%
</Text>
}
/>
<VisuallyHidden>
<progress value={stats.yearProgress} max={100}>
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
</progress>
</VisuallyHidden>
</Box>
)}
</Group>
{layout.showStats && (
<Group gap={6} wrap="wrap">
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
{options.showDayOfYear && (
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
)}
{layout.showQuarter && options.showQuarter && (
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
)}
{layout.showQuarter && (
<Text size="xs" c="dimmed">
{t("daysAfterToday", { days: stats.daysAfterToday })}
</Text>
)}
</Group>
)}
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
</Stack>
);
}
interface AdvancedTodayProps {
now: Date;
locale: string;
options: WidgetComponentProps<"today">["options"];
stats: ReturnType<typeof getTodayStats>;
}
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
const t = useScopedI18n("widget.today");
const numberFormatter = new Intl.NumberFormat(locale);
const visibleMetrics = [
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
options.showDayOfYear && {
label: t("advanced.dayOfYear"),
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
},
options.showQuarter && {
label: t("advanced.quarter"),
value: t("advanced.quarterValue", { quarter: stats.quarter }),
},
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Stack gap={0}>
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
</Text>
{options.showWeekNumber && (
<Text size="sm" c="dimmed">
{t(`weekConvention.${options.weekConvention}`)}
</Text>
)}
</Stack>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{visibleMetrics.map((metric) => (
<Paper key={metric.label} withBorder p="sm">
<Text size="xs" c="dimmed">
{metric.label}
</Text>
<Text fz="xl" fw={700}>
{metric.value}
</Text>
</Paper>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
</SimpleGrid>
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
{Array.from({ length: 12 }, (_, monthIndex) => {
const date = new Date(now.getFullYear(), monthIndex, 1);
const isCurrent = monthIndex === stats.monthIndex;
return (
<Paper
key={monthIndex}
withBorder
p="xs"
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
>
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
{formatLocalizedDate(date, locale, { month: "short" })}
</Text>
{isCurrent && (
<Text size="xs" c="dimmed" ta="center">
{now.getDate()}
</Text>
)}
</Paper>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
const roundedValue = formatProgress(value);
return (
<Box>
<Group justify="space-between" gap="xs" mb={3}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600}>
{roundedValue}%
</Text>
</Group>
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
</Box>
);
};
const toDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { getTodayLayout, getTodayStats } from "./date-stats";
describe("today date statistics", () => {
it("accounts for leap years", () => {
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
expect(stats.dayOfYear).toBe(366);
expect(stats.daysInYear).toBe(366);
expect(stats.daysAfterToday).toBe(0);
});
it("uses the requested week convention", () => {
const date = new Date(2023, 0, 1, 12);
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
});
it("returns bounded calendar progress", () => {
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
expect(stats.quarter).toBe(3);
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
expect(stats.weekProgress).toBeLessThanOrEqual(100);
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
expect(stats.monthProgress).toBeLessThanOrEqual(100);
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
expect(stats.yearProgress).toBeLessThanOrEqual(100);
});
});
describe("today compact layout", () => {
it("keeps tiny widgets focused on the date", () => {
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
});
it("reveals secondary information only when both axes have room", () => {
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
expect(getTodayLayout(300, 180)).toEqual({
tiny: false,
showFullDate: true,
showStats: true,
showQuarter: true,
showMonthProgress: true,
});
});
});
-172
View File
@@ -1,172 +0,0 @@
import dayjs from "dayjs";
import dayOfYear from "dayjs/plugin/dayOfYear";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
dayjs.extend(dayOfYear);
dayjs.extend(quarterOfYear);
export type WeekConvention = "locale" | "iso";
export interface TodayStats {
dayOfYear: number;
daysInYear: number;
daysAfterToday: number;
monthIndex: number;
quarter: number;
weekNumber: number;
weekProgress: number;
monthProgress: number;
yearProgress: number;
}
const getProgress = (value: number, start: number, end: number) => {
if (end <= start) return 0;
return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
};
export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
const value = dayjs(date);
const yearStart = value.startOf("year");
const nextYearStart = yearStart.add(1, "year");
const monthStart = value.startOf("month");
const nextMonthStart = monthStart.add(1, "month");
const weekRules = getWeekRules(weekConvention, locale);
const weekDay = value.day() === 0 ? 7 : value.day();
const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
const currentDayOfYear = value.dayOfYear();
const daysInYear = yearStart.endOf("year").dayOfYear();
return {
dayOfYear: currentDayOfYear,
daysInYear,
daysAfterToday: daysInYear - currentDayOfYear,
monthIndex: value.month(),
quarter: value.quarter(),
weekNumber: getWeekNumber(date, weekRules),
weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
};
};
interface WeekRules {
firstDay: number;
minimalDays: number;
}
interface LocaleWeekInfo {
firstDay: number;
minimalDays?: number;
}
type LocaleWithWeekInfo = Intl.Locale & {
weekInfo?: LocaleWeekInfo;
getWeekInfo?: () => LocaleWeekInfo;
};
const fourDayMinimumRegions = new Set([
"AD",
"AN",
"AT",
"AX",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FJ",
"FO",
"FR",
"GF",
"GB",
"GG",
"GI",
"GP",
"GR",
"IE",
"IM",
"IS",
"IT",
"LI",
"LT",
"LU",
"MC",
"MQ",
"NL",
"NO",
"PL",
"PT",
"RE",
"RU",
"SE",
"SJ",
"SK",
"VA",
]);
const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
try {
const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
if (weekInfo) {
const region = localeInfo.maximize().region;
const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
}
} catch {
// Invalid locales fall back to the common US week convention.
}
return { firstDay: 7, minimalDays: 1 };
};
const getWeekNumber = (date: Date, rules: WeekRules): number => {
const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
let weekYear = date.getFullYear();
let firstWeekStart = getFirstWeekStart(weekYear, rules);
if (dateDay < firstWeekStart) {
weekYear -= 1;
firstWeekStart = getFirstWeekStart(weekYear, rules);
} else {
const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
if (dateDay >= nextFirstWeekStart) return 1;
}
return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
};
const getFirstWeekStart = (year: number, rules: WeekRules): number => {
const januaryFirst = Date.UTC(year, 0, 1);
const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
const daysInFirstWeek = 7 - daysBeforeJanuary;
if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
return januaryFirst + daysInFirstWeek * 86_400_000;
};
export interface TodayLayout {
tiny: boolean;
showFullDate: boolean;
showStats: boolean;
showQuarter: boolean;
showMonthProgress: boolean;
}
export const getTodayLayout = (width: number, height: number): TodayLayout => {
const tiny = width < 150 || height < 96;
return {
tiny,
showFullDate: !tiny && width >= 180,
showStats: !tiny && width >= 210 && height >= 115,
showQuarter: width >= 240 && height >= 145,
showMonthProgress: width >= 300 && height >= 170,
};
};
-24
View File
@@ -1,24 +0,0 @@
import { IconCalendarWeek } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("today", {
icon: IconCalendarWeek,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
weekConvention: factory.select({
defaultValue: "locale",
options: [
{ value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
{ value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
],
}),
showWeekNumber: factory.switch({ defaultValue: true }),
showDayOfYear: factory.switch({ defaultValue: true }),
showQuarter: factory.switch({ defaultValue: true }),
showYearProgress: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -32,6 +32,7 @@ interface WidgetIntegrationSelectProps {
canSelectMultiple?: boolean;
data: IntegrationSelectOption[];
withAsterisk?: boolean;
onOpenNewIntegration?: () => void;
}
export const WidgetIntegrationSelect = ({
data,
@@ -40,6 +41,7 @@ export const WidgetIntegrationSelect = ({
label,
canSelectMultiple = true,
withAsterisk = false,
onOpenNewIntegration,
...props
}: WidgetIntegrationSelectProps) => {
const t = useI18n();
@@ -109,11 +111,19 @@ export const WidgetIntegrationSelect = ({
<PillsInput
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs" span>
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
onOpenNewIntegration ? (
<Text size="xs" span>
<Anchor size="xs" component="button" type="button" onClick={onOpenNewIntegration}>
{t("integration.action.create")}
</Anchor>
</Text>
) : (
<Text size="xs" span>
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
)
}
pointer
onClick={() => combobox.openDropdown()}