Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
26 changes: 17 additions & 9 deletions src/components/dashboard/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

/**
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
21 changes: 16 additions & 5 deletions src/components/dashboard/table-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function createDeviceColumns(
title=${localize(labelKey)}
></span>`
: nothing;
const migrationTitle = localize("dashboard.status_migration_available_action");
return [
{
accessorKey: "status",
Expand Down Expand Up @@ -239,15 +240,25 @@ export function createDeviceColumns(
encVisual.actionable ? encVisual.actionTooltipKey : encVisual.tooltipKey
)
: "";
const migrationAvailable = row._device.migration_available === true;
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"
)}
${
!migrationAvailable
? nothing
: selectMode
? indicatorDot(true, "migration", "dashboard.status_migration_available")
: 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>`
}
${
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
49 changes: 41 additions & 8 deletions test/components/dashboard/table-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,12 @@ describe("device table busy install/update actions", () => {
});
});

function renderNameCell(rowOverrides: Partial<DeviceRow> = {}): TemplateResult {
const col = columns.find((c) => "accessorKey" in c && c.accessorKey === "name");
function renderNameCell(
rowOverrides: Partial<DeviceRow> = {},
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");
}
Expand All @@ -225,13 +229,42 @@ 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 container = renderInto(
renderNameCell(
{
_device: { web_port: null, migration_available: true },
} as unknown as Partial<DeviceRow>,
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();
});
Expand Down
Loading