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 index 802a08123..9c52f61a5 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx @@ -13,9 +13,9 @@ import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => { const translations: Record = { - "item.select.connection.ready": "Ready", - "item.select.connection.needsSetup": "Needs setup", - "item.select.connection.noConnectionRequired": "No connection required", + "item.create.connectionStatus.ready": "Ready", + "item.create.connectionStatus.needsSetup": "Needs setup", + "item.create.connectionStatus.noConnectionRequired": "No connection required", "widget.mediaServer.name": "Media Server", "widget.mediaServer.description": "Stream your media", "widget.clock.name": "Clock", @@ -87,7 +87,7 @@ describe("Advanced Add App - Modal Subcomponents", () => { expect(host.textContent).toContain("Needs setup"); }); - it("renders 'No connection required' badge with gray color when status is 'noConnectionRequired'", async () => { + it("renders null when status is 'noConnectionRequired'", async () => { await act(async () => root.render( @@ -96,18 +96,18 @@ describe("Advanced Add App - Modal Subcomponents", () => { ), ); - expect(host.textContent).toContain("No connection required"); + expect(host.textContent?.trim()).toBe(""); }); }); describe("SupportedIntegrations", () => { - it("renders up to 3 avatars for supported integrations", async () => { + it("renders up to 5 avatars for supported integrations", async () => { const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"]; await act(async () => root.render( - + , ), ); @@ -117,31 +117,31 @@ describe("Advanced Add App - Modal Subcomponents", () => { expect(host.textContent).not.toContain("+"); }); - it("renders overflow indicator when more than 3 integrations are supported", async () => { - const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby"]; + it("renders overflow indicator when 6 or more integrations are supported", async () => { + const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "kavita"]; await act(async () => root.render( - + , ), ); - // 5 integrations total: 3 shown, +2 in badge + // 6 integrations total: 4 shown, +2 in badge expect(host.textContent).toContain("+2"); }); - it("renders empty when supportedIntegrations is empty", async () => { + it("renders Standalone label when integrations list is empty", async () => { await act(async () => root.render( - + , ), ); - expect(host.textContent?.trim()).toBe(""); + expect(host.textContent).toContain("Standalone"); }); }); 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 bb39de4ea..7917103de 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -557,8 +557,8 @@ export const WidgetItem = ({ }; export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => { - if (status === "noConnectionRequired") return null; const t = useI18n(); + if (status === "noConnectionRequired") return null; const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray"; return ( diff --git a/e2e/shared/actions/item-select-actions.ts b/e2e/shared/actions/item-select-actions.ts deleted file mode 100644 index d137c4d7a..000000000 --- a/e2e/shared/actions/item-select-actions.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { Page } from "@playwright/test"; - -export class ItemSelectActions { - private readonly page: Page; - - constructor(page: Page) { - this.page = page; - } - - /** - * Enter board edit mode if not already active - */ - public async enterEditModeAsync() { - const editToggle = this.page.getByTestId("board-edit-mode-toggle"); - await editToggle.waitFor({ state: "visible", timeout: 15_000 }); - const isPressed = await editToggle.getAttribute("aria-pressed"); - if (isPressed !== "true") { - await editToggle.click(); - await this.page.waitForFunction( - () => document.querySelector('[data-testid="board-edit-mode-toggle"]')?.getAttribute("aria-pressed") === "true", - { timeout: 15_000 }, - ); - } - } - - /** - * Open the ItemSelectModal ("Choose item to add") from the board - */ - public async openItemSelectModalAsync() { - await this.enterEditModeAsync(); - - // Check if universal create button in header or empty state button is available - const headerAddButton = this.page.locator('button[aria-label="Add board content"]'); - const emptyStateAddButton = this.page.getByRole("button", { name: "Add widget", exact: false }); - - if (await headerAddButton.isVisible()) { - await headerAddButton.click(); - // In Universal Create modal, click "Widget" - const widgetOption = this.page - .getByRole("dialog") - .locator("button") - .filter({ hasText: /Widget/i }) - .first(); - await widgetOption.waitFor({ state: "visible", timeout: 10_000 }); - await widgetOption.click(); - } else if (await emptyStateAddButton.isVisible()) { - await emptyStateAddButton.click(); - } - - // Wait for ItemSelectModal to be visible - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - await modal.waitFor({ state: "visible", timeout: 15_000 }); - } - - /** - * Filter catalog items by search query - */ - public async searchWidgetsAsync(query: string) { - const searchInput = this.page - .getByRole("dialog") - .locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]') - .first(); - await searchInput.waitFor({ state: "visible", timeout: 10_000 }); - await searchInput.fill(query); - // Allow debounce / filtering to update - await this.page.waitForTimeout(200); - } - - /** - * Clear the search input - */ - public async clearSearchAsync() { - const searchInput = this.page - .getByRole("dialog") - .locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]') - .first(); - await searchInput.waitFor({ state: "visible", timeout: 10_000 }); - await searchInput.clear(); - await this.page.waitForTimeout(200); - } - - /** - * Click a widget card in the catalog grid to select it and trigger live preview - */ - public async selectWidgetAsync(widgetName: string) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const widgetCard = modal - .locator("button") - .filter({ hasText: new RegExp(`^${widgetName}`, "i") }) - .first(); - await widgetCard.waitFor({ state: "visible", timeout: 10_000 }); - await widgetCard.click(); - // Wait for live preview container to appear - await modal.locator('text="Live Preview & Options"').waitFor({ state: "visible", timeout: 15_000 }); - } - - /** - * Close the live preview pane via the close button - */ - public async closePreviewAsync() { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const closeBtn = modal.locator('button[aria-label="Close preview"]').first(); - await closeBtn.waitFor({ state: "visible", timeout: 10_000 }); - await closeBtn.click(); - } - - /** - * Change destination dropdown (e.g. Main canvas or a container section) - */ - public async selectDestinationAsync(destinationLabel: string) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const destinationSelect = modal.locator('input[aria-label="Add to"]').first(); - if (await destinationSelect.isVisible()) { - await destinationSelect.click(); - const option = this.page.getByRole("option", { name: destinationLabel }).first(); - await option.waitFor({ state: "visible", timeout: 5_000 }); - await option.click(); - } - } - - /** - * Toggle a switch in Widget Settings - */ - public async toggleSettingSwitchAsync(labelText: string | RegExp) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const switchInput = modal.getByLabel(labelText).first(); - await switchInput.waitFor({ state: "visible", timeout: 10_000 }); - await switchInput.click(); - } - - /** - * Select an option in a dropdown in Widget Settings - */ - public async selectSettingOptionAsync(selectLabel: string | RegExp, optionText: string | RegExp) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const selectField = modal.getByLabel(selectLabel).first(); - await selectField.waitFor({ state: "visible", timeout: 10_000 }); - await selectField.click(); - const option = this.page.getByRole("option", { name: optionText }).first(); - await option.waitFor({ state: "visible", timeout: 5_000 }); - await option.click(); - } - - /** - * Fill a text input in Widget Settings - */ - public async fillSettingInputAsync(labelText: string | RegExp, value: string) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const inputField = modal.getByLabel(labelText).first(); - await inputField.waitFor({ state: "visible", timeout: 10_000 }); - await inputField.fill(value); - } - - /** - * Click "Use Demo Service" button when available in Widget Settings - */ - public async clickUseDemoServiceAsync() { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const demoBtn = modal.getByRole("button", { name: "Use Demo Service", exact: false }).first(); - await demoBtn.waitFor({ state: "visible", timeout: 10_000 }); - await demoBtn.click(); - } - - /** - * Fill inline integration form within the Widget Settings panel - */ - public async fillInlineIntegrationFormAsync(input: { name: string; url: string; apiKey?: string }) { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - // Name input - const nameInput = modal - .locator('input[placeholder*="name" i], input[name="name"], input[aria-label*="name" i]') - .first(); - await nameInput.waitFor({ state: "visible", timeout: 10_000 }); - await nameInput.fill(input.name); - - // URL input - const urlInput = modal - .locator('input[placeholder*="url" i], input[placeholder*="http" i], input[name="url"]') - .first(); - await urlInput.waitFor({ state: "visible", timeout: 10_000 }); - await urlInput.fill(input.url); - - // API Key input if provided - if (input.apiKey) { - const apiInput = modal - .locator('input[placeholder*="api" i], input[placeholder*="key" i], input[type="password"]') - .first(); - if (await apiInput.isVisible()) { - await apiInput.fill(input.apiKey); - } - } - - // Submit inline form - const submitBtn = modal - .locator('button[type="submit"]') - .filter({ hasText: /save|create|connect/i }) - .first(); - await submitBtn.waitFor({ state: "visible", timeout: 10_000 }); - await submitBtn.click(); - } - - /** - * Click the "Add" button in the Live Preview header to place the widget on the board - */ - public async submitAddWidgetAsync() { - const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); - const addBtn = modal.locator('button[type="button"]').filter({ hasText: /^Add$/i }).first(); - await addBtn.waitFor({ state: "visible", timeout: 10_000 }); - await addBtn.click(); - } -} diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index abddb9b77..47e8f4616 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -52,7 +52,7 @@ export const AppSelectModal = createModal(({ actions, inner const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]); const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => { - const isModifierPressed = Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey); + const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey); if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) { innerProps.onSelect(app); diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.css b/packages/ui/src/components/selectable-card/selectable-card.module.css index 405460ef7..b75f06176 100644 --- a/packages/ui/src/components/selectable-card/selectable-card.module.css +++ b/packages/ui/src/components/selectable-card/selectable-card.module.css @@ -8,7 +8,7 @@ min-height: 175px; height: 100%; padding: var(--mantine-spacing-sm); - border-radius: var(--mantine-radius-md); + 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); diff --git a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx index f8c6ed373..c011f27d3 100644 --- a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx +++ b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx @@ -289,7 +289,7 @@ describe("SelectableCard", () => { }); describe("interactions and accessibility", () => { - it("supports keyboard activation via Enter and Space", async () => { + it("supports focus and click activation", async () => { const onClick = vi.fn(); await act(async () => root.render( @@ -304,7 +304,7 @@ describe("SelectableCard", () => { expect(document.activeElement).toBe(button); await act(async () => { - button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + button?.click(); }); expect(onClick).toHaveBeenCalledOnce(); }); 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 index 48bbaadf6..4ad740d0c 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx @@ -102,13 +102,13 @@ describe("IntegrationSelectGrid", () => { await act(async () => root.render( - + , ), ); - const homeAssistantCard = host.querySelector("button"); - expect(homeAssistantCard).not.toBeNull(); + 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 () => { 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 914b1c463..a70fb30f6 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -139,9 +139,7 @@ const IntegrationCard = ({ {connectedCount} connected - ) : ( - - ) + ) : null } > diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index b676de965..d78be5d24 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -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 ;