Compare commits

...

14 Commits

Author SHA1 Message Date
Aidan Timson 900efcba6c Add a pull request standards workflow 2026-06-11 12:46:43 +01:00
Aidan Timson c46f286cb8 Gate more info "Add to" button to admins (#52547) 2026-06-11 13:12:39 +02:00
Petar Petrov cc6b51d53f Show a single toast with the update name when Update all fails (#52530) 2026-06-11 12:06:47 +01:00
karwosts 6915ca8fdd Remove dead code in hui-timestamp-display (#52544) 2026-06-11 08:40:55 +03:00
Jan-Philipp Benecke 677e53f685 Migrate maintenance panel topbar to ha-top-app-bar-fixed (#52540) 2026-06-11 08:40:08 +03:00
Aidan Timson 46b6ae8d7b Remove isDirty from PreventUnsavedMixin, migrate automation editors to DirtyStateProviderMixin (#52515)
* Remove isDirty from PreventUnsavedMixin, migrate to DirtyStateProviderMixin

Drop the legacy isDirty getter from PreventUnsavedMixin and switch to
isDirtyState (provided by DirtyStateProviderMixin). All consumers that
previously overrode isDirty are migrated to use DirtyStateProviderMixin
with proper dirty tracking:

- AutomationScriptEditorMixin: deep config comparison
- ha-scene-editor: revision counter with shallow comparison
- Blueprint editors + manual editor mixin: consume dirty context

This is the atomic core change — all isDirty overriders are migrated in
the same commit so no compatibility layer is needed.

* Fix dirty state not updated when YAML editor has invalid content

- ha-scene-editor: call _updateDirtyState on invalid YAML to increment
  the revision counter, marking the editor dirty
- ha-script-editor, ha-automation-editor: override isDirtyState to also
  return true when yamlErrors is set, ensuring the PreventUnsavedMixin
  navigation guard fires even when the user has never produced a valid
  intermediate config change

* FIx/migrate editor-toast

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-11 08:36:19 +03:00
Aidan Timson 09fda1ca1e Migrate energy dialogs to use dirty state provider (#52535)
* Migrate energy dialogs to use dirty state provider

* Migrate energy grid settings dialog

* Fix baseline for add mode

* Review
2026-06-11 08:28:39 +03:00
Jan-Philipp Benecke 7c1522b975 Migrate hass-error-screen to ha-top-app-bar-fixed (#52543) 2026-06-11 07:06:36 +02:00
Jan-Philipp Benecke d26ad7b354 Migrate hass-loading-screen to ha-top-app-bar-fixed (#52542) 2026-06-11 07:06:26 +02:00
Bram Kragten 66235a4c99 Don't try to load brand images without a token (#52532) 2026-06-10 18:09:35 +02:00
dependabot[bot] 6c02864334 Bump shell-quote from 1.8.3 to 1.8.4 (#52533)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 15:15:35 +03:00
Aidan Timson 3471cd103a Add a blocking labels workflow (#52531) 2026-06-10 13:53:51 +02:00
Jan-Philipp Benecke 9ae25d96f2 Move default menu/back button to ha-top-app-bar-fixed (#52444) 2026-06-10 12:11:09 +02:00
Marcin Bauer 02361f2517 Show condition row icon on mobile in visibility editor (#52527)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:30:37 +02:00
43 changed files with 826 additions and 491 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
"Needs Template",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
@@ -0,0 +1,185 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
- reopened
- ready_for_review
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check pull request follows contribution standards
runs-on: ubuntu-latest
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s*${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
+27 -5
View File
@@ -2,6 +2,9 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
@@ -135,6 +138,8 @@ export const haTopAppBarFixedStyles = css`
export class HaTopAppBarFixed extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "back-button", type: Boolean }) backButton = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
@@ -200,16 +205,14 @@ export class HaTopAppBarFixed extends LitElement {
<div class="row">
${paneHeader
? html`<section class="section" id="title">
<slot name="navigationIcon"></slot>
${title}
${this._renderNavigationIcon()} ${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
: html`${this._renderNavigationIcon()}
${this.centerTitle ? nothing : title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
@@ -225,6 +228,20 @@ export class HaTopAppBarFixed extends LitElement {
`;
}
private _renderNavigationIcon() {
return html`
<slot name="navigationIcon">
${this.backButton
? html`
<ha-icon-button-arrow-prev
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button></ha-menu-button>`}
</slot>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
@@ -268,6 +285,11 @@ export class HaTopAppBarFixed extends LitElement {
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
private _handleBackClick(ev: Event) {
ev.stopPropagation();
goBack();
}
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
+14 -4
View File
@@ -146,10 +146,20 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const installUpdates = (
hass: HomeAssistant,
entityIds: string[],
notifyOnError = true
) =>
hass.callService(
"update",
"install",
{
entity_id: entityIds,
},
undefined,
notifyOnError
);
export const checkForEntityUpdates = async (
element: HTMLElement,
+3 -1
View File
@@ -40,7 +40,9 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = getDefaultAddToActions();
this._defaultActions = this._config?.user?.is_admin
? getDefaultAddToActions()
: [];
this._externalActions = [];
if (this._config?.auth.external?.config.hasEntityAddTo) {
+1 -2
View File
@@ -266,9 +266,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _shouldShowAddEntityTo(): boolean {
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
return (
this._newTriggersAndConditions ||
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
+17 -35
View File
@@ -2,9 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-button";
import "../components/ha-menu-button";
import "../components/ha-top-app-bar-fixed";
import type { HomeAssistant } from "../types";
import "../components/ha-alert";
@@ -21,18 +20,22 @@ class HassErrorScreen extends LitElement {
@property() public error?: string;
protected render(): TemplateResult {
if (!this.toolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.toolbar
? html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`
: ""}
<div class="content">
<ha-alert alert-type="error">${this.error}</ha-alert>
<slot>
@@ -56,30 +59,9 @@ class HassErrorScreen extends LitElement {
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
color: var(--primary-text-color);
height: calc(100% - var(--header-height));
height: 100%;
display: flex;
padding: 16px;
align-items: center;
+34 -64
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("hass-loading-screen")
@@ -22,18 +19,22 @@ class HassLoadingScreen extends LitElement {
@property() public message?: string;
protected render(): TemplateResult {
if (this.noToolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.noToolbar
? ""
: html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`}
<div class="content">
<ha-spinner></ha-spinner>
${this.message
@@ -43,55 +44,24 @@ class HassLoadingScreen extends LitElement {
`;
}
private _handleBack() {
goBack();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-menu-button,
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
height: calc(100% - var(--header-height));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: 16px;
}
`,
];
}
static styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.content {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: var(--ha-space-4);
}
`;
}
declare global {
+4 -6
View File
@@ -6,6 +6,9 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
superClass: T
) =>
class extends superClass {
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
@@ -33,7 +36,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirty) {
if (this.isDirtyState) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
@@ -47,11 +50,6 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
this._removeListeners();
}
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
protected get isDirty(): boolean {
return false;
}
protected async promptDiscardChanges(): Promise<boolean> {
return true;
}
-4
View File
@@ -16,7 +16,6 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
@@ -119,7 +118,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
if (!this._entityRegistry) {
return html`
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">
${this.hass.localize("ui.components.calendar.my_calendars")}
</div>
@@ -154,8 +152,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
footer
.narrow=${this.narrow}
>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
${!showPane
? html`<ha-dropdown slot="title">
<ha-button slot="trigger">
+4 -17
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -90,22 +87,12 @@ class PanelClimate extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
@@ -1,13 +1,18 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
import { saveFabStyles } from "./styles";
@@ -19,7 +24,9 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@property({ type: Boolean }) public dirty = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get _config(): BlueprintAutomationConfig {
return this.config;
@@ -58,7 +65,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this.dirty ? "dirty" : ""}
class=${this._dirtyState?.isDirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveAutomation}
>
+13 -5
View File
@@ -4,17 +4,25 @@ import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
// Editor elements that expose dirty tracking: the top-level automation/script
// editors via `isDirtyState`, and the manual editors via `dirty`.
interface DirtyStateElement extends HTMLElement {
isDirtyState?: boolean;
dirty?: boolean;
}
const isDirtyStateElement = (el: HTMLElement | null): el is DirtyStateElement =>
el !== null && ("isDirtyState" in el || "dirty" in el);
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
return isDirtyStateElement(el) && Boolean(el.isDirtyState);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
const holder = closestWithProperty(el, "dirty", false);
return isDirtyStateElement(holder) && Boolean(holder.dirty);
}
export function showEditorToast(
@@ -146,6 +146,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
return super.isDirtyState || !!this.yamlErrors;
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -421,7 +425,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.dirty=${this.dirty}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
></blueprint-automation-editor>
@@ -434,7 +437,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.stateObj=${stateObj}
.config=${this.config}
.disabled=${this.readOnly}
.dirty=${this.dirty}
.saving=${this.saving}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@@ -554,7 +556,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<ha-button
slot="fab"
size="l"
class=${this.dirty ? "dirty" : ""}
class=${this.isDirtyState ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveAutomation}
>
@@ -602,7 +604,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
!this.entityId
) {
const initData = getAutomationEditorInitData();
this.dirty = !!initData;
let baseConfig: Partial<AutomationConfig> = { description: "" };
if (!initData || !("use_blueprint" in initData)) {
baseConfig = {
@@ -617,6 +618,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...baseConfig,
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
this._updateDirtyState(this.config);
this.currentEntityId = undefined;
this.readOnly = false;
}
@@ -624,10 +627,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (changedProps.has("entityId") && this.entityId) {
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._checkValidation();
});
this.currentEntityId = this.entityId;
this.dirty = false;
this.readOnly = true;
}
@@ -690,7 +693,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this.dirty = true;
this._updateDirtyState(this.config);
this.errors = undefined;
}
@@ -762,7 +765,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this.dirty = true;
if (!ev.detail.isValid) {
this.yamlErrors = ev.detail.errorMsg;
return;
@@ -772,11 +774,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
id: this.config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._updateDirtyState(this.config!);
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
if (!this.dirty) {
if (!this.isDirtyState) {
return true;
}
@@ -787,7 +790,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -901,7 +904,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
resolve(true);
},
@@ -918,7 +921,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this.dirty = true;
this._updateDirtyState(config);
this.requestUpdate();
resolve();
},
@@ -1009,7 +1012,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
this.dirty = false;
this._markDirtyStateClean();
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -1068,7 +1071,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this.dirty = true;
this._updateDirtyState(this.config);
}
private _undo() {
@@ -20,6 +20,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
@@ -87,7 +88,9 @@ export interface EditorDomainHooks<TConfig> {
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends superClass {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
superClass
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -102,8 +105,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected dirty = false;
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@@ -217,7 +218,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
this.dirty = true;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
this._updateDirtyState(this.config!);
this.blueprintConfig = undefined;
}
@@ -237,10 +240,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
@@ -259,9 +258,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
this.dirty = false;
this.readOnly = false;
this.config = hooks.normalizeConfig(config);
this._initDirtyTracking({ type: "deep" }, this.config);
hooks.checkValidation();
} catch (err: any) {
if (err.status_code !== 404) {
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -19,6 +20,10 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type {
Constructor,
HomeAssistant,
@@ -46,7 +51,13 @@ export const ManualEditorMixin = <TConfig>(
@property({ attribute: false }) public config!: TConfig;
@property({ attribute: false }) public dirty = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get dirty(): boolean {
return this._dirtyState?.isDirty ?? false;
}
@state() protected pastedConfig?: TConfig;
@@ -45,6 +45,7 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
@@ -347,13 +348,22 @@ class HaConfigSectionUpdates extends LitElement {
return;
}
try {
await installUpdates(this.hass, entityIds);
await installUpdates(this.hass, entityIds, false);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
let message = extractApiErrorMessage(err);
// The backend error embeds the raw entity_id; swap in the update's name.
for (const entityId of entityIds) {
const stateObj = this.hass.states[entityId] as UpdateEntity | undefined;
if (stateObj && message.includes(entityId)) {
message = message.replaceAll(
entityId,
stateObj.attributes.title ||
stateObj.attributes.friendly_name ||
entityId
);
}
}
showToast(this, { message, duration: 10000, dismissable: true });
}
}
@@ -17,7 +17,6 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tip";
import "../../../components/ha-tooltip";
@@ -236,7 +235,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">${this.hass.localize("panel.config")}</div>
<ha-icon-button
@@ -22,6 +22,7 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-energy-power-config";
@@ -35,13 +36,19 @@ import {
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
interface BatteryFormState {
source: BatterySourceTypeEnergyPreference;
powerType: PowerType;
powerConfig: PowerConfig;
}
const energyUnitClasses = ["energy"];
const socStatisticsUnits = ["%"];
const socDeviceClass = "battery";
@customElement("dialog-energy-battery-settings")
export class DialogEnergyBatterySettings
extends LitElement
extends DirtyStateProviderMixin<BatteryFormState>()(LitElement)
implements HassDialog<EnergySettingsBatteryDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -108,6 +115,14 @@ export class DialogEnergyBatterySettings
);
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
}
);
}
public closeDialog() {
@@ -137,7 +152,7 @@ export class DialogEnergyBatterySettings
header-title=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
@@ -241,7 +256,8 @@ export class DialogEnergyBatterySettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid()}
.disabled=${!this._isValid() ||
(!!this._params?.source && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -283,11 +299,13 @@ export class DialogEnergyBatterySettings
private _statisticToChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_energy_to: ev.detail.value };
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _statisticFromChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -298,6 +316,7 @@ export class DialogEnergyBatterySettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _handlePowerConfigChanged(
@@ -305,6 +324,7 @@ export class DialogEnergyBatterySettings
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
this._updateFormDirtyState();
}
private _statisticSocChanged(ev: ValueChangedEvent<string>) {
@@ -312,6 +332,15 @@ export class DialogEnergyBatterySettings
...this._source!,
stat_soc: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
});
}
private async _save() {
@@ -335,6 +364,7 @@ export class DialogEnergyBatterySettings
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -23,6 +23,7 @@ import {
saveFrontendSystemData,
} from "../../../../data/frontend";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import { showToast } from "../../../../util/toast";
import type {
@@ -45,7 +46,7 @@ const VIEW_GROUPS: { view: EnergyViewPath; labelKey: LocalizeKeys }[] = [
@customElement("dialog-energy-customise")
export class DialogEnergyCustomise
extends LitElement
extends DirtyStateProviderMixin<string[]>()(LitElement)
implements HassDialog<EnergyCustomiseDialogParams>
{
@state()
@@ -107,6 +108,7 @@ export class DialogEnergyCustomise
this._error = err?.message || "Unknown error";
this._hidden = new Set();
}
this._initDirtyTracking({ type: "deep" }, [...this._hidden].sort());
}
protected render() {
@@ -120,7 +122,7 @@ export class DialogEnergyCustomise
.headerTitle=${this._i18n.localize(
"ui.panel.config.energy.customise.title"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${!this._hidden
@@ -143,7 +145,10 @@ export class DialogEnergyCustomise
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this._hidden || !!this._error}
.disabled=${this._submitting ||
!this._hidden ||
!!this._error ||
!this.isDirtyState}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
@@ -216,6 +221,7 @@ export class DialogEnergyCustomise
next.add(cardKey);
}
this._hidden = next;
this._updateDirtyState([...this._hidden].sort());
};
private async _save(): Promise<void> {
@@ -228,6 +234,7 @@ export class DialogEnergyCustomise
await saveFrontendSystemData(this._connection.connection, "energy", {
hidden_cards: hidden.length ? hidden : undefined,
});
this._markDirtyStateClean();
this._params?.saveCallback?.();
this.closeDialog();
} catch (_err) {
@@ -20,6 +20,7 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy";
@@ -29,7 +30,9 @@ const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-device-settings-water")
export class DialogEnergyDeviceSettingsWater
extends LitElement
extends DirtyStateProviderMixin<DeviceConsumptionEnergyPreference | null>()(
LitElement
)
implements HassDialog<EnergySettingsDeviceWaterDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -72,6 +75,7 @@ export class DialogEnergyDeviceSettingsWater
.filter((id) => id && id !== this._device?.stat_rate) as string[];
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._device ?? null);
}
private _computePossibleParents() {
@@ -146,7 +150,7 @@ export class DialogEnergyDeviceSettingsWater
header-title=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -226,7 +230,8 @@ export class DialogEnergyDeviceSettingsWater
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._device}
.disabled=${!this._device ||
(!!this._params?.device && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -239,10 +244,12 @@ export class DialogEnergyDeviceSettingsWater
private async _statisticChanged(ev: ValueChangedEvent<string>) {
if (!ev.detail.value) {
this._device = undefined;
this._updateDirtyState(this._device ?? null);
return;
}
this._device = { stat_consumption: ev.detail.value };
this._computePossibleParents();
this._updateDirtyState(this._device);
if (
isExternalStatistic(ev.detail.value) &&
@@ -271,6 +278,7 @@ export class DialogEnergyDeviceSettingsWater
delete newDevice.stat_rate;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _nameChanged(ev: InputEvent) {
@@ -282,6 +290,7 @@ export class DialogEnergyDeviceSettingsWater
delete newDevice.name;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
@@ -293,11 +302,13 @@ export class DialogEnergyDeviceSettingsWater
delete newDevice.included_in_stat;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private async _save() {
try {
await this._params!.saveCallback(this._device!);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -23,6 +23,7 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
@@ -32,7 +33,9 @@ const powerUnitClasses = ["power"];
@customElement("dialog-energy-device-settings")
export class DialogEnergyDeviceSettings
extends LitElement
extends DirtyStateProviderMixin<DeviceConsumptionEnergyPreference | null>()(
LitElement
)
implements HassDialog<EnergySettingsDeviceDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -75,6 +78,7 @@ export class DialogEnergyDeviceSettings
.filter((id) => id && id !== this._device?.stat_rate) as string[];
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._device ?? null);
}
private _computePossibleParents() {
@@ -147,7 +151,7 @@ export class DialogEnergyDeviceSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -226,7 +230,8 @@ export class DialogEnergyDeviceSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._device}
.disabled=${!this._device ||
(!!this._params?.device && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -239,10 +244,12 @@ export class DialogEnergyDeviceSettings
private async _statisticChanged(ev: ValueChangedEvent<string>) {
if (!ev.detail.value) {
this._device = undefined;
this._updateDirtyState(this._device ?? null);
return;
}
this._device = { stat_consumption: ev.detail.value };
this._computePossibleParents();
this._updateDirtyState(this._device);
if (
isExternalStatistic(ev.detail.value) &&
@@ -271,6 +278,7 @@ export class DialogEnergyDeviceSettings
delete newDevice.stat_rate;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _nameChanged(ev: InputEvent) {
@@ -282,6 +290,7 @@ export class DialogEnergyDeviceSettings
delete newDevice.name;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
@@ -293,11 +302,13 @@ export class DialogEnergyDeviceSettings
delete newDevice.included_in_stat;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private async _save() {
try {
await this._params!.saveCallback(this._device!);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -25,18 +25,26 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsGasDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
type CostType = "no-costs" | "number" | "entity" | "statistic";
interface GasFormState {
source: GasSourceTypeEnergyPreference;
costs: CostType;
}
const gasDeviceClasses = ["gas", "energy"];
const gasUnitClasses = ["volume", "energy"];
const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-gas-settings")
export class DialogEnergyGasSettings
extends LitElement
extends DirtyStateProviderMixin<GasFormState>()(LitElement)
implements HassDialog<EnergySettingsGasDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,7 +55,7 @@ export class DialogEnergyGasSettings
@state() private _source?: GasSourceTypeEnergyPreference;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _costs?: CostType;
@state() private _pickedDisplayUnit?: string | null;
@@ -101,6 +109,10 @@ export class DialogEnergyGasSettings
.filter((id) => id && id !== this._source?.stat_rate) as string[];
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ source: this._source!, costs: this._costs! }
);
}
public closeDialog() {
@@ -153,7 +165,7 @@ export class DialogEnergyGasSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -318,7 +330,8 @@ export class DialogEnergyGasSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source.stat_energy_from}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -329,11 +342,8 @@ export class DialogEnergyGasSettings
}
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
}
private _numberPriceChanged(ev: InputEvent) {
@@ -343,6 +353,7 @@ export class DialogEnergyGasSettings
entity_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _priceStatChanged(ev: CustomEvent) {
@@ -352,6 +363,7 @@ export class DialogEnergyGasSettings
number_energy_price: null,
stat_cost: ev.detail.value,
};
this._updateFormDirtyState();
}
private _priceEntityChanged(ev: CustomEvent) {
@@ -361,6 +373,7 @@ export class DialogEnergyGasSettings
number_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
@@ -368,6 +381,7 @@ export class DialogEnergyGasSettings
...this._source!,
stat_rate: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
@@ -399,6 +413,7 @@ export class DialogEnergyGasSettings
...this._source!,
stat_energy_from: ev.detail.value,
};
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -409,6 +424,11 @@ export class DialogEnergyGasSettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({ source: this._source!, costs: this._costs! });
}
private async _save() {
@@ -419,6 +439,7 @@ export class DialogEnergyGasSettings
this._source!.stat_cost = null;
}
await this._params!.saveCallback(this._source!);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -26,6 +26,7 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-energy-power-config";
@@ -43,9 +44,17 @@ const energyUnitClasses = ["energy"];
type CostType = "no_cost" | "stat" | "entity" | "number";
interface GridFormState {
source: GridSourceTypeEnergyPreference;
powerType: PowerType;
powerConfig: PowerConfig;
importCostType: CostType;
exportCostType: CostType;
}
@customElement("dialog-energy-grid-settings")
export class DialogEnergyGridSettings
extends LitElement
extends DirtyStateProviderMixin<GridFormState>()(LitElement)
implements HassDialog<EnergySettingsGridDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -144,6 +153,16 @@ export class DialogEnergyGridSettings
);
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
importCostType: this._importCostType,
exportCostType: this._exportCostType,
}
);
}
public closeDialog() {
@@ -185,7 +204,7 @@ export class DialogEnergyGridSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
@@ -446,7 +465,8 @@ export class DialogEnergyGridSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid()}
.disabled=${!this._isValid() ||
(!!this._params?.source && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -507,6 +527,7 @@ export class DialogEnergyGridSettings
};
}
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _statisticToChanged(ev: ValueChangedEvent<string>) {
@@ -536,6 +557,7 @@ export class DialogEnergyGridSettings
};
}
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -546,6 +568,7 @@ export class DialogEnergyGridSettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _handleImportCostTypeChanged(ev: Event) {
@@ -557,6 +580,7 @@ export class DialogEnergyGridSettings
entity_energy_price: null,
number_energy_price: null,
};
this._updateFormDirtyState();
}
private _handleExportCostTypeChanged(ev: Event) {
@@ -568,10 +592,12 @@ export class DialogEnergyGridSettings
entity_energy_price_export: null,
number_energy_price_export: null,
};
this._updateFormDirtyState();
}
private _statCostChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_cost: ev.detail.value || null };
this._updateFormDirtyState();
}
private _entityCostChanged(ev: ValueChangedEvent<string>) {
@@ -579,12 +605,14 @@ export class DialogEnergyGridSettings
...this._source!,
entity_energy_price: ev.detail.value || null,
};
this._updateFormDirtyState();
}
private _numberCostChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
this._source = { ...this._source!, number_energy_price: value };
this._updateFormDirtyState();
}
private _statCompensationChanged(ev: ValueChangedEvent<string>) {
@@ -592,6 +620,7 @@ export class DialogEnergyGridSettings
...this._source!,
stat_compensation: ev.detail.value || null,
};
this._updateFormDirtyState();
}
private _entityCompensationChanged(ev: ValueChangedEvent<string>) {
@@ -599,12 +628,14 @@ export class DialogEnergyGridSettings
...this._source!,
entity_energy_price_export: ev.detail.value || null,
};
this._updateFormDirtyState();
}
private _numberCompensationChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
this._source = { ...this._source!, number_energy_price_export: value };
this._updateFormDirtyState();
}
private _handlePowerConfigChanged(
@@ -612,6 +643,17 @@ export class DialogEnergyGridSettings
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
importCostType: this._importCostType,
exportCostType: this._exportCostType,
});
}
private async _save() {
@@ -638,6 +680,7 @@ export class DialogEnergyGridSettings
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -24,6 +24,7 @@ import {
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
@@ -35,12 +36,17 @@ import {
} from "../../../../data/recorder";
import type { HaInput } from "../../../../components/input/ha-input";
interface SolarFormState {
source: SolarSourceTypeEnergyPreference;
forecast: boolean;
}
const energyUnitClasses = ["energy"];
const powerUnitClasses = ["power"];
@customElement("dialog-energy-solar-settings")
export class DialogEnergySolarSettings
extends LitElement
extends DirtyStateProviderMixin<SolarFormState>()(LitElement)
implements HassDialog<EnergySettingsSolarDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -88,6 +94,10 @@ export class DialogEnergySolarSettings
.filter((id) => id && id !== this._source?.stat_rate) as string[];
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ source: this._source!, forecast: this._forecast! }
);
}
public closeDialog() {
@@ -114,7 +124,7 @@ export class DialogEnergySolarSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -248,7 +258,8 @@ export class DialogEnergySolarSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source.stat_energy_from}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -275,23 +286,23 @@ export class DialogEnergySolarSettings
private _handleForecastChanged(ev: Event) {
this._forecast = (ev.currentTarget as HaRadioGroup).value === "true";
this._updateFormDirtyState();
}
private _forecastCheckChanged(ev) {
const input = ev.currentTarget as HaCheckbox;
const entry = (input as any).entry as ConfigEntry;
const checked = input.checked;
const list = this._source!.config_entry_solar_forecast
? [...this._source!.config_entry_solar_forecast]
: [];
if (checked) {
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(entry.entry_id);
list.push(entry.entry_id);
} else {
this._source!.config_entry_solar_forecast!.splice(
this._source!.config_entry_solar_forecast!.indexOf(entry.entry_id),
1
);
list.splice(list.indexOf(entry.entry_id), 1);
}
this._source = { ...this._source!, config_entry_solar_forecast: list };
this._updateFormDirtyState();
}
private _addForecast() {
@@ -299,11 +310,16 @@ export class DialogEnergySolarSettings
startFlowHandler: "forecast_solar",
dialogClosedCallback: (params) => {
if (params.entryId) {
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(params.entryId);
const list = this._source!.config_entry_solar_forecast
? [...this._source!.config_entry_solar_forecast]
: [];
list.push(params.entryId);
this._source = {
...this._source!,
config_entry_solar_forecast: list,
};
this._fetchSolarForecastConfigEntries();
this._updateFormDirtyState();
}
},
});
@@ -325,10 +341,12 @@ export class DialogEnergySolarSettings
this.requestUpdate("_params");
}
}
this._updateFormDirtyState();
}
private _powerStatisticChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_rate: ev.detail.value };
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -339,6 +357,14 @@ export class DialogEnergySolarSettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({
source: this._source!,
forecast: this._forecast!,
});
}
private async _save() {
@@ -347,6 +373,7 @@ export class DialogEnergySolarSettings
this._source!.config_entry_solar_forecast = null;
}
await this._params!.saveCallback(this._source!);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -24,16 +24,24 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsWaterDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
type CostType = "no-costs" | "number" | "entity" | "statistic";
interface WaterFormState {
source: WaterSourceTypeEnergyPreference;
costs: CostType;
}
const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-water-settings")
export class DialogEnergyWaterSettings
extends LitElement
extends DirtyStateProviderMixin<WaterFormState>()(LitElement)
implements HassDialog<EnergySettingsWaterDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -44,7 +52,7 @@ export class DialogEnergyWaterSettings
@state() private _source?: WaterSourceTypeEnergyPreference;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _costs?: CostType;
@state() private _water_units?: string[];
@@ -84,6 +92,10 @@ export class DialogEnergyWaterSettings
.filter((id) => id && id !== this._source?.stat_rate) as string[];
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ source: this._source!, costs: this._costs! }
);
}
public closeDialog() {
@@ -121,7 +133,7 @@ export class DialogEnergyWaterSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.water.dialog.header"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -259,7 +271,8 @@ export class DialogEnergyWaterSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source.stat_energy_from}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -270,11 +283,8 @@ export class DialogEnergyWaterSettings
}
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
}
private _numberPriceChanged(ev: InputEvent) {
@@ -284,6 +294,7 @@ export class DialogEnergyWaterSettings
entity_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _priceStatChanged(ev: CustomEvent) {
@@ -293,6 +304,7 @@ export class DialogEnergyWaterSettings
number_energy_price: null,
stat_cost: ev.detail.value,
};
this._updateFormDirtyState();
}
private _priceEntityChanged(ev: CustomEvent) {
@@ -302,6 +314,7 @@ export class DialogEnergyWaterSettings
number_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
@@ -309,6 +322,7 @@ export class DialogEnergyWaterSettings
...this._source!,
stat_rate: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
@@ -338,6 +352,7 @@ export class DialogEnergyWaterSettings
this.requestUpdate("_params");
}
}
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -348,6 +363,11 @@ export class DialogEnergyWaterSettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({ source: this._source!, costs: this._costs! });
}
private async _save() {
@@ -358,6 +378,7 @@ export class DialogEnergyWaterSettings
this._source!.stat_cost = null;
}
await this._params!.saveCallback(this._source!);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
+27 -24
View File
@@ -75,6 +75,7 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-subpage";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
@@ -97,8 +98,8 @@ interface DeviceEntities {
type DeviceEntitiesLookup = Record<string, string[]>;
@customElement("ha-scene-editor")
export class HaSceneEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -112,10 +113,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
@property({ attribute: false }) public scenes!: SceneEntity[];
@state() private _dirty = false;
@state() private _errors?: string;
private _sceneRevision = 0;
@state() private _yamlErrors?: string;
@state() private _config?: SceneConfig;
@@ -322,7 +323,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
.disabled=${this._saving}
@click=${this._saveScene}
class=${classMap({
dirty: this._dirty || !this.sceneId,
dirty: this.isDirtyState || !this.sceneId,
saving: this._saving,
})}
>
@@ -641,7 +642,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
}
if (changedProps.has("sceneId") && !this.sceneId && this.hass) {
this._dirty = false;
this._sceneRevision = 0;
const initData = getSceneEditorInitData();
this._config = {
name: this.hass.localize("ui.panel.config.scene.editor.default_name"),
@@ -656,9 +657,13 @@ export class HaSceneEditor extends PreventUnsavedMixin(
category: "",
};
}
this._dirty =
this._initDirtyTracking({ type: "shallow" }, 0);
if (
initData !== undefined &&
(initData.areaId !== undefined || initData.config !== undefined);
(initData.areaId !== undefined || initData.config !== undefined)
) {
this._updateDirtyState(++this._sceneRevision);
}
}
if (changedProps.has("_entityRegistryEntries")) {
@@ -816,7 +821,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
}
private async _enterLiveMode() {
if (this._dirty) {
if (this.isDirtyState) {
const result = await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.scene.editor.enter_live_mode_unsaved"
@@ -850,13 +855,14 @@ export class HaSceneEditor extends PreventUnsavedMixin(
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this._dirty = true;
if (!ev.detail.isValid) {
this._yamlErrors = ev.detail.errorMsg;
this._updateDirtyState(++this._sceneRevision);
return;
}
this._yamlErrors = undefined;
this._config = ev.detail.value;
this._updateDirtyState(++this._sceneRevision);
this._errors = undefined;
}
@@ -911,7 +917,8 @@ export class HaSceneEditor extends PreventUnsavedMixin(
(entity: SceneEntity) => entity.attributes.id === this.sceneId
);
this._dirty = false;
this._sceneRevision = 0;
this._initDirtyTracking({ type: "shallow" }, 0);
this._config = config;
}
@@ -960,7 +967,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
this._entities = [...this._entities, entityId];
this._single_entities.push(entityId);
this._storeState(entityId);
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
}
private _deleteEntity(ev: Event) {
@@ -978,7 +985,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
if (this._config!.metadata) {
delete this._config!.metadata[deleteEntityId];
}
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
}
private _pickDevice(device_id: string) {
@@ -994,7 +1001,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
deviceEntities.forEach((entityId) => {
this._storeState(entityId);
});
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
}
private _devicePicked(ev: CustomEvent) {
@@ -1018,7 +1025,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
delete this._config!.entities[entityId];
});
}
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
}
private _stateChanged(event: HassEvent) {
@@ -1026,7 +1033,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
event.context.id !== this._activateContextId &&
this._entities.includes(event.data.entity_id)
) {
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
}
}
@@ -1072,7 +1079,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (this._dirty) {
if (this.isDirtyState) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
@@ -1239,7 +1246,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
}
}
this._dirty = false;
this._markDirtyStateClean();
if (isNewScene) {
navigate(`/config/scene/edit/${id}`, { replace: true });
}
@@ -1294,7 +1301,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
updateConfig: async (newConfig, entityRegistryUpdate) => {
this._config = newConfig;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
this.requestUpdate();
resolve(true);
},
@@ -1313,7 +1320,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
updateConfig: async (newConfig, entityRegistryUpdate) => {
this._config = newConfig;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
this._updateDirtyState(++this._sceneRevision);
this.requestUpdate();
resolve(true);
},
@@ -1322,10 +1329,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
});
}
protected get isDirty() {
return this._dirty;
}
protected async promptDiscardChanges() {
return this._confirmUnsavedChanged();
}
@@ -1,10 +1,15 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import { fetchBlueprints } from "../../../data/blueprint";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { BlueprintScriptConfig } from "../../../data/script";
import { saveFabStyles } from "../automation/styles";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
@@ -15,7 +20,9 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@property({ type: Boolean }) public dirty = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get _config(): BlueprintScriptConfig {
return this.config;
@@ -35,7 +42,7 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this.dirty ? "dirty" : ""}
class=${this._dirtyState?.isDirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveScript}
>
+17 -14
View File
@@ -107,6 +107,10 @@ export class HaScriptEditor extends SubscribeMixin(
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
return super.isDirtyState || !!this.yamlErrors;
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -377,7 +381,6 @@ export class HaScriptEditor extends SubscribeMixin(
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.dirty=${this.dirty}
@value-changed=${this._valueChanged}
@save-script=${this._handleSaveScript}
></blueprint-script-editor>
@@ -389,7 +392,6 @@ export class HaScriptEditor extends SubscribeMixin(
.isWide=${this.isWide}
.config=${this.config}
.disabled=${this.readOnly}
.dirty=${this.dirty}
.saving=${this.saving}
@value-changed=${this._valueChanged}
@editor-save=${this._handleSaveScript}
@@ -470,7 +472,7 @@ export class HaScriptEditor extends SubscribeMixin(
<ha-button
slot="fab"
size="l"
class=${!this.readOnly && this.dirty ? "dirty" : ""}
class=${!this.readOnly && this.isDirtyState ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveScript}
>
@@ -522,7 +524,6 @@ export class HaScriptEditor extends SubscribeMixin(
if (changedProps.has("scriptId") && !this.scriptId && this.hass) {
const initData = getScriptEditorInitData();
this.dirty = !!initData;
const baseConfig: Partial<ScriptConfig> = {};
if (!initData || !("use_blueprint" in initData)) {
baseConfig.sequence = [];
@@ -531,12 +532,15 @@ export class HaScriptEditor extends SubscribeMixin(
...baseConfig,
...initData,
} as ScriptConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as ScriptConfig);
this._updateDirtyState(this.config);
this.readOnly = false;
}
if (changedProps.has("entityId") && this.entityId) {
getScriptStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeScriptConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._checkValidation();
});
const regEntry = this.entityRegistry?.find(
@@ -546,7 +550,6 @@ export class HaScriptEditor extends SubscribeMixin(
this.scriptId = regEntry.unique_id;
}
this.currentEntityId = this.entityId;
this.dirty = false;
this.readOnly = true;
}
}
@@ -582,7 +585,7 @@ export class HaScriptEditor extends SubscribeMixin(
this.config = ev.detail.value;
this.errors = undefined;
this.dirty = true;
this._updateDirtyState(this.config!);
}
private async _runScript() {
@@ -669,7 +672,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
this._manualEditor?.addFields();
this.dirty = true;
this._updateDirtyState(this.config!);
}
private _preprocessYaml() {
@@ -678,18 +681,18 @@ export class HaScriptEditor extends SubscribeMixin(
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this.dirty = true;
if (!ev.detail.isValid) {
this.yamlErrors = ev.detail.errorMsg;
return;
}
this.yamlErrors = undefined;
this.config = ev.detail.value;
this._updateDirtyState(this.config!);
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
if (!this.dirty) {
if (!this.isDirtyState) {
return true;
}
@@ -700,7 +703,7 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
const id = this.scriptId || String(Date.now());
@@ -815,7 +818,7 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
resolve(true);
},
@@ -834,7 +837,7 @@ export class HaScriptEditor extends SubscribeMixin(
config: this.config!,
updateConfig: (config) => {
this.config = config;
this.dirty = true;
this._updateDirtyState(config);
this.requestUpdate();
resolve();
},
@@ -930,7 +933,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
}
this.dirty = false;
this._markDirtyStateClean();
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -980,7 +983,7 @@ export class HaScriptEditor extends SubscribeMixin(
private _applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this.dirty = true;
this._updateDirtyState(this.config);
}
private _undo() {
+5 -16
View File
@@ -15,7 +15,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../common/decorators/storage";
import { computeDomain } from "../../common/entity/compute_domain";
import { goBack, navigate } from "../../common/navigate";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
@@ -34,8 +34,6 @@ import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
@@ -117,22 +115,13 @@ class HaPanelHistory extends LitElement {
this._unsubscribeHistory();
}
private _goBack(): void {
goBack();
}
protected render() {
const entitiesSelected = this._getEntityIds().length > 0;
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._showBack
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!!this._showBack}
>
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
+4 -17
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -90,22 +87,12 @@ class PanelLight extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.light")}</div>
${this._lovelace
? html`
+5 -16
View File
@@ -5,7 +5,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../common/decorators/storage";
import { goBack, navigate } from "../../common/navigate";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
@@ -18,8 +18,6 @@ import {
} from "../../common/url/search-params";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
@@ -65,21 +63,12 @@ export class HaPanelLogbook extends LitElement {
this._time = { range: [start, end] };
}
private _goBack(): void {
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._showBack
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@@ -80,12 +80,7 @@ class HuiTimestampDisplay extends LitElement {
if (!changedProperties.has("format") || !this._connected) {
return;
}
if (INTERVAL_FORMAT.includes("relative")) {
this._startInterval();
} else {
this._clearInterval();
}
this._startInterval();
}
private get _format(): string {
@@ -486,15 +486,10 @@ export class HaCardConditionEditor extends LitElement {
--expansion-panel-content-padding: 0;
}
.icon-badge-wrapper {
display: none;
}
@media (min-width: 870px) {
.icon-badge-wrapper {
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
}
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
}
h3 {
margin: 0;
+20 -104
View File
@@ -1,12 +1,9 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -90,41 +87,27 @@ class PanelMaintenance extends LitElement {
this._setLovelace();
};
private _back(ev: Event) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<div class="main-title">
${this.hass.localize("panel.maintenance")}
</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`
: nothing}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`
: nothing}
</ha-top-app-bar-fixed>
`;
}
@@ -165,77 +148,10 @@ class PanelMaintenance extends LitElement {
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
position: fixed;
top: 0;
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
z-index: 4;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--bar-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
-2
View File
@@ -6,7 +6,6 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { navigate } from "../../common/navigate";
import "../../components/ha-icon-button";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import "../../components/map/ha-map";
import { haStyle } from "../../resources/styles";
@@ -23,7 +22,6 @@ class HaPanelMap extends LitElement {
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">${this.hass.localize("panel.map")}</div>
${!__DEMO__ && this.hass.user?.is_admin
? html`<ha-icon-button
@@ -5,7 +5,7 @@ import {
mdiListBoxOutline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
@@ -15,7 +15,6 @@ import "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import "../../components/media-player/ha-media-manage-button";
import "../../components/media-player/ha-media-player-browse";
@@ -100,7 +99,7 @@ class PanelMediaBrowser extends LitElement {
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
: nothing}
<h1 class="page-title" slot="title">
${!this._currentItem
? this.hass.localize(
+4 -17
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
@@ -90,22 +87,12 @@ class PanelSecurity extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${this._lovelace
? html`
-2
View File
@@ -32,7 +32,6 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
@@ -190,7 +189,6 @@ class PanelTodo extends LitElement {
footer
.narrow=${this.narrow}
>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">
${!showPane
? html`<ha-dropdown class="lists">
-1
View File
@@ -2750,7 +2750,6 @@
"group_integrations": "Integrations",
"group_apps": "Apps",
"update_all": "Update all",
"update_all_failed": "Failed to start updates",
"no_updates": "No updates available",
"no_update_entities": {
"title": "Unable to check for updates",
+29 -15
View File
@@ -77,15 +77,19 @@ export const clearBrandsTokenRefresh = (): void => {
};
export const brandsUrl = (options: BrandsOptions, hassUrl?: string): string => {
// The brands API requires a token; without one the request 401s. Return an
// empty src so no request fires until the token is available. Components
// re-render once the token arrives (see connection-mixin) and recompute this.
if (!_brandsAccessToken) {
return "";
}
hassUrl = hassUrl ?? location.origin;
const base = `/api/brands/integration/${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
const url = new URL(base, hassUrl);
if (_brandsAccessToken) {
url.searchParams.set("token", _brandsAccessToken);
}
url.searchParams.set("token", _brandsAccessToken);
return url.toString();
};
@@ -93,34 +97,44 @@ export const hardwareBrandsUrl = (
options: HardwareBrandsOptions,
hassUrl?: string
): string => {
// See brandsUrl: wait for the token before producing a loadable URL.
if (!_brandsAccessToken) {
return "";
}
hassUrl = hassUrl ?? location.origin;
const base = `/api/brands/hardware/${options.category}/${
options.darkOptimized ? "dark_" : ""
}${options.manufacturer}${options.model ? `_${options.model}` : ""}.png`;
const url = new URL(base, hassUrl);
if (_brandsAccessToken) {
url.searchParams.set("token", _brandsAccessToken);
}
url.searchParams.set("token", _brandsAccessToken);
return url.toString();
};
export const addBrandsAuth = (url: string, hassUrl?: string): string => {
hassUrl = hassUrl ?? location.origin;
if (!_brandsAccessToken) {
return url;
}
let parsedUrl: URL;
try {
const parsedUrl = new URL(url, hassUrl);
if (!parsedUrl.pathname.startsWith("/api/brands/")) {
return url;
}
parsedUrl.searchParams.set("token", _brandsAccessToken);
return parsedUrl.toString();
parsedUrl = new URL(url, hassUrl);
} catch {
return url;
}
// Non-brands URLs (e.g. CDN brands.home-assistant.io or camera proxies) are
// returned unchanged; they don't use the brands token.
if (!parsedUrl.pathname.startsWith("/api/brands/")) {
return url;
}
// Brands API request without a token would 401; return an empty src so it
// doesn't fire until the token is available.
if (!_brandsAccessToken) {
return "";
}
parsedUrl.searchParams.set("token", _brandsAccessToken);
return parsedUrl.toString();
};
export const extractDomainFromBrandUrl = (url: string): string => {
+61 -14
View File
@@ -1,4 +1,4 @@
import { assert, describe, it, vi, afterEach } from "vitest";
import { assert, describe, it, vi, afterEach, beforeEach } from "vitest";
import type { HomeAssistant } from "../../src/types";
import {
addBrandsAuth,
@@ -6,17 +6,74 @@ import {
clearBrandsTokenRefresh,
fetchAndScheduleBrandsAccessToken,
fetchBrandsAccessToken,
hardwareBrandsUrl,
scheduleBrandsTokenRefresh,
} from "../../src/util/brands-url";
// NOTE: the cached brands token is module-level state that persists across
// tests. The "without a token" assertions below must run before any test
// fetches a token, so this block is intentionally declared first.
describe("Brands URLs without a token", () => {
// The brands API requires a token; until one is fetched the URL builders
// return an empty src so no token-less request (which 401s) fires. Components
// re-render once the token arrives and recompute the URL.
it("brandsUrl returns an empty src", () => {
assert.strictEqual(
brandsUrl(
{ domain: "cloud", type: "logo" },
"http://homeassistant.local:8123"
),
""
);
});
it("hardwareBrandsUrl returns an empty src", () => {
assert.strictEqual(
hardwareBrandsUrl(
{ category: "boards", manufacturer: "raspberry_pi" },
"http://homeassistant.local:8123"
),
""
);
});
it("addBrandsAuth returns an empty src for brands URLs", () => {
assert.strictEqual(
addBrandsAuth(
"/api/brands/integration/demo/icon.png",
"http://homeassistant.local:8123"
),
""
);
});
it("addBrandsAuth returns non-brands URLs unchanged", () => {
assert.strictEqual(
addBrandsAuth(
"/api/camera_proxy/camera.foo?token=abc",
"http://homeassistant.local:8123"
),
"/api/camera_proxy/camera.foo?token=abc"
);
});
});
describe("Generate brands Url", () => {
// Fetch a token before these run so the URL builders produce loadable URLs.
beforeEach(async () => {
const mockHass = {
callWS: async () => ({ token: "test-token-123" }),
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
});
it("Generate logo brands url for cloud component", () => {
assert.strictEqual(
brandsUrl(
{ domain: "cloud", type: "logo" },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/logo.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/logo.png?token=test-token-123"
);
});
it("Generate icon brands url for cloud component", () => {
@@ -25,7 +82,7 @@ describe("Generate brands Url", () => {
{ domain: "cloud", type: "icon" },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/icon.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/icon.png?token=test-token-123"
);
});
@@ -35,7 +92,7 @@ describe("Generate brands Url", () => {
{ domain: "cloud", type: "logo", darkOptimized: true },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/dark_logo.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/dark_logo.png?token=test-token-123"
);
});
});
@@ -51,16 +108,6 @@ describe("addBrandsAuth", () => {
);
});
it("Returns brands URL unchanged when no token is available", () => {
assert.strictEqual(
addBrandsAuth(
"/api/brands/integration/demo/icon.png",
"http://homeassistant.local:8123"
),
"/api/brands/integration/demo/icon.png"
);
});
it("Appends token to brands URL when token is available", async () => {
const mockHass = {
callWS: async () => ({ token: "test-token-123" }),
+3 -3
View File
@@ -12265,9 +12265,9 @@ __metadata:
linkType: hard
"shell-quote@npm:^1.8.3":
version: 1.8.3
resolution: "shell-quote@npm:1.8.3"
checksum: 10/5473e354637c2bd698911224129c9a8961697486cff1fb221f234d71c153fc377674029b0223d1d3c953a68d451d79366abfe53d1a0b46ee1f28eb9ade928f4c
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c
languageName: node
linkType: hard