From f3014fe2712ed11d35d5398bd2fe6727133e20c4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 21 Aug 2026 03:19:18 +0200 Subject: [PATCH] refactor(board): modernize item and application select modals with SelectableCard (#6642) Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence. --- apps/docs/docs/management/boards/index.mdx | 17 +- apps/docs/docs/widgets/today/index.mdx | 34 -- apps/docs/docs/widgets/today/index.ts | 43 -- apps/docs/static/api/open-api-schema.json | 1 - .../app/[locale]/boards/(content)/_client.tsx | 127 ++--- .../boards/(content)/_edit-actions.tsx | 93 +++- .../components/board/audio/board-sounds.ts | 123 +++++ .../board/items/item-content.module.css | 19 + .../src/components/board/items/item-menu.tsx | 7 +- .../board/items/item-select-data.spec.ts | 277 +++++++--- .../board/items/item-select-modal.module.css | 20 - .../board/items/item-select-modal.spec.tsx | 211 ++++++++ .../board/items/item-select-modal.tsx | 483 +++++++++--------- .../board/items/lazy-widget-edit-modal.tsx | 41 +- .../board/sections/grid/fixed-grid-item.tsx | 14 + .../board/sections/grid/grid-editor.css | 144 +++--- .../board/sections/grid/grid-editor.tsx | 48 +- .../grid/test/fixed-grid-item.spec.ts | 9 + .../selection/board-selection-context.tsx | 270 ++++++++++ .../selection/board-selection-toolbar.tsx | 255 +++++++++ .../board/selection/board-selection.spec.tsx | 251 +++++++++ .../src/components/create/setup-analytics.ts | 2 +- .../create/universal-create-actions.spec.ts | 77 --- .../create/universal-create-actions.ts | 120 ----- .../create/universal-create-modal.module.css | 21 - .../universal-create-modal.module.d.css.ts | 5 - .../create/universal-create-modal.tsx | 162 ------ .../create/universal-create-spotlight.tsx | 35 -- .../create/universal-create-trigger.tsx | 49 -- .../integration/integration-select-modal.tsx | 52 +- apps/nextjs/src/components/layout/header.tsx | 5 - .../src/docs/homarr-docs-sitemap.ts | 1 - .../definitions/src/docs/widget-doc-slugs.ts | 1 - packages/definitions/src/widget.ts | 2 - .../src/apps/app-select-modal.spec.tsx | 438 ++++++++++++++++ .../src/apps/app-select-modal.tsx | 266 ++++++---- packages/modals/src/index.tsx | 10 +- packages/modals/src/reducer.tsx | 11 + packages/modals/src/type.ts | 10 +- packages/translation/src/lang/en.json | 129 +---- packages/ui/src/components/index.tsx | 7 + .../src/components/selectable-card/index.ts | 7 + .../selectable-card.module.css | 79 +++ .../selectable-card.module.d.css.ts | 7 + .../selectable-card/selectable-card.spec.tsx | 370 ++++++++++++++ .../selectable-card/selectable-card.tsx | 177 +++++++ .../integration-select-grid.spec.tsx | 279 ++++++++++ .../integration-select-grid.tsx | 294 ++++++++--- packages/widgets/package.json | 4 +- .../widgets/src/_inputs/widget-app-input.tsx | 14 +- packages/widgets/src/app/component.tsx | 3 +- packages/widgets/src/catalog.ts | 2 - packages/widgets/src/index.tsx | 2 - packages/widgets/src/manifest.ts | 2 - .../widgets/src/modals/widget-edit-modal.tsx | 14 +- packages/widgets/src/today/component.tsx | 223 -------- packages/widgets/src/today/date-stats.spec.ts | 47 -- packages/widgets/src/today/date-stats.ts | 172 ------- packages/widgets/src/today/index.ts | 24 - .../widgets/src/widget-integration-select.tsx | 20 +- 60 files changed, 3791 insertions(+), 1839 deletions(-) delete mode 100644 apps/docs/docs/widgets/today/index.mdx delete mode 100644 apps/docs/docs/widgets/today/index.ts create mode 100644 apps/nextjs/src/components/board/audio/board-sounds.ts delete mode 100644 apps/nextjs/src/components/board/items/item-select-modal.module.css create mode 100644 apps/nextjs/src/components/board/items/item-select-modal.spec.tsx create mode 100644 apps/nextjs/src/components/board/selection/board-selection-context.tsx create mode 100644 apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx create mode 100644 apps/nextjs/src/components/board/selection/board-selection.spec.tsx delete mode 100644 apps/nextjs/src/components/create/universal-create-actions.spec.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-actions.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-modal.module.css delete mode 100644 apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-modal.tsx delete mode 100644 apps/nextjs/src/components/create/universal-create-spotlight.tsx delete mode 100644 apps/nextjs/src/components/create/universal-create-trigger.tsx create mode 100644 packages/modals-collection/src/apps/app-select-modal.spec.tsx create mode 100644 packages/ui/src/components/selectable-card/index.ts create mode 100644 packages/ui/src/components/selectable-card/selectable-card.module.css create mode 100644 packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts create mode 100644 packages/ui/src/components/selectable-card/selectable-card.spec.tsx create mode 100644 packages/ui/src/components/selectable-card/selectable-card.tsx create mode 100644 packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx delete mode 100644 packages/widgets/src/today/component.tsx delete mode 100644 packages/widgets/src/today/date-stats.spec.ts delete mode 100644 packages/widgets/src/today/date-stats.ts delete mode 100644 packages/widgets/src/today/index.ts diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 6f1710e3b..5c1407572 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -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. -A focused board tile in keyboard editing mode +A focused board tile in keyboard editing mode #### Preview diff --git a/apps/docs/docs/widgets/today/index.mdx b/apps/docs/docs/widgets/today/index.mdx deleted file mode 100644 index 000861d20..000000000 --- a/apps/docs/docs/widgets/today/index.mdx +++ /dev/null @@ -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 "."; - - - -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. - - - -### Adding the widget - - - -### Configuration - - diff --git a/apps/docs/docs/widgets/today/index.ts b/apps/docs/docs/widgets/today/index.ts deleted file mode 100644 index 8922d4b6e..000000000 --- a/apps/docs/docs/widgets/today/index.ts +++ /dev/null @@ -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", - }, - ], - }, -}; diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 8e5da38c1..83b48a665 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -2106,7 +2106,6 @@ "enum": [ "clock", "weather", - "today", "airQuality", "countdown", "timer", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index bfd8d33ec..9fa4b7c5d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -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 = ( - - - - - - - - - - -
- {leftColumnCount > 0 && leftSection && ( - - )} + + + + + + + + + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} -
- -
- - {rightColumnCount > 0 && rightSection && ( - - )} -
-
-
-
-
-
-
+ + + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
+
+ +
+
+ ); if (representativeWidth === null) return content; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx index 87eda8813..0c9404897 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx @@ -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 ( - - openModal({ - boardActions: { - widget: addWidget, - app: addApp, - integration: canConnectService ? connectService : undefined, - container: addContainer, - }, - }) - } - > - - + + + + + + + + + + + } onClick={handleSelectItem}> + {t("item.action.create")} + + } onClick={handleSelectApp}> + {t("app.action.add")} + + {session?.user.permissions.includes("integration-create") && ( + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + )} + + } onClick={addContainer}> + {t("section.container.action.create")} + + + ); }; diff --git a/apps/nextjs/src/components/board/audio/board-sounds.ts b/apps/nextjs/src/components/board/audio/board-sounds.ts new file mode 100644 index 000000000..3d8c4bce8 --- /dev/null +++ b/apps/nextjs/src/components/board/audio/board-sounds.ts @@ -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. + } +}; diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index 7100f3197..7ec4b3a16 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -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; + } +} diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 0679edde3..55e998125 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -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 })} > diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts index a9527a3ba..cc269c8b6 100644 --- a/apps/nextjs/src/components/board/items/item-select-data.spec.ts +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -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(["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(), + 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"); }); }); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css deleted file mode 100644 index 1a53dd49b..000000000 --- a/apps/nextjs/src/components/board/items/item-select-modal.module.css +++ /dev/null @@ -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; - } -} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx new file mode 100644 index 000000000..ee589bf57 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx @@ -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 = { + "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>("@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( + + + , + ), + ); + + 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( + + + , + ), + ); + + expect(host.textContent).toContain("Needs setup"); + }); + + it("renders null when status is 'noConnectionRequired'", async () => { + await act(async () => + root.render( + + + , + ), + ); + + 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( + + + , + ), + ); + + // 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( + + + , + ), + ); + + // 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( + + + , + ), + ); + + expect(host.textContent).toContain("Standalone"); + }); + }); + + describe("WidgetItem", () => { + const FakeIcon = () => Icon; + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.hasAttribute("data-selected")).toBe(false); + }); + }); +}); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2d163c89d..1b53094a9 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -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(placementOptions[0]?.value ?? null); + const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId) + ? targetSectionId + : (placementOptions[0]?.value ?? null); + const [loadingSelection, setLoadingSelection] = useState(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( () => 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 ( - - - { - if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { - const [item] = filteredItems; - if (item) void handleAdd(item.kind); - } - }} - > - {placementOptions.length > 1 && ( - - setSearch(event.currentTarget.value)} + leftSection={} + 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); + } + }} + /> + + + {placementOptions.length > 1 && ( + + setSearch(event.currentTarget.value)} + leftSection={} + 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)} - > - - - {app.name} - - {app.name} - - {multiSelect && selectedAppIds.has(app.id) && ( - - )} - - - - {app.description ?? ""} - - - - {multiSelect - ? selectedAppIds.has(app.id) - ? t("app.action.select.selected") - : t("app.action.select.toggle") - : t("app.action.select.action", { app: app.name })} - - - - ))} - - {filteredApps.length === 0 && !isPending && ( -
- {t("app.action.select.noResults")} -
+ }} + /> + {multiSelect && ( + + + {t("app.action.select.multiSelectTip")} + + {selectedAppIds.size > 0 && ( + + {t("app.action.select.selectedCount", { count: selectedAppIds.size })} + + )} + )} -
- {multiSelect && ( - - - + + + {/* Scrollable Container with App Cards */} + + + + {innerProps.withCreate && ( + + + + } + title={t("app.action.create.title")} + description={t("app.action.create.description")} + footerLeft={ + + {t("app.action.select.customApplication")} + + } + /> + )} + + {filteredApps.map((app) => ( + + ))} + + + {filteredApps.length === 0 && !isPending && ( +
+ {t("app.action.select.noResults")} +
+ )} +
+
+ + {/* Multi-Select Action Footer */} + {multiSelect && selectedApps.length > 0 && ( + + + + {t("app.action.select.appsSelected", { count: selectedApps.length })} + + + + + + + )} - + ); }).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 ( + onSelect(app, event)} + aria-label={app.name} + selected={isSelected} + icon={{app.name}} + title={app.name} + topRight={ + isSelected ? ( + + {t("app.action.select.selected")} + + ) : null + } + description={app.description} + footerLeft={ + + {multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")} + + } + /> + ); +}; diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index 884182462..fa25dd79b 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -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 ( { {...otherModalProps} title={translateIfNecessary(t, modal.props.defaultTitle)} opened={opened} - onClose={handleCloseModal} + onClose={handleClose} > {modal.reference.content} @@ -144,7 +151,6 @@ export const useModalAction = (modal: TModal) => return { openModal: (innerProps: inferInnerProps, options: OpenModalOptions | void) => { // void actually is undefined - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition context.openModalInner({ modal, innerProps, options: options ?? {} }); }, }; diff --git a/packages/modals/src/reducer.tsx b/packages/modals/src/reducer.tsx index ba22f7297..039f07e52 100644 --- a/packages/modals/src/reducer.tsx +++ b/packages/modals/src/reducer.tsx @@ -94,6 +94,8 @@ const getModal = (modal: ModalState) => 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 = (modal: ModalState) => innerProps={innerProps} actions={{ closeModal: () => context.closeModal(modal.id), + setCloseInterceptor: (interceptor: (() => boolean) | null) => { + closeInterceptor = interceptor; + }, }} /> ); @@ -114,5 +119,11 @@ const getModal = (modal: ModalState) => return { modalProps: rest, content: , + onCloseRequest: () => { + if (closeInterceptor) { + return closeInterceptor(); + } + return true; + }, }; }; diff --git a/packages/modals/src/type.ts b/packages/modals/src/type.ts index cd553ac1a..5faa421d2 100644 --- a/packages/modals/src/type.ts +++ b/packages/modals/src/type.ts @@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation"; import type { ModalPresentation } from "./modal-presentation"; -export type ModalComponent = (props: { - actions: { closeModal: () => void }; - innerProps: TInnerProps; -}) => ReactNode; +export interface ModalActions { + closeModal: () => void; + setCloseInterceptor?: (interceptor: (() => boolean) | null) => void; +} + +export type ModalComponent = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode; export type CreateModalOptions = Pick< ModalOptions, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 19e7c1ddd..492bd1880 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index a593e1975..e2f8d28dc 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -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"; diff --git a/packages/ui/src/components/selectable-card/index.ts b/packages/ui/src/components/selectable-card/index.ts new file mode 100644 index 000000000..c71cde19a --- /dev/null +++ b/packages/ui/src/components/selectable-card/index.ts @@ -0,0 +1,7 @@ +export { SelectableCard } from "./selectable-card"; +export type { + SelectableCardProps, + SelectableCardStylesNames, + SelectableCardCssVariables, + SelectableCardFactory, +} from "./selectable-card"; diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.css b/packages/ui/src/components/selectable-card/selectable-card.module.css new file mode 100644 index 000000000..b75f06176 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.module.css @@ -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; +} diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts b/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts new file mode 100644 index 000000000..3aa25b940 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts @@ -0,0 +1,7 @@ +declare const classes: { + readonly root: string; + readonly header: string; + readonly body: string; + readonly footer: string; +}; +export default classes; diff --git a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx new file mode 100644 index 000000000..fbecf3093 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx @@ -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( + + Icon} + title="Sonarr" + topRight={Media} + description="Manage TV series downloads" + footerLeft={Left Meta} + footerRight={Right Meta} + onClick={onClick} + /> + , + ), + ); + + 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( + + I} + title="Only Header" + topRight={TR} + /> + , + ), + ); + + 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( + + Left} /> + , + ), + ); + + expect(host.querySelector(`.${classes.footer}`)).not.toBeNull(); + expect(host.querySelector("[data-testid='left-only']")).not.toBeNull(); + + await act(async () => + root.render( + + Right} /> + , + ), + ); + + 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( + + Footer} + classNames={{ + root: "custom-root", + header: "custom-header", + body: "custom-body", + footer: "custom-footer", + }} + /> + , + ), + ); + + 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( + + Category Badge} + /> + , + ), + ); + + 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( + + Custom Title Element} + topRight={Top Right} + /> + , + ), + ); + + 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( + + +
Custom Child Content
+
+
, + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.getAttribute("data-selected")).toBe("true"); + + await act(async () => + root.render( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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(); + await act(async () => + root.render( + + + , + ), + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current?.tagName).toBe("BUTTON"); + }); + + it("resolves radius CSS variables via varsResolver", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)"); + + await act(async () => + root.render( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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); + }); + }); +}); diff --git a/packages/ui/src/components/selectable-card/selectable-card.tsx b/packages/ui/src/components/selectable-card/selectable-card.tsx new file mode 100644 index 000000000..c32944917 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.tsx @@ -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, Omit, "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 = { + type: "button", +}; + +const varsResolver = createVarsResolver((_theme, { radius }) => ({ + root: { + "--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined, + }, +})); + +export const SelectableCard = factory((_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({ + name: "SelectableCard", + classes, + props, + className, + style, + classNames, + styles, + unstyled, + vars, + attributes, + varsResolver, + }); + + return ( + + + + {/* Header Section: Inset background with Title that never elides before the category */} + {(icon || title || topRight) && ( + + + + {icon} + {typeof title === "string" ? ( + + {title} + + ) : ( + title + )} + + {topRight && ( + + {topRight} + + )} + + + )} + + {/* Body: Custom Children or Default Description */} + {children ? ( + {children} + ) : description !== undefined ? ( + + + + {description} + + + + ) : null} + + {/* Footer Section: Inset Dashed Bottom Meta */} + {(footerLeft || footerRight) && ( + + + {footerLeft} + {footerRight} + + + )} + + ); +}); + +SelectableCard.displayName = "@homarr/ui/SelectableCard"; +SelectableCard.classes = classes; diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx new file mode 100644 index 000000000..dfd52e5b6 --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx @@ -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(); + 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 = { + "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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + // 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + const buttons = Array.from(host.querySelectorAll("button")); + const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock"); + expect(mockButton).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.tsx index a0cfec6d8..18d10d8e6 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -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 ( - - {filtered.map((integration) => ( - onSelect(integration.kind)} - aria-label={integration.name} - > - - - - - - {integration.name} - - - - - - - ))} - {filtered.length === 0 && ( -
- {t("common.noResults")} -
- )} -
- ); -}; - -const IntegrationGridMeta = ({ integration }: { integration: ReturnType[number] }) => { - const t = useI18n(); - return ( - - - {integration.categories.slice(0, 2).map((category) => ( - - {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} - - ))} - - {integration.widgets.length > 0 ? ( - - {integration.widgets.map((widgetKind) => ( - - {t(`widget.${widgetKind}.name`)} - - ))} - + + {/* Top Search Input */} + setSearch(event.currentTarget.value)} + leftSection={} + 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); } - > - }> - {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} - - - ) : ( - - {t("integration.grid.noWidgets")} - - )} + }} + /> + + {/* Scrollable Container with Integration Cards */} + + + + {filtered.map((integration) => ( + i.kind === integration.kind).length} + /> + ))} + + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} +
+
); }; + +// ========================================================================= +// 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 ( + onSelect(integration.kind)} + aria-label={integration.name} + icon={} + title={integration.name} + topRight={} + footerLeft={ + connectedCount > 0 ? ( + + {t("integration.grid.connected", { count: connectedCount })} + + ) : null + } + > + + {t("integration.grid.tiedWidgets")} + + + + ); +}; + +const widgetIconsMap: Partial> = { + 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 ( + + {t("integration.grid.noWidgets")} + + ); + } + + const items = limit ? widgets.slice(0, limit) : widgets; + const moreCount = limit ? widgets.length - limit : 0; + + return ( + + {items.map((widgetKind) => { + const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle; + return ( + } + style={{ fontWeight: 500 }} + > + {t(`widget.${widgetKind}.name`)} + + ); + })} + {moreCount > 0 && ( + + {t("integration.grid.more", { count: moreCount })} + + )} + + ); +}; + +const IntegrationCategoryBadges = ({ + categories, + limit, +}: { + categories: IntegrationGridItem["categories"]; + limit?: number; +}) => { + const t = useI18n(); + const items = limit ? categories.slice(0, limit) : categories; + + return ( + + {items.map((category) => ( + + {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} + + ))} + + ); +}; diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 034197e44..8580c9ed8 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -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", diff --git a/packages/widgets/src/_inputs/widget-app-input.tsx b/packages/widgets/src/_inputs/widget-app-input.tsx index 933f7daf9..313d7243f 100644 --- a/packages/widgets/src/_inputs/widget-app-input.tsx +++ b/packages/widgets/src/_inputs/widget-app-input.tsx @@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; +const renderHereLink = (children: React.ReactNode) => ( + + {children} + +); + 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={ - + {t.rich("widget.common.app.description", { - here: () => ( - - {t("common.here")} - - ), + here: renderHereLink, })} } diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 8475de4e0..d78be5d24 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -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 ; if (isInitialWidgetQueryPending(appQuery)) return ; if (!app) return ; diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 6ca945d47..3287a15d5 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -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 = { clock: IconClock, weather: IconCloud, - today: IconCalendarWeek, airQuality: IconWind, countdown: IconHourglass, timer: IconClockPlay, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 060185a39..86ea2599d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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, diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 9d1cb6e6d..09795db10 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -24,7 +24,6 @@ export type WidgetResources = { const moduleLoaders: Record Promise> = { clock: () => import("./clock") as Promise, weather: () => import("./weather") as Promise, - today: () => import("./today") as Promise, airQuality: () => import("./air-quality") as Promise, countdown: () => import("./countdown") as Promise, timer: () => import("./timer") as Promise, @@ -89,7 +88,6 @@ const moduleLoaders: Record Promise> = { const componentLoaders: Record Promise> = { clock: () => import("./clock/component") as Promise, weather: () => import("./weather/component") as Promise, - today: () => import("./today/component") as Promise, airQuality: () => import("./air-quality/component") as Promise, countdown: () => import("./countdown/component") as Promise, timer: () => import("./timer/component") as Promise, diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 36ec3815b..bd0d9c835 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -58,6 +58,7 @@ export interface WidgetEditModalProps { appId?: string; integrationEditForm?: ComponentType; onIntegrationSaved?: () => void; + onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; } export const getSelectedWidgetIntegrations = ( @@ -217,6 +218,17 @@ export const WidgetEditModal = createModal>(({ 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 = ( {canConfigureWidget && innerProps.integrationSupport && ( @@ -225,6 +237,7 @@ export const WidgetEditModal = createModal>(({ data={innerProps.integrationData} canSelectMultiple={maxIntegrations > 1} withAsterisk={integrationsRequired} + onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} {...form.getInputProps("integrationIds")} /> )} @@ -413,6 +426,5 @@ export const WidgetEditModal = createModal>(({ return t("item.edit.title"); }, size: modalSizeForm, - presentation: "inspector", closeOnClickOutside: false, }); diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx deleted file mode 100644 index 9b7493577..000000000 --- a/packages/widgets/src/today/component.tsx +++ /dev/null @@ -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 ( - - — - - ); - } - - const stats = getTodayStats(now, options.weekConvention, locale); - if (displayMode === "advanced") { - return ; - } - - 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 ( - - - {weekday} · {month} - - - {now.getDate()} - - - ); - } - - return ( - - - - - {weekday} - - - {layout.showFullDate ? fullDate : `${month} ${now.getDate()}`} - - - {options.showYearProgress && ( - - - {formatProgress(stats.yearProgress)}% - - } - /> - - - {t("yearProgress", { progress: formatProgress(stats.yearProgress) })} - - -
- )} - - - {layout.showStats && ( - - {options.showWeekNumber && {t("weekNumber", { week: stats.weekNumber })}} - {options.showDayOfYear && ( - {t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })} - )} - {layout.showQuarter && options.showQuarter && ( - {t("quarter", { quarter: stats.quarter })} - )} - {layout.showQuarter && ( - - {t("daysAfterToday", { days: stats.daysAfterToday })} - - )} - - )} - - {layout.showMonthProgress && } - - ); -} - -interface AdvancedTodayProps { - now: Date; - locale: string; - options: WidgetComponentProps<"today">["options"]; - stats: ReturnType; -} - -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 ( - - - - - {formatLocalizedDate(now, locale, { dateStyle: "full" })} - - {options.showWeekNumber && ( - - {t(`weekConvention.${options.weekConvention}`)} - - )} - - - - {visibleMetrics.map((metric) => ( - - - {metric.label} - - - {metric.value} - - - ))} - - - - - - {options.showYearProgress && } - - - - {Array.from({ length: 12 }, (_, monthIndex) => { - const date = new Date(now.getFullYear(), monthIndex, 1); - const isCurrent = monthIndex === stats.monthIndex; - return ( - - - {formatLocalizedDate(date, locale, { month: "short" })} - - {isCurrent && ( - - {now.getDate()} - - )} - - ); - })} - - - - ); -}; - -const ProgressMetric = ({ label, value }: { label: string; value: number }) => { - const roundedValue = formatProgress(value); - return ( - - - - {label} - - - {roundedValue}% - - - - - ); -}; - -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}`; -}; diff --git a/packages/widgets/src/today/date-stats.spec.ts b/packages/widgets/src/today/date-stats.spec.ts deleted file mode 100644 index 414732dfa..000000000 --- a/packages/widgets/src/today/date-stats.spec.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts deleted file mode 100644 index 790d41016..000000000 --- a/packages/widgets/src/today/date-stats.ts +++ /dev/null @@ -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, - }; -}; diff --git a/packages/widgets/src/today/index.ts b/packages/widgets/src/today/index.ts deleted file mode 100644 index e494df35b..000000000 --- a/packages/widgets/src/today/index.ts +++ /dev/null @@ -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")); diff --git a/packages/widgets/src/widget-integration-select.tsx b/packages/widgets/src/widget-integration-select.tsx index ed82c4099..e24418395 100644 --- a/packages/widgets/src/widget-integration-select.tsx +++ b/packages/widgets/src/widget-integration-select.tsx @@ -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 = ({ - - {t("widget.common.integration.manage")} - - + onOpenNewIntegration ? ( + + + {t("integration.action.create")} + + + ) : ( + + + {t("widget.common.integration.manage")} + + + ) } pointer onClick={() => combobox.openDropdown()}