Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/dashboard/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ 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)}`);
/** ``reveal`` opts into the one-shot ``reveal=1`` intent: show the visual
* pane even in a YAML-only or mobile layout (e.g. so the migration nudge
* a dashboard indicator promised is actually on screen). */
export function editDevice(device: ConfiguredDevice, opts: { reveal?: boolean } = {}) {
const reveal = opts.reveal ? "?reveal=1" : "";
void navigate(`/device/${encodeURIComponent(device.configuration)}${reveal}`);
}

/** Open the editor deep-linked to a component section (e.g. ``api`` for the
Expand Down
10 changes: 8 additions & 2 deletions src/components/dashboard/device-drawer-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -186,10 +187,15 @@ export class ESPHomeDeviceDrawerContent extends LitElement {
}
${
migrationAvailable
? html`<span class="status-badge status-badge--migration">
? html`<button
type="button"
class="status-badge status-badge--migration"
title=${this._localize("dashboard.status_migration_available_action")}
@click=${() => fireEvent(this, "open-config-migration", d)}
>
Comment on lines +190 to +198
<wa-icon library="mdi" name="auto-fix"></wa-icon>
${this._localize("dashboard.status_migration_available")}
</span>`
</button>`
: nothing
}
${apiEnabled ? renderEncryptionBadge(this, d, encState) : nothing}
Expand Down
7 changes: 7 additions & 0 deletions src/components/dashboard/render-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down Expand Up @@ -203,6 +204,8 @@ export function renderTable(host: ESPHomePageDashboard): TemplateResult {
@select-all=${(e: CustomEvent<string[]>) => host._addToSelection(e.detail)}
@deselect-all=${(e: CustomEvent<string[]>) => host._removeFromSelection(e.detail)}
@edit-device=${(e: CustomEvent<ConfiguredDevice>) => editDevice(e.detail)}
@open-config-migration=${(e: CustomEvent<ConfiguredDevice>) =>
editDevice(e.detail, { reveal: true })}
@open-encryption-settings=${(e: CustomEvent<ConfiguredDevice>) =>
editDeviceSection(e.detail, "api", { reveal: true })}
@update-device=${(e: CustomEvent<ConfiguredDevice>) =>
Expand Down Expand Up @@ -274,6 +277,10 @@ export function renderDrawer(host: ESPHomePageDashboard): TemplateResult {
host._drawerOpen = false;
editDevice(e.detail);
}}
@open-config-migration=${(e: CustomEvent<ConfiguredDevice>) => {
host._drawerOpen = false;
editDevice(e.detail, { reveal: true });
}}
@open-encryption-settings=${(e: CustomEvent<ConfiguredDevice>) => {
host._drawerOpen = false;
editDeviceSection(e.detail, "api", { reveal: true });
Expand Down
12 changes: 12 additions & 0 deletions src/components/dashboard/table-cell-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ 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 {
padding: 0;
border: none;
cursor: pointer;
}
button.cell-indicator:focus-visible {
outline: 2px solid var(--esphome-primary);
outline-offset: 2px;
}

.cell-indicator-queued {
font-size: 14px;
flex-shrink: 0;
Expand Down
22 changes: 17 additions & 5 deletions src/components/dashboard/table-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,27 @@ export function createDeviceColumns(
encVisual.actionable ? encVisual.actionTooltipKey : encVisual.tooltipKey
)
: "";
const migrationTitle = localize("dashboard.status_migration_available_action");
return html`<span class="cell-name-wrap">
<span class="cell-name">${row.friendly_name || row.name}</span>
${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"
)}
${
row._device.migration_available === true && !selectMode
? html`<button
type="button"
class="cell-indicator cell-indicator--migration"
title=${migrationTitle}
aria-label=${migrationTitle}
@click=${(e: Event) =>
dispatchRowEvent(e, "open-config-migration", row._device)}
></button>`
: indicatorDot(
row._device.migration_available === true,
"migration",
"dashboard.status_migration_available"
)
}
${
row.hasQueuedUpdate
? html`<wa-icon
Expand Down
16 changes: 2 additions & 14 deletions src/components/device-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { navigateCards, onHostContextMenu } from "./device-card/keyboard-nav.js"
import {
renderEncryptionIcon,
renderLabels,
renderMigrationDot,
renderStatusBadge,
} from "./device-card/render-bits.js";
import { deviceCardStyles } from "./device-card/styles.js";
Expand Down Expand Up @@ -221,20 +222,7 @@ export class ESPHomeDeviceCard extends LitElement {
</wa-tooltip>`
: nothing
}
${
this.migrationAvailable
? html`<span
id="ind-migration"
class="indicator-dot indicator-dot--migration"
tabindex="0"
role="img"
aria-label=${this._localize("dashboard.status_migration_available")}
></span>
<wa-tooltip for="ind-migration">
${this._localize("dashboard.status_migration_available")}
</wa-tooltip>`
: nothing
}
${renderMigrationDot(this)}
${
this.queuedUpdate
? html`<wa-icon
Expand Down
31 changes: 31 additions & 0 deletions src/components/device-card/render-bits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ export function renderLabels(card: ESPHomeDeviceCard): TemplateResult | typeof n
</div>`;
}

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`<button
id="ind-migration"
type="button"
class="indicator-dot indicator-dot--migration"
aria-label=${label}
@click=${(e: Event) => {
e.stopPropagation();
fireEvent(card, "open-config-migration");
}}
></button>
<wa-tooltip for="ind-migration">${label}</wa-tooltip>`;
}
const tooltip = card._localize("dashboard.status_migration_available");
return html`<span
id="ind-migration"
class="indicator-dot indicator-dot--migration"
tabindex="0"
role="img"
aria-label=${tooltip}
></span>
<wa-tooltip for="ind-migration">${tooltip}</wa-tooltip>`;
}

// Compact view: no lock for encrypted devices, only the attention
// states (plaintext / pending / mismatch) get an icon.
export function renderEncryptionIcon(
Expand Down
12 changes: 12 additions & 0 deletions src/components/device-card/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ 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 {
padding: 0;
border: none;
cursor: pointer;
}
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;
Expand Down
2 changes: 2 additions & 0 deletions src/components/device/config-migration-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 12 additions & 6 deletions src/components/device/notice-banner.styles.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
15 changes: 11 additions & 4 deletions test/components/dashboard/device-drawer-content-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(
"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 () => {
Expand Down
51 changes: 45 additions & 6 deletions test/components/dashboard/table-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,52 @@ function renderNameCell(rowOverrides: Partial<DeviceRow> = {}): 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<DeviceRow>)
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<DeviceRow>)
);
expect(shown.querySelector(".cell-indicator--migration")).not.toBeNull();
const btn = container.querySelector<HTMLButtonElement>(
"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 selectColumns = createDeviceColumns(identityLocalize, true);
const col = selectColumns.find((c) => "accessorKey" in c && c.accessorKey === "name");
if (!col?.cell || typeof col.cell !== "function") {
throw new Error("no cell renderer for name column");
}
const row = {
name: "kitchen",
friendly_name: "Kitchen",
showModified: false,
showUpdate: false,
hasPendingChanges: false,
api_enabled: false,
api_encrypted: false,
api_encryption_active: null,
_device: { web_port: null, migration_available: true },
} as unknown as DeviceRow;
const info = { row: { original: row } } as unknown as CellContext<DeviceRow, unknown>;
const container = renderInto(col.cell(info) as TemplateResult);
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();
});
Expand Down
30 changes: 26 additions & 4 deletions test/components/device-card-migration-dot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<HTMLButtonElement>(
"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 () => {
Expand Down