348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
@@ -5,8 +5,8 @@
* behaviour the extraction must preserve:
* - header renders name / object breadcrumb / status chip / actions
* - Complete / Skip route to the panel callbacks with the task
* - operator mode hides archive + the more-menu (read-only surface)
* - the more-menu items (edit/duplicate/reset/snooze/delete) fire callbacks
* - operator mode: only Complete/Skip; the more-menu carries just QR + worksheet
* - the more-menu items (edit/qr/worksheet/duplicate/…/archive/delete) fire callbacks
* - tab bar switches via setActiveTab; history tab renders the timeline
* - KPI bar shows warning days + currency; user badge resolves names
*/
@@ -154,16 +154,18 @@ describe("task-detail renderer", () => {
expect(skipped).to.equal(1);
});
it("operator mode hides archive button and the more-menu", () => {
const host = mount(task(), ctx({ isOperator: true }));
expect(host.querySelector(".more-menu-wrapper")).to.be.null;
// Only Complete / Skip / QR remain.
const labels = [...host.querySelectorAll(".task-header-actions ha-button")]
.map((b) => b.textContent || "");
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => {
const host = mount(task(), ctx({ isOperator: true, moreMenuOpen: true }));
// The ⋮ stays (QR + worksheet moved into it), but write actions don't.
expect(host.querySelector(".more-menu-wrapper"), "menu present").to.exist;
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
expect(buttons.length).to.equal(2); // Complete + Skip only
const items = [...host.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim() || "");
expect(items.length).to.equal(2);
expect(items.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i);
});
it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => {
it("open more-menu lists edit/qr/worksheet/duplicate/reset/postpone/snooze/archive/delete and fires callbacks", () => {
const calls: string[] = [];
const host = mount(task(), ctx({
moreMenuOpen: true,
@@ -172,14 +174,19 @@ describe("task-detail renderer", () => {
promptPostpone: () => calls.push("postpone"),
snoozeTask: () => calls.push("snooze"),
printWorksheet: () => calls.push("worksheet"),
toggleArchive: () => calls.push("archive"),
}));
const items = [...host.querySelectorAll(".popup-menu-item")];
expect(items.length).to.equal(7);
(items[3] as HTMLElement).click(); // postpone
(items[4] as HTMLElement).click(); // snooze
(items[5] as HTMLElement).click(); // work sheet (v2.21)
(items[6] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]);
// edit, qr, worksheet, duplicate, reset, postpone, snooze, archive, delete
expect(items.length).to.equal(9);
(items[5] as HTMLElement).click(); // postpone
(items[6] as HTMLElement).click(); // snooze
(items[2] as HTMLElement).click(); // work sheet (v2.21)
(items[7] as HTMLElement).click(); // archive (demoted from the header)
(items[8] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal([
"close", "postpone", "close", "snooze", "close", "worksheet", "close", "archive", "close", "delete",
]);
});
it("shows a postponed badge when the task has a due_override, and none otherwise", () => {
@@ -177,8 +177,12 @@ describe("maintenance-task-detail-view", () => {
expect(el.querySelector(".detail-section")).to.equal(null);
});
it("hides the more-menu for operators (read-only surface preserved)", async () => {
const el = await mount(task(), ctx({ isOperator: true }));
expect(el.querySelector(".more-menu-wrapper")).to.equal(null);
it("operators get the more-menu with only the read-safe items (QR + worksheet)", async () => {
const el = await mount(task(), ctx({ isOperator: true, moreMenuOpen: true }));
expect(el.querySelector(".more-menu-wrapper"), "menu present for operators").to.exist;
const labels = [...el.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim());
expect(labels.length).to.equal(2);
// No write actions leak into the operator menu.
expect(labels.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i);
});
});
@@ -130,6 +130,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _defaultWarningDays = 7;
@state() private _actionLoading = false;
@state() private _moreMenuOpen = false;
@state() private _objMenuOpen = false;
@state() private _toastMessage = "";
@state() private _toastUndo: (() => void) | null = null;
@state() private _toastActionLabel = "";
@@ -3014,34 +3015,34 @@ export class MaintenanceSupporterPanel extends LitElement {
<h2>${o.name}</h2>
<div class="action-buttons">
${!isOperator ? html`
<ha-button appearance="plain" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
dlg?.openEdit(obj.entry_id, o);
}}>${t("edit", L)}</ha-button>
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
}}>${t("add_task", L)}</ha-button>
<ha-button appearance="plain" .disabled=${this._actionLoading} @click=${() => this._duplicateObject(obj.entry_id)}>
<ha-icon icon="mdi:content-copy"></ha-icon> ${t("duplicate", L)}
</ha-button>
<ha-button appearance="plain" @click=${() => this._toggleArchiveObject(obj.entry_id, !!o.archived)}>
<ha-icon icon="${o.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${o.archived ? t("unarchive_object", L) : t("archive_object", L)}
</ha-button>
${!o.archived ? html`
<ha-button appearance="plain" @click=${() => this._togglePauseObject(obj.entry_id, !!o.paused)}>
<ha-icon icon="${o.paused ? 'mdi:play-circle-outline' : 'mdi:pause-circle-outline'}"></ha-icon>
${o.paused ? t("resume_object", L) : t("pause_object", L)}
</ha-button>
<ha-button appearance="plain" .disabled=${this._actionLoading} @click=${() => this._replaceObject(obj.entry_id, o.name)}>
<ha-icon icon="mdi:swap-horizontal"></ha-icon> ${t("replace_object", L)}
</ha-button>
` : nothing}
<ha-button variant="danger" appearance="plain" @click=${() => this._deleteObject(obj.entry_id)}>${t("delete", L)}</ha-button>
<ha-button appearance="plain" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
dlg?.openEdit(obj.entry_id, o);
}}>${t("edit", L)}</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => this._openQrForObject(obj.entry_id, o.name)}><ha-icon icon="mdi:qrcode"></ha-icon> ${t("qr_code", L)}</ha-button>
<ha-button appearance="plain" @click=${() => this._printObjectReport(obj.entry_id)}><ha-icon icon="mdi:file-document-outline"></ha-icon> ${t("report_button", L)}</ha-button>
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${this._actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => this._toggleObjMenu()}></ha-icon-button>
${this._objMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._openQrForObject(obj.entry_id, o.name); }}>${t("qr_code", L)}</div>
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._printObjectReport(obj.entry_id); }}>${t("report_button", L)}</div>
${!isOperator ? html`
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._duplicateObject(obj.entry_id); }}>${t("duplicate", L)}</div>
${!o.archived ? html`
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._togglePauseObject(obj.entry_id, !!o.paused); }}>${o.paused ? t("resume_object", L) : t("pause_object", L)}</div>
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._replaceObject(obj.entry_id, o.name); }}>${t("replace_object", L)}</div>
` : nothing}
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._toggleArchiveObject(obj.entry_id, !!o.archived); }}>${o.archived ? t("unarchive_object", L) : t("archive_object", L)}</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item danger" @click=${() => { this._closeObjMenu(); this._deleteObject(obj.entry_id); }}>${t("delete", L)}</div>
` : nothing}
</div>
` : nothing}
</div>
</div>
</div>
${o.paused
@@ -3141,6 +3142,21 @@ export class MaintenanceSupporterPanel extends LitElement {
/**
* Render compact task header with status chip and action buttons.
*/
// Object-detail ⋮ menu — same click-away pattern as the task-detail menu.
private _toggleObjMenu(): void {
this._objMenuOpen = !this._objMenuOpen;
if (this._objMenuOpen) {
setTimeout(() => {
const handler = () => { this._objMenuOpen = false; document.removeEventListener("click", handler); };
document.addEventListener("click", handler);
}, 0);
}
}
private _closeObjMenu(): void {
this._objMenuOpen = false;
}
private _toggleMoreMenu(): void {
this._moreMenuOpen = !this._moreMenuOpen;
if (this._moreMenuOpen) {
@@ -121,30 +121,27 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<div class="task-header-actions">
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
<ha-button appearance="plain" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>
${!isOperator ? html`
<ha-button appearance="plain" @click=${() => ctx.toggleArchive(!!task.archived)}>
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => ctx.openQr(task.name)}><ha-icon icon="mdi:qrcode"></ha-icon> ${t("qr_code", L)}</ha-button>
${!isOperator ? html`
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
${ctx.moreMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
${ctx.moreMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
${!isOperator ? html`
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
` : nothing}
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openQr(task.name); }}>${t("qr_code", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
${!isOperator ? html`
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.toggleArchive(!!task.archived); }}>${task.archived ? t("unarchive", L) : t("archive", L)}</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>
</div>
` : nothing}
</div>
` : nothing}
` : nothing}
</div>
` : nothing}
</div>
</div>
</div>
`;
@@ -373,10 +370,13 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
return html`
<div class="tab-content overview-tab">
${task.battery_fleet_task
? html`<maintenance-battery-fleet-section .hass=${ctx.hass}></maintenance-battery-fleet-section>`
: nothing}
${renderKPIBar(task, ctx)}
${renderTaskMeta(task, ctx)}
${task.battery_fleet_task
? html`<maintenance-battery-fleet-section .hass=${ctx.hass}></maintenance-battery-fleet-section>`
? nothing
: html`
${renderDaysProgress(task, ctx.lang)}
${renderTriggerSection(task, ctx.sparkline)}