diff --git a/src/components/dashboard/actions.ts b/src/components/dashboard/actions.ts index 282c3d48b..04874d17b 100644 --- a/src/components/dashboard/actions.ts +++ b/src/components/dashboard/actions.ts @@ -20,23 +20,31 @@ import { } from "../../util/web-serial.js"; import { chipNameToFilterLabel } from "../wizard/wizard-step-board-platforms.js"; -export function editDevice(device: ConfiguredDevice) { - void navigate(`/device/${encodeURIComponent(device.configuration)}`); +/** Open the editor. ``section`` deep-links a component section (read from + * ``?section=`` on load); ``reveal`` opts into the one-shot ``reveal=1`` + * intent — show the visual pane even in a YAML-only or mobile layout, + * consumed and stripped on arrival so a reload keeps the saved layout. */ +export function editDevice( + device: ConfiguredDevice, + opts: { section?: string; reveal?: boolean } = {} +) { + const params = new URLSearchParams(); + if (opts.section) params.set("section", opts.section); + if (opts.reveal) params.set("reveal", "1"); + const query = params.toString(); + void navigate( + `/device/${encodeURIComponent(device.configuration)}${query ? `?${query}` : ""}` + ); } /** Open the editor deep-linked to a component section (e.g. ``api`` for the - * encryption affordance); the section is read from ``?section=`` on load. - * ``reveal`` opts into the one-shot ``reveal=1`` intent: show the visual - * pane even in a YAML-only or mobile layout — consumed and stripped on - * arrival, so a reload keeps the user's saved layout. */ + * encryption affordance); see {@link editDevice} for the opts semantics. */ export function editDeviceSection( device: ConfiguredDevice, section: string, opts: { reveal?: boolean } = {} ) { - const path = `/device/${encodeURIComponent(device.configuration)}`; - const reveal = opts.reveal ? "&reveal=1" : ""; - void navigate(`${path}?section=${encodeURIComponent(section)}${reveal}`); + editDevice(device, { ...opts, section }); } /** diff --git a/src/components/dashboard/device-drawer-content.ts b/src/components/dashboard/device-drawer-content.ts index 0e32421c6..8381394f1 100644 --- a/src/components/dashboard/device-drawer-content.ts +++ b/src/components/dashboard/device-drawer-content.ts @@ -45,6 +45,7 @@ import { import { espHomeStyles } from "../../styles/shared.js"; import { showPendingChanges, showUpdateAvailable } from "../../util/device-sync.js"; import { getEncryptionState } from "../../util/encryption-state.js"; +import { fireEvent } from "../../util/fire-event.js"; import { ReachabilityFollower } from "../../util/reachability-follower.js"; import { registerMdiIcons } from "../../util/register-icons.js"; import { renderReachabilitySection } from "./device-drawer-content/reachability.js"; @@ -186,10 +187,18 @@ export class ESPHomeDeviceDrawerContent extends LitElement { } ${ migrationAvailable - ? html` + ? html`` : nothing } ${apiEnabled ? renderEncryptionBadge(this, d, encState) : nothing} diff --git a/src/components/dashboard/render-content.ts b/src/components/dashboard/render-content.ts index 24702f8ad..723c4e1bf 100644 --- a/src/components/dashboard/render-content.ts +++ b/src/components/dashboard/render-content.ts @@ -151,6 +151,7 @@ export function renderCardGrid( ?select-mode=${host._selectMode} ?selected=${host._selectedDevices.has(device.configuration)} @edit-device=${() => editDevice(device)} + @open-config-migration=${() => editDevice(device, { reveal: true })} @open-encryption-settings=${() => editDeviceSection(device, "api", { reveal: true })} @install-device=${() => host._openInstallMethod(device)} @update-device=${() => host._openCommand(device, "install")} @@ -203,6 +204,8 @@ export function renderTable(host: ESPHomePageDashboard): TemplateResult { @select-all=${(e: CustomEvent) => host._addToSelection(e.detail)} @deselect-all=${(e: CustomEvent) => host._removeFromSelection(e.detail)} @edit-device=${(e: CustomEvent) => editDevice(e.detail)} + @open-config-migration=${(e: CustomEvent) => + editDevice(e.detail, { reveal: true })} @open-encryption-settings=${(e: CustomEvent) => editDeviceSection(e.detail, "api", { reveal: true })} @update-device=${(e: CustomEvent) => @@ -274,6 +277,10 @@ export function renderDrawer(host: ESPHomePageDashboard): TemplateResult { host._drawerOpen = false; editDevice(e.detail); }} + @open-config-migration=${(e: CustomEvent) => { + host._drawerOpen = false; + editDevice(e.detail, { reveal: true }); + }} @open-encryption-settings=${(e: CustomEvent) => { host._drawerOpen = false; editDeviceSection(e.detail, "api", { reveal: true }); diff --git a/src/components/dashboard/table-cell-styles.ts b/src/components/dashboard/table-cell-styles.ts index 8afd6b5f8..fefbff3f6 100644 --- a/src/components/dashboard/table-cell-styles.ts +++ b/src/components/dashboard/table-cell-styles.ts @@ -185,6 +185,26 @@ export const tableCellStyles = css` box-shadow: 0 0 5px color-mix(in srgb, var(--esphome-migration), transparent 50%); } + /* The migration dot deep-links to the editor; reset the native chrome + so it matches the passive dots. */ + button.cell-indicator { + position: relative; + padding: 0; + border: none; + cursor: pointer; + } + /* Invisible hit-area extender — the 8px dot alone is too small a + touch target (24px minimum). */ + button.cell-indicator::before { + content: ""; + position: absolute; + inset: -8px; + } + button.cell-indicator:focus-visible { + outline: 2px solid var(--esphome-primary); + outline-offset: 2px; + } + .cell-indicator-queued { font-size: 14px; flex-shrink: 0; diff --git a/src/components/dashboard/table-columns.ts b/src/components/dashboard/table-columns.ts index e216bd46e..1eea539b3 100644 --- a/src/components/dashboard/table-columns.ts +++ b/src/components/dashboard/table-columns.ts @@ -101,6 +101,7 @@ export function createDeviceColumns( title=${localize(labelKey)} >` : nothing; + const migrationTitle = localize("dashboard.status_migration_available_action"); return [ { accessorKey: "status", @@ -239,15 +240,25 @@ export function createDeviceColumns( encVisual.actionable ? encVisual.actionTooltipKey : encVisual.tooltipKey ) : ""; + const migrationAvailable = row._device.migration_available === true; return html` ${row.friendly_name || row.name} ${indicatorDot(row.showModified, "modified", "dashboard.status_modified")} ${indicatorDot(row.showUpdate, "update", "dashboard.status_update_available")} - ${indicatorDot( - row._device.migration_available === true, - "migration", - "dashboard.status_migration_available" - )} + ${ + !migrationAvailable + ? nothing + : selectMode + ? indicatorDot(true, "migration", "dashboard.status_migration_available") + : html`` + } ${ row.hasQueuedUpdate ? html`` : nothing } - ${ - this.migrationAvailable - ? html` - - ${this._localize("dashboard.status_migration_available")} - ` - : nothing - } + ${renderMigrationDot(this)} ${ this.queuedUpdate ? html``; } +export function renderMigrationDot( + card: ESPHomeDeviceCard +): TemplateResult | typeof nothing { + if (!card.migrationAvailable) return nothing; + // Actionable except while selecting — in select mode the whole card is + // one toggle target, same rule as the encryption button below. + if (!card.selectMode) { + const label = card._localize("dashboard.status_migration_available_action"); + return html` + ${label}`; + } + const tooltip = card._localize("dashboard.status_migration_available"); + return html` + ${tooltip}`; +} + // Compact view: no lock for encrypted devices, only the attention // states (plaintext / pending / mismatch) get an icon. export function renderEncryptionIcon( diff --git a/src/components/device-card/styles.ts b/src/components/device-card/styles.ts index dfb577fcd..abc270b53 100644 --- a/src/components/device-card/styles.ts +++ b/src/components/device-card/styles.ts @@ -144,6 +144,26 @@ export const deviceCardStyles = [ box-shadow: 0 0 5px color-mix(in srgb, var(--esphome-migration), transparent 50%); } + /* The migration dot is a button that opens the editor; reset the + native chrome so it matches the passive dots. */ + button.indicator-dot { + position: relative; + padding: 0; + border: none; + cursor: pointer; + } + /* Invisible hit-area extender — the 8px dot alone is too small a + touch target (24px minimum). */ + button.indicator-dot::before { + content: ""; + position: absolute; + inset: -8px; + } + button.indicator-dot:focus-visible { + outline: 2px solid var(--esphome-primary); + outline-offset: 2px; + } + /* 4-state encryption icon — secure / insecure / pending / mismatch. */ .encryption-icon { font-size: 14px; diff --git a/src/components/device/config-migration-notice.ts b/src/components/device/config-migration-notice.ts index f921486e3..ab5f2bdb4 100644 --- a/src/components/device/config-migration-notice.ts +++ b/src/components/device/config-migration-notice.ts @@ -84,6 +84,8 @@ export class ESPHomeConfigMigrationNotice extends LitElement { css` :host { display: block; + /* Migration identity color — matches the dashboard dot and badge. */ + --notice-accent: var(--esphome-migration); } :host([hidden]) { display: none; diff --git a/src/components/device/notice-banner.styles.ts b/src/components/device/notice-banner.styles.ts index 8a6593adb..7a55e5714 100644 --- a/src/components/device/notice-banner.styles.ts +++ b/src/components/device/notice-banner.styles.ts @@ -1,7 +1,8 @@ /** - * Shared styles for the inline notice banners shown above a section's form - * the warning - * `.notice` box, its `.body` column, and the `.cta` button. + * Shared styles for the inline notice banners shown above a section's form: + * the `.notice` box, its `.body` column, and the `.cta` button. + * Accent color is `--notice-accent`, defaulting to the warning color; set + * it on `:host` to retheme (the migration notice sets the migration purple). */ import { css } from "lit"; @@ -12,8 +13,13 @@ export const noticeBannerStyles = css` gap: var(--wa-space-s); margin-bottom: var(--wa-space-m); padding: var(--wa-space-s) var(--wa-space-m); - border: var(--wa-border-width-s) solid var(--esphome-warning, #f59e0b); - background: color-mix(in srgb, var(--esphome-warning, #f59e0b), transparent 90%); + border: var(--wa-border-width-s) solid + var(--notice-accent, var(--esphome-warning, #f59e0b)); + background: color-mix( + in srgb, + var(--notice-accent, var(--esphome-warning, #f59e0b)), + transparent 90% + ); border-radius: var(--wa-border-radius-m); color: var(--wa-color-text-normal); font-size: var(--wa-font-size-s); @@ -23,7 +29,7 @@ export const noticeBannerStyles = css` .notice wa-icon { flex-shrink: 0; font-size: 20px; - color: var(--esphome-warning, #f59e0b); + color: var(--notice-accent, var(--esphome-warning, #f59e0b)); } .body { diff --git a/src/translations/en.json b/src/translations/en.json index 243b84e93..a7769eb58 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -361,6 +361,7 @@ "status_modified": "Modified", "status_update_available": "Update available", "status_migration_available": "Config migration available", + "status_migration_available_action": "Config migration available. Open the editor to update.", "status_installing": "Installing…", "status_compiling": "Compiling…", "status_renaming": "Renaming…", diff --git a/test/components/dashboard/device-drawer-content-migration.test.ts b/test/components/dashboard/device-drawer-content-migration.test.ts index 0b6b1b6c2..c67edae0e 100644 --- a/test/components/dashboard/device-drawer-content-migration.test.ts +++ b/test/components/dashboard/device-drawer-content-migration.test.ts @@ -13,13 +13,20 @@ import { makeConfiguredDevice } from "../../_make-configured-device.js"; import { mountDrawerContent } from "./_drawer-content.js"; describe("drawer migration badge", () => { - it("renders with its label when migration_available is set", async () => { - const { el } = await mountDrawerContent( - makeConfiguredDevice({ migration_available: true }) + it("is a labeled button that opens the editor with the device", async () => { + const device = makeConfiguredDevice({ migration_available: true }); + const { el } = await mountDrawerContent(device); + const badge = el.shadowRoot!.querySelector( + "button.status-badge--migration" ); - const badge = el.shadowRoot!.querySelector(".status-badge--migration"); expect(badge).not.toBeNull(); expect(badge!.textContent).toContain("dashboard.status_migration_available"); + let detail: unknown; + el.addEventListener("open-config-migration", (e) => { + detail = (e as CustomEvent).detail; + }); + badge!.click(); + expect(detail).toBe(device); }); it("stays absent by default", async () => { diff --git a/test/components/dashboard/render-content-migration-wiring.test.ts b/test/components/dashboard/render-content-migration-wiring.test.ts new file mode 100644 index 000000000..73e3e0fa1 --- /dev/null +++ b/test/components/dashboard/render-content-migration-wiring.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment happy-dom + * + * Pins the open-config-migration → editDevice({ reveal: true }) wiring on + * all three dashboard surfaces — the reveal intent is what puts the + * migrate nudge on screen in a YAML-only or mobile layout. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@home-assistant/webawesome/dist/components/icon/icon.js", () => ({})); +vi.mock("sonner-js", () => ({ default: { success: vi.fn(), error: vi.fn() } })); +vi.mock("../../../src/util/navigation.js", () => ({ navigate: vi.fn() })); + +import { renderInto } from "../../_dom.js"; +import { makeConfiguredDevice } from "../../_make-configured-device.js"; +import type { ConfiguredDevice } from "../../../src/api/types/devices.js"; +import { + renderCardGrid, + renderDrawer, + renderTable, +} from "../../../src/components/dashboard/render-content.js"; +import { navigate } from "../../../src/util/navigation.js"; +import { makeDashboardHost, makeTableHost } from "./_host.js"; + +const DEEP_LINK = "/device/kitchen.yaml?reveal=1"; + +afterEach(() => { + vi.mocked(navigate).mockClear(); +}); + +function dispatchOpen(el: Element, detail?: ConfiguredDevice) { + el.dispatchEvent(new CustomEvent("open-config-migration", { detail })); +} + +describe("open-config-migration wiring", () => { + it("card grid opens the editor with the reveal intent", () => { + const device = makeConfiguredDevice(); + const host = makeDashboardHost({ + _devices: [device], + _activeJobs: new Map(), + _recentJobs: new Map(), + _recentlyAdopted: null, + _selectMode: false, + _selectedDevices: new Set(), + }); + const container = renderInto(renderCardGrid(host, [device])); + const card = container.querySelector("esphome-device-card"); + expect(card).not.toBeNull(); + + dispatchOpen(card!); + expect(navigate).toHaveBeenCalledWith(DEEP_LINK); + }); + + it("table opens the event's device with the reveal intent", () => { + const device = makeConfiguredDevice(); + const host = makeTableHost({ _devices: [device], _sortedDevices: [device] }); + const container = renderInto(renderTable(host)); + const table = container.querySelector("esphome-device-table"); + expect(table).not.toBeNull(); + + dispatchOpen(table!, device); + expect(navigate).toHaveBeenCalledWith(DEEP_LINK); + }); + + it("drawer closes and opens the event's device with the reveal intent", () => { + const device = makeConfiguredDevice(); + const host = makeDashboardHost({ + _drawerOpen: true, + _drawerDevice: device, + _activeJobs: new Map(), + }); + const container = renderInto(renderDrawer(host)); + const drawer = container.querySelector("esphome-device-drawer"); + expect(drawer).not.toBeNull(); + + dispatchOpen(drawer!, device); + expect(navigate).toHaveBeenCalledWith(DEEP_LINK); + expect(host._drawerOpen).toBe(false); + }); +}); diff --git a/test/components/dashboard/table-columns.test.ts b/test/components/dashboard/table-columns.test.ts index a8d3441bd..425010b4e 100644 --- a/test/components/dashboard/table-columns.test.ts +++ b/test/components/dashboard/table-columns.test.ts @@ -203,8 +203,12 @@ describe("device table busy install/update actions", () => { }); }); -function renderNameCell(rowOverrides: Partial = {}): TemplateResult { - const col = columns.find((c) => "accessorKey" in c && c.accessorKey === "name"); +function renderNameCell( + rowOverrides: Partial = {}, + selectMode = false +): TemplateResult { + const cols = selectMode ? createDeviceColumns(identityLocalize, true) : columns; + const col = cols.find((c) => "accessorKey" in c && c.accessorKey === "name"); if (!col?.cell || typeof col.cell !== "function") { throw new Error("no cell renderer for name column"); } @@ -225,13 +229,42 @@ function renderNameCell(rowOverrides: Partial = {}): TemplateResult { } describe("name-cell migration dot", () => { - it("renders from the raw device flag and hides by default", () => { - const shown = renderInto( - renderNameCell({ - _device: { web_port: null, migration_available: true }, - } as unknown as Partial) + it("deep-links to the editor and stops the row click", () => { + const device = { web_port: null, migration_available: true }; + const container = renderInto( + renderNameCell({ _device: device } as unknown as Partial) ); - expect(shown.querySelector(".cell-indicator--migration")).not.toBeNull(); + const btn = container.querySelector( + "button.cell-indicator--migration" + ); + expect(btn).not.toBeNull(); + let detail: unknown; + let rowClicked = false; + container.addEventListener("open-config-migration", (e) => { + detail = (e as CustomEvent).detail; + }); + container.addEventListener("click", () => { + rowClicked = true; + }); + btn!.click(); + expect(detail).toBe(device); + expect(rowClicked).toBe(false); + }); + + it("renders passive while selecting", () => { + const container = renderInto( + renderNameCell( + { + _device: { web_port: null, migration_available: true }, + } as unknown as Partial, + true + ) + ); + expect(container.querySelector("button.cell-indicator--migration")).toBeNull(); + expect(container.querySelector("span.cell-indicator--migration")).not.toBeNull(); + }); + + it("hides by default", () => { const hidden = renderInto(renderNameCell()); expect(hidden.querySelector(".cell-indicator--migration")).toBeNull(); }); diff --git a/test/components/device-card-migration-dot.test.ts b/test/components/device-card-migration-dot.test.ts index 66fd4902f..c93165a7d 100644 --- a/test/components/device-card-migration-dot.test.ts +++ b/test/components/device-card-migration-dot.test.ts @@ -2,7 +2,9 @@ * @vitest-environment happy-dom * * The migration dot reads the raw migration_available flag — YAML-derived, - * so unlike the modified / update dots it is never mDNS-gated. + * never mDNS-gated — and deep-links to the editor, where the migrate nudge + * is the next click. Passive while selecting: the whole card is one toggle + * target in select mode. */ import { describe, expect, it, vi } from "vitest"; @@ -13,12 +15,32 @@ vi.mock("@home-assistant/webawesome/dist/components/tooltip/tooltip.js", () => ( import { mountDeviceCard as mount } from "./_device-card.js"; describe("device-card migration dot", () => { - it("shows the dot with its tooltip when migration_available is set", async () => { + it("is a button that opens the editor without triggering the card click", async () => { const el = await mount({ migrationAvailable: true }); - expect(el.shadowRoot!.querySelector(".indicator-dot--migration")).not.toBeNull(); + const dot = el.shadowRoot!.querySelector( + "button.indicator-dot--migration" + ); + expect(dot).not.toBeNull(); expect( el.shadowRoot!.querySelector("wa-tooltip[for='ind-migration']")?.textContent - ).toContain("dashboard.status_migration_available"); + ).toContain("dashboard.status_migration_available_action"); + let opens = 0; + let cardClicks = 0; + el.addEventListener("open-config-migration", () => { + opens++; + }); + el.addEventListener("card-click", () => { + cardClicks++; + }); + dot!.click(); + expect(opens).toBe(1); + expect(cardClicks).toBe(0); + }); + + it("renders passive while selecting", async () => { + const el = await mount({ migrationAvailable: true, selectMode: true }); + expect(el.shadowRoot!.querySelector("button.indicator-dot--migration")).toBeNull(); + expect(el.shadowRoot!.querySelector("span.indicator-dot--migration")).not.toBeNull(); }); it("hides the dot by default", async () => {