Compare commits

..

26 Commits

Author SHA1 Message Date
Bram Kragten d24a84e8e3 fix(build): make license override check hoisting-robust
The gen-licenses task read the top-level node_modules/type-fest to verify
the pinned 5.7.0 override, but yarn may hoist a different version there when
unrelated dependencies shift the graph (browserstack-node-sdk's subtree pulls
older type-fest versions via global-agent, pushing boxen's 2.19.0 to the root
slot). The production-bundled copy via zxing-wasm is still 5.7.0, so the
generated license file was correct — only the pre-flight check failed.

Resolve the pinned version wherever it is installed (hoisted root or nested
under its consumer) instead of assuming it is hoisted. The version-bump guard
still fails loudly if the pinned version is no longer installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:50:25 +02:00
Bram Kragten 1af4985714 Merge remote-tracking branch 'origin/dev' into e2e-playwright-tests
# Conflicts:
#	package.json
#	yarn.lock
2026-06-10 10:27:24 +02:00
Bram Kragten dff5767b27 update 2026-06-10 09:48:17 +02:00
Bram Kragten f40ddceadc test(e2e): handle skip return from waitForLocator in all sidebar wait calls 2026-05-27 10:27:42 +02:00
Bram Kragten de9a6d82de test(e2e): skip on both dynamic-import errors and BrowserStack iOS Internal error 2026-05-27 10:27:42 +02:00
Bram Kragten b691ebe336 test(e2e): revert to locator.waitFor with BS iOS Internal error catch-and-skip 2026-05-27 10:27:42 +02:00
Bram Kragten 79347ecc34 test(e2e): fix waitForShadow helper to use recursive shadow DOM traversal 2026-05-27 10:27:42 +02:00
Bram Kragten 01340c5e03 test(e2e): replace waitForSelector with evaluate polling in sidebar test for iOS 2026-05-27 10:27:42 +02:00
Bram Kragten f3abf60a19 test(e2e): use evaluate for card visibility check on iOS where locator can't pierce shadow DOM 2026-05-27 10:27:42 +02:00
Bram Kragten 748f616d15 test(e2e): fix evaluate argument serialization on BrowserStack iOS 2026-05-27 10:27:42 +02:00
Bram Kragten b4c88520f2 test(e2e): use recursive shadow DOM evaluate poll instead of waitForSelector on iOS 2026-05-27 10:27:41 +02:00
Bram Kragten ecada55a33 test(e2e): skip cards/dialog checks when dynamic imports fail over tunnel 2026-05-27 10:27:41 +02:00
Bram Kragten f443c0ba74 test(e2e): replace class-wildcard selector with explicit hui-* tag selectors for iPhone 2026-05-27 10:27:41 +02:00
Bram Kragten 30414703c1 test(e2e): fix WebKit dynamic-import filter and avoid concurrent waitFor on mobile 2026-05-27 10:27:41 +02:00
Bram Kragten e0e93e0fa9 test(e2e): reuse pre-existing BrowserStack page; filter infra dynamic-import errors 2026-05-27 10:27:41 +02:00
Bram Kragten b7bdad3d21 test(e2e): use serial mode + shared page to fix BrowserStack iPhone context limit 2026-05-27 10:27:41 +02:00
Bram Kragten bc55bc727d Use browserstack node SDK instead 2026-05-27 10:27:41 +02:00
Bram Kragten 7ec5f157c3 use bs-local.com for browserstack 2026-05-27 10:27:23 +02:00
Bram Kragten c1a21dff29 Update browserstack.capabilities.ts 2026-05-27 10:27:23 +02:00
Bram Kragten 2d19d40277 fix browserstack ios tests 2026-05-27 10:27:23 +02:00
Bram Kragten 73a028fa1a fix gallery tests 2026-05-27 10:27:23 +02:00
Bram Kragten 78074f7305 fix 2026-05-27 10:27:23 +02:00
Bram Kragten 1428a5b14e fix browserstack tests 2026-05-27 10:27:23 +02:00
Bram Kragten abd438fc47 fixes 2026-05-27 10:27:23 +02:00
Bram Kragten 27e2dc91ad Update e2e.yaml 2026-05-27 10:27:23 +02:00
Bram Kragten da9ce03987 Add e2e playwright tests 2026-05-27 10:27:23 +02:00
117 changed files with 5793 additions and 1721 deletions
-50
View File
@@ -1,50 +0,0 @@
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",
];
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();
}
+300
View File
@@ -0,0 +1,300 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+7
View File
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21
View File
@@ -268,3 +268,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+69 -29
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { readFile, access, readdir } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
licenseFile: "license-mit",
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
if (!packageDir) {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -231,3 +232,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -234,6 +234,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+17 -5
View File
@@ -21,7 +21,16 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -34,10 +43,10 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.8",
@@ -136,9 +145,11 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -157,6 +168,7 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
@@ -186,7 +198,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.4",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -194,7 +206,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.60.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+46 -86
View File
@@ -1,23 +1,5 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -37,47 +19,11 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) {
const pointData = getPointData(point);
@@ -89,39 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
frames.set(frameIndex, [{ point, x, y }]);
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
frame.push({ point, x, y });
}
}
// Convert frames back to points
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
}
}
+1 -22
View File
@@ -1520,9 +1520,7 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
line-height: 1;
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1560,25 +1558,6 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
+5 -27
View File
@@ -2,9 +2,6 @@ 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;
@@ -138,8 +135,6 @@ 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;
@@ -205,14 +200,16 @@ export class HaTopAppBarFixed extends LitElement {
<div class="row">
${paneHeader
? html`<section class="section" id="title">
${this._renderNavigationIcon()} ${title}
<slot name="navigationIcon"></slot>
${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`${this._renderNavigationIcon()}
${this.centerTitle ? nothing : title}`}
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
@@ -228,20 +225,6 @@ 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>
@@ -285,11 +268,6 @@ 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",
+1 -3
View File
@@ -128,13 +128,11 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string,
networkOnly: boolean
code: string
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
+4 -14
View File
@@ -146,20 +146,10 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (
hass: HomeAssistant,
entityIds: string[],
notifyOnError = true
) =>
hass.callService(
"update",
"install",
{
entity_id: entityIds,
},
undefined,
notifyOnError
);
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const checkForEntityUpdates = async (
element: HTMLElement,
@@ -10,21 +10,13 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
class DialogConfigEntrySystemOptions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -46,13 +38,6 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -83,7 +68,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
) || this._params.entry.domain,
}
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -150,7 +135,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -164,19 +149,11 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
+1 -3
View File
@@ -40,9 +40,7 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = this._config?.user?.is_admin
? getDefaultAddToActions()
: [];
this._defaultActions = getDefaultAddToActions();
this._externalActions = [];
if (this._config?.auth.external?.config.hasEntityAddTo) {
+2 -1
View File
@@ -266,8 +266,9 @@ 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.hass.user?.is_admin) ||
this._newTriggersAndConditions ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
-67
View File
@@ -19,64 +19,6 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -117,15 +59,6 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
+4
View File
@@ -420,6 +420,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+35 -17
View File
@@ -2,8 +2,9 @@ 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-top-app-bar-fixed";
import "../components/ha-menu-button";
import type { HomeAssistant } from "../types";
import "../components/ha-alert";
@@ -20,22 +21,18 @@ 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>
@@ -59,9 +56,30 @@ 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: 100%;
height: calc(100% - var(--header-height));
display: flex;
padding: 16px;
align-items: center;
+64 -34
View File
@@ -1,8 +1,11 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/ha-top-app-bar-fixed";
import { goBack } from "../common/navigate";
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")
@@ -19,22 +22,18 @@ 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
@@ -44,24 +43,55 @@ class HassLoadingScreen extends LitElement {
`;
}
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);
}
`;
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;
}
`,
];
}
}
declare global {
+6 -4
View File
@@ -6,9 +6,6 @@ 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];
@@ -36,7 +33,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState) {
if (this.isDirty) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
@@ -50,6 +47,11 @@ 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,6 +16,7 @@ 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";
@@ -118,6 +119,7 @@ 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>
@@ -152,6 +154,8 @@ 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">
+17 -4
View File
@@ -1,8 +1,11 @@
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";
@@ -87,12 +90,22 @@ class PanelClimate extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<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>`}
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
@@ -23,28 +23,18 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -86,7 +76,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -111,7 +100,10 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -292,7 +284,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -316,16 +307,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -32,7 +32,6 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -57,15 +56,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
class SupervisorAppConfig extends LitElement {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -352,7 +351,9 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -376,7 +377,6 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,13 +415,11 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -452,7 +450,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -471,7 +469,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this.isDirtyState || !this._valid) {
if (this.disabled || !this._configHasChanged || !this._valid) {
return;
}
@@ -501,7 +499,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
options,
});
this._markDirtyStateClean();
this._configHasChanged = false;
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -15,16 +15,13 @@ import type {
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
class SupervisorAppNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -33,19 +30,19 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, number | null>;
@state() private _config?: Record<string, any>;
protected render() {
if (!this._config) {
return nothing;
}
const config = this._config;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
);
return html`
@@ -101,7 +98,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this.isDirtyState || this.disabled}
.disabled=${!this._configHasChanged || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -118,10 +115,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private _createSchema = memoizeOne(
(
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
@@ -147,14 +141,12 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
item.name;
private _setNetworkConfig(): void {
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
this._config = this.addon.network || {};
}
private _configChanged(ev: CustomEvent): void {
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -169,7 +161,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -196,14 +188,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this.isDirtyState || this.disabled) {
if (!this._configHasChanged || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration: Record<string, number | null> = {};
const networkconfiguration = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
@@ -214,7 +206,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
@@ -55,20 +54,9 @@ const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
interface AreaFormState {
name: string;
aliases: string[];
labels: string[];
picture: string | null;
icon: string | null;
floor: string | null;
temperatureEntity: string | null;
humidityEntity: string | null;
}
@customElement("dialog-area-registry-detail")
class DialogAreaDetail
extends DirtyStateProviderMixin<AreaFormState>()(LitElement)
extends LitElement
implements HassDialog<AreaRegistryDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -128,23 +116,9 @@ class DialogAreaDetail
this._humidityEntity = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): AreaFormState {
return {
name: this._name,
aliases: this._aliases,
labels: this._labels,
picture: this._picture,
icon: this._icon,
floor: this._floor,
temperatureEntity: this._temperatureEntity,
humidityEntity: this._humidityEntity,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -352,8 +326,6 @@ class DialogAreaDetail
if (processed.floor) {
this._floor = processed.floor;
}
this._updateDirtyState(this._currentState());
}
protected render() {
@@ -371,7 +343,7 @@ class DialogAreaDetail
header-title=${entry
? this.hass.localize("ui.panel.config.areas.editor.update_area")
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
@@ -412,9 +384,7 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid ||
this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
@@ -448,43 +418,36 @@ class DialogAreaDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -506,7 +469,6 @@ class DialogAreaDetail
} else {
await this._params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -1,18 +1,13 @@
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, state } from "lit/decorators";
import { customElement, property } 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";
@@ -24,9 +19,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
@property({ type: Boolean }) public dirty = false;
protected get _config(): BlueprintAutomationConfig {
return this.config;
@@ -65,7 +58,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this._dirtyState?.isDirty ? "dirty" : ""}
class=${this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveAutomation}
>
+5 -13
View File
@@ -4,25 +4,17 @@ 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 isDirtyStateElement(el) && Boolean(el.isDirtyState);
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false);
return isDirtyStateElement(holder) && Boolean(holder.dirty);
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
export function showEditorToast(
@@ -146,10 +146,6 @@ 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);
@@ -425,6 +421,7 @@ 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>
@@ -437,6 +434,7 @@ 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}
@@ -556,7 +554,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<ha-button
slot="fab"
size="l"
class=${this.isDirtyState ? "dirty" : ""}
class=${this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveAutomation}
>
@@ -604,6 +602,7 @@ 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 = {
@@ -618,8 +617,6 @@ 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;
}
@@ -627,10 +624,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;
}
@@ -693,7 +690,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this._updateDirtyState(this.config);
this.dirty = true;
this.errors = undefined;
}
@@ -765,6 +762,7 @@ 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;
@@ -774,12 +772,11 @@ 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.isDirtyState) {
if (!this.dirty) {
return true;
}
@@ -790,7 +787,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -904,7 +901,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
resolve(true);
},
@@ -921,7 +918,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this.dirty = true;
this.requestUpdate();
resolve();
},
@@ -1012,7 +1009,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
this._markDirtyStateClean();
this.dirty = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -1071,7 +1068,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this.dirty = true;
}
private _undo() {
@@ -20,7 +20,6 @@ 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";
@@ -88,9 +87,7 @@ export interface EditorDomainHooks<TConfig> {
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
superClass
) {
class AutomationScriptEditorClass extends superClass {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -105,6 +102,8 @@ 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;
@@ -218,9 +217,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
// 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.dirty = true;
this.blueprintConfig = undefined;
}
@@ -240,6 +237,10 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
@@ -258,9 +259,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,4 +1,3 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -20,10 +19,6 @@ 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,
@@ -51,13 +46,7 @@ export const ManualEditorMixin = <TConfig>(
@property({ attribute: false }) public config!: TConfig;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get dirty(): boolean {
return this._dirtyState?.isDirty ?? false;
}
@property({ attribute: false }) public dirty = false;
@state() protected pastedConfig?: TConfig;
@@ -15,7 +15,6 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type {
BackupConfig,
BackupMutableConfig,
@@ -83,10 +82,7 @@ const RECOMMENDED_CONFIG: BackupConfig = {
};
@customElement("ha-dialog-backup-onboarding")
class DialogBackupOnboarding
extends DirtyStateProviderMixin<BackupConfig>()(LitElement)
implements HassDialog
{
class DialogBackupOnboarding extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -119,7 +115,6 @@ class DialogBackupOnboarding
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._config!);
}
public closeDialog() {
@@ -174,7 +169,6 @@ class DialogBackupOnboarding
try {
await this._save(true);
this._params?.submit!(true);
this._markDirtyStateClean();
this.closeDialog();
} catch (err) {
// eslint-disable-next-line no-console
@@ -220,7 +214,7 @@ class DialogBackupOnboarding
<ha-dialog
.open=${this._open}
header-title=${this._stepTitle}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${isFirstStep
@@ -299,7 +293,6 @@ class DialogBackupOnboarding
password: this._config.create_backup.password,
},
};
this._updateDirtyState(this._config);
this._done();
}
@@ -522,7 +515,6 @@ class DialogBackupOnboarding
include_addons: data.include_addons || null,
},
};
this._updateDirtyState(this._config);
}
private _scheduleChanged(ev) {
@@ -532,7 +524,6 @@ class DialogBackupOnboarding
schedule: value.schedule,
retention: value.retention,
};
this._updateDirtyState(this._config);
}
private _agentsConfigChanged(ev) {
@@ -544,7 +535,6 @@ class DialogBackupOnboarding
agent_ids: agents,
},
};
this._updateDirtyState(this._config);
}
static get styles(): CSSResultGroup {
@@ -12,17 +12,13 @@ import {
getPreferredAgentForDownload,
} from "../../../../data/backup";
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 } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup
extends DirtyStateProviderMixin<string>()(LitElement)
implements HassDialog
{
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -36,7 +32,6 @@ class DialogDownloadDecryptedBackup
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._open = true;
this._params = params;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -65,7 +60,7 @@ class DialogDownloadDecryptedBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<p>
@@ -110,11 +105,7 @@ class DialogDownloadDecryptedBackup
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
@@ -145,7 +136,6 @@ class DialogDownloadDecryptedBackup
this._agentId,
this._encryptionKey
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
@@ -165,7 +155,6 @@ class DialogDownloadDecryptedBackup
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
this._updateDirtyState(this._encryptionKey);
}
private get _agentId() {
@@ -28,7 +28,6 @@ import {
fetchBackupConfig,
} from "../../../../data/backup";
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 "../components/config/ha-backup-config-data";
@@ -60,10 +59,7 @@ const STEPS = ["data", "sync"] as const;
const DISALLOWED_AGENTS_NO_HA = [CLOUD_AGENT];
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup
extends DirtyStateProviderMixin<FormData>()(LitElement)
implements HassDialog
{
class DialogGenerateBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "data" | "sync";
@@ -83,8 +79,6 @@ class DialogGenerateBackup
this._formData = INITIAL_DATA;
this._params = _params;
this._open = true;
this._initDirtyTracking({ type: "deep" }, INITIAL_DATA);
this._updateDirtyState(this._formData);
this._fetchAgents();
this._fetchBackupConfig();
@@ -166,7 +160,6 @@ class DialogGenerateBackup
agents_mode: "custom",
agent_ids: filteredAgents,
};
this._updateDirtyState(this._formData);
}
}
}
@@ -187,11 +180,7 @@ class DialogGenerateBackup
const selectedAgents = this._formData.agent_ids;
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog .open=${this._open} @closed=${this._dialogClosed}>
<ha-dialog-header slot="header">
${isFirstStep
? html`
@@ -287,7 +276,6 @@ class DialogGenerateBackup
...this._formData!,
data,
};
this._updateDirtyState(this._formData);
}
private _renderSync() {
@@ -382,7 +370,6 @@ class DialogGenerateBackup
...this._formData!,
agents_mode: value,
};
this._updateDirtyState(this._formData);
}
private _agentsChanged(ev) {
@@ -390,7 +377,6 @@ class DialogGenerateBackup
...this._formData!,
agent_ids: ev.detail.value,
};
this._updateDirtyState(this._formData);
}
private _nameChanged(ev: InputEvent) {
@@ -398,7 +384,6 @@ class DialogGenerateBackup
...this._formData!,
name: (ev.target as HaInput).value ?? "",
};
this._updateDirtyState(this._formData);
}
private _disabledAgentIds() {
@@ -444,7 +429,6 @@ class DialogGenerateBackup
}
this._params!.submit?.(params);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,7 +13,6 @@ import type {
} from "../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../data/hassio/common";
import { changeMountOptions } from "../../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LocalBackupLocationDialogParams } from "./show-dialog-local-backup-location";
@@ -26,14 +25,8 @@ const SCHEMA = [
},
] as const satisfies HaFormSchema[];
interface LocalBackupLocationFormState {
default_backup_mount: string | null | undefined;
}
@customElement("dialog-local-backup-location")
class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocationFormState>()(
LitElement
) {
class LocalBackupLocationDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: LocalBackupLocationDialogParams;
@@ -51,10 +44,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
): Promise<void> {
this._dialogParams = dialogParams;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ default_backup_mount: undefined }
);
}
public closeDialog(): void {
@@ -79,7 +68,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
header-title=${this.hass.localize(
`ui.panel.config.backup.dialogs.local_backup_location.title`
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error
@@ -113,7 +102,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._waiting || !this.isDirtyState}
.disabled=${this._waiting || !this._data}
slot="primaryAction"
@click=${this._changeMount}
>
@@ -136,9 +125,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._data = {
default_backup_mount: newLocation === "/backup" ? null : newLocation,
};
this._updateDirtyState({
default_backup_mount: this._data.default_backup_mount,
});
}
private async _changeMount() {
@@ -154,7 +140,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._waiting = false;
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -52,6 +52,7 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
@@ -21,7 +21,6 @@ import {
type BackupUploadFileFormData,
} from "../../../../data/backup";
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 } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@@ -29,7 +28,7 @@ import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends DirtyStateProviderMixin<BackupUploadFileFormData>()(LitElement)
extends LitElement
implements HassDialog<UploadBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -48,8 +47,6 @@ export class DialogUploadBackup
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, INITIAL_UPLOAD_FORM_DATA);
this._updateDirtyState(this._formData);
}
private _dialogClosed() {
@@ -67,6 +64,10 @@ export class DialogUploadBackup
return true;
}
private _formValid() {
return this._formData?.file !== undefined;
}
protected render() {
if (!this._params || !this._formData) {
return nothing;
@@ -78,7 +79,7 @@ export class DialogUploadBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
.preventScrimClose=${this.isDirtyState || this._uploading}
?prevent-scrim-close=${this._uploading}
@closed=${this._dialogClosed}
>
${this._error
@@ -111,7 +112,7 @@ export class DialogUploadBackup
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this.isDirtyState || this._uploading}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
@@ -130,13 +131,11 @@ export class DialogUploadBackup
...this._formData!,
file,
};
this._updateDirtyState(this._formData);
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._updateDirtyState(this._formData);
}
private async _upload() {
@@ -162,7 +161,6 @@ export class DialogUploadBackup
try {
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -45,7 +45,6 @@ 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";
@@ -348,22 +347,13 @@ class HaConfigSectionUpdates extends LitElement {
return;
}
try {
await installUpdates(this.hass, entityIds, false);
await installUpdates(this.hass, entityIds);
} catch (err: any) {
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 });
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
}
}
@@ -17,6 +17,7 @@ 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";
@@ -235,6 +236,7 @@ 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,7 +22,6 @@ 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";
@@ -36,19 +35,13 @@ 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 DirtyStateProviderMixin<BatteryFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsBatteryDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -115,14 +108,6 @@ export class DialogEnergyBatterySettings
);
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
}
);
}
public closeDialog() {
@@ -152,7 +137,7 @@ export class DialogEnergyBatterySettings
header-title=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
@@ -256,8 +241,7 @@ export class DialogEnergyBatterySettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid() ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._isValid()}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -299,13 +283,11 @@ 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) {
@@ -316,7 +298,6 @@ export class DialogEnergyBatterySettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _handlePowerConfigChanged(
@@ -324,7 +305,6 @@ export class DialogEnergyBatterySettings
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
this._updateFormDirtyState();
}
private _statisticSocChanged(ev: ValueChangedEvent<string>) {
@@ -332,15 +312,6 @@ 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() {
@@ -364,7 +335,6 @@ export class DialogEnergyBatterySettings
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -23,7 +23,6 @@ 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 {
@@ -46,7 +45,7 @@ const VIEW_GROUPS: { view: EnergyViewPath; labelKey: LocalizeKeys }[] = [
@customElement("dialog-energy-customise")
export class DialogEnergyCustomise
extends DirtyStateProviderMixin<string[]>()(LitElement)
extends LitElement
implements HassDialog<EnergyCustomiseDialogParams>
{
@state()
@@ -108,7 +107,6 @@ export class DialogEnergyCustomise
this._error = err?.message || "Unknown error";
this._hidden = new Set();
}
this._initDirtyTracking({ type: "deep" }, [...this._hidden].sort());
}
protected render() {
@@ -122,7 +120,7 @@ export class DialogEnergyCustomise
.headerTitle=${this._i18n.localize(
"ui.panel.config.energy.customise.title"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${!this._hidden
@@ -145,10 +143,7 @@ export class DialogEnergyCustomise
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting ||
!this._hidden ||
!!this._error ||
!this.isDirtyState}
.disabled=${this._submitting || !this._hidden || !!this._error}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
@@ -221,7 +216,6 @@ export class DialogEnergyCustomise
next.add(cardKey);
}
this._hidden = next;
this._updateDirtyState([...this._hidden].sort());
};
private async _save(): Promise<void> {
@@ -234,7 +228,6 @@ 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,7 +20,6 @@ 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";
@@ -30,9 +29,7 @@ const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-device-settings-water")
export class DialogEnergyDeviceSettingsWater
extends DirtyStateProviderMixin<DeviceConsumptionEnergyPreference | null>()(
LitElement
)
extends LitElement
implements HassDialog<EnergySettingsDeviceWaterDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -75,7 +72,6 @@ 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() {
@@ -150,7 +146,7 @@ export class DialogEnergyDeviceSettingsWater
header-title=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -230,8 +226,7 @@ export class DialogEnergyDeviceSettingsWater
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._device ||
(!!this._params?.device && !this.isDirtyState)}
.disabled=${!this._device}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -244,12 +239,10 @@ 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) &&
@@ -278,7 +271,6 @@ export class DialogEnergyDeviceSettingsWater
delete newDevice.stat_rate;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _nameChanged(ev: InputEvent) {
@@ -290,7 +282,6 @@ export class DialogEnergyDeviceSettingsWater
delete newDevice.name;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
@@ -302,13 +293,11 @@ 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,7 +23,6 @@ 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";
@@ -33,9 +32,7 @@ const powerUnitClasses = ["power"];
@customElement("dialog-energy-device-settings")
export class DialogEnergyDeviceSettings
extends DirtyStateProviderMixin<DeviceConsumptionEnergyPreference | null>()(
LitElement
)
extends LitElement
implements HassDialog<EnergySettingsDeviceDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -78,7 +75,6 @@ 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() {
@@ -151,7 +147,7 @@ export class DialogEnergyDeviceSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -230,8 +226,7 @@ export class DialogEnergyDeviceSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._device ||
(!!this._params?.device && !this.isDirtyState)}
.disabled=${!this._device}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -244,12 +239,10 @@ 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) &&
@@ -278,7 +271,6 @@ export class DialogEnergyDeviceSettings
delete newDevice.stat_rate;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _nameChanged(ev: InputEvent) {
@@ -290,7 +282,6 @@ export class DialogEnergyDeviceSettings
delete newDevice.name;
}
this._device = newDevice;
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
@@ -302,13 +293,11 @@ 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,26 +25,18 @@ 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 DirtyStateProviderMixin<GasFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsGasDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -55,7 +47,7 @@ export class DialogEnergyGasSettings
@state() private _source?: GasSourceTypeEnergyPreference;
@state() private _costs?: CostType;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _pickedDisplayUnit?: string | null;
@@ -109,10 +101,6 @@ 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() {
@@ -165,7 +153,7 @@ export class DialogEnergyGasSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -330,8 +318,7 @@ export class DialogEnergyGasSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._source.stat_energy_from}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -342,8 +329,11 @@ export class DialogEnergyGasSettings
}
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
}
private _numberPriceChanged(ev: InputEvent) {
@@ -353,7 +343,6 @@ export class DialogEnergyGasSettings
entity_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _priceStatChanged(ev: CustomEvent) {
@@ -363,7 +352,6 @@ export class DialogEnergyGasSettings
number_energy_price: null,
stat_cost: ev.detail.value,
};
this._updateFormDirtyState();
}
private _priceEntityChanged(ev: CustomEvent) {
@@ -373,7 +361,6 @@ export class DialogEnergyGasSettings
number_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
@@ -381,7 +368,6 @@ export class DialogEnergyGasSettings
...this._source!,
stat_rate: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
@@ -413,7 +399,6 @@ export class DialogEnergyGasSettings
...this._source!,
stat_energy_from: ev.detail.value,
};
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -424,11 +409,6 @@ 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() {
@@ -439,7 +419,6 @@ 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,7 +26,6 @@ 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";
@@ -44,17 +43,9 @@ 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 DirtyStateProviderMixin<GridFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsGridDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -153,16 +144,6 @@ 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() {
@@ -204,7 +185,7 @@ export class DialogEnergyGridSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
@@ -465,8 +446,7 @@ export class DialogEnergyGridSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid() ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._isValid()}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -527,7 +507,6 @@ export class DialogEnergyGridSettings
};
}
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _statisticToChanged(ev: ValueChangedEvent<string>) {
@@ -557,7 +536,6 @@ export class DialogEnergyGridSettings
};
}
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -568,7 +546,6 @@ export class DialogEnergyGridSettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _handleImportCostTypeChanged(ev: Event) {
@@ -580,7 +557,6 @@ export class DialogEnergyGridSettings
entity_energy_price: null,
number_energy_price: null,
};
this._updateFormDirtyState();
}
private _handleExportCostTypeChanged(ev: Event) {
@@ -592,12 +568,10 @@ 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>) {
@@ -605,14 +579,12 @@ 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>) {
@@ -620,7 +592,6 @@ export class DialogEnergyGridSettings
...this._source!,
stat_compensation: ev.detail.value || null,
};
this._updateFormDirtyState();
}
private _entityCompensationChanged(ev: ValueChangedEvent<string>) {
@@ -628,14 +599,12 @@ 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(
@@ -643,17 +612,6 @@ 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() {
@@ -680,7 +638,6 @@ export class DialogEnergyGridSettings
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -24,7 +24,6 @@ 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";
@@ -36,17 +35,12 @@ 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 DirtyStateProviderMixin<SolarFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsSolarDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -94,10 +88,6 @@ 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() {
@@ -124,7 +114,7 @@ export class DialogEnergySolarSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -258,8 +248,7 @@ export class DialogEnergySolarSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._source.stat_energy_from}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -286,23 +275,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) {
list.push(entry.entry_id);
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(entry.entry_id);
} else {
list.splice(list.indexOf(entry.entry_id), 1);
this._source!.config_entry_solar_forecast!.splice(
this._source!.config_entry_solar_forecast!.indexOf(entry.entry_id),
1
);
}
this._source = { ...this._source!, config_entry_solar_forecast: list };
this._updateFormDirtyState();
}
private _addForecast() {
@@ -310,16 +299,11 @@ export class DialogEnergySolarSettings
startFlowHandler: "forecast_solar",
dialogClosedCallback: (params) => {
if (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,
};
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(params.entryId);
this._fetchSolarForecastConfigEntries();
this._updateFormDirtyState();
}
},
});
@@ -341,12 +325,10 @@ 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) {
@@ -357,14 +339,6 @@ 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() {
@@ -373,7 +347,6 @@ 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,24 +24,16 @@ 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 DirtyStateProviderMixin<WaterFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsWaterDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -52,7 +44,7 @@ export class DialogEnergyWaterSettings
@state() private _source?: WaterSourceTypeEnergyPreference;
@state() private _costs?: CostType;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _water_units?: string[];
@@ -92,10 +84,6 @@ 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() {
@@ -133,7 +121,7 @@ export class DialogEnergyWaterSettings
header-title=${this.hass.localize(
"ui.panel.config.energy.water.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
@@ -271,8 +259,7 @@ export class DialogEnergyWaterSettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source!.stat_energy_from ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._source.stat_energy_from}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -283,8 +270,11 @@ export class DialogEnergyWaterSettings
}
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
}
private _numberPriceChanged(ev: InputEvent) {
@@ -294,7 +284,6 @@ export class DialogEnergyWaterSettings
entity_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _priceStatChanged(ev: CustomEvent) {
@@ -304,7 +293,6 @@ export class DialogEnergyWaterSettings
number_energy_price: null,
stat_cost: ev.detail.value,
};
this._updateFormDirtyState();
}
private _priceEntityChanged(ev: CustomEvent) {
@@ -314,7 +302,6 @@ export class DialogEnergyWaterSettings
number_energy_price: null,
stat_cost: null,
};
this._updateFormDirtyState();
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
@@ -322,7 +309,6 @@ export class DialogEnergyWaterSettings
...this._source!,
stat_rate: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
@@ -352,7 +338,6 @@ export class DialogEnergyWaterSettings
this.requestUpdate("_params");
}
}
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -363,11 +348,6 @@ 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() {
@@ -378,7 +358,6 @@ 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;
@@ -18,7 +18,6 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import { createInputBoolean } from "../../../data/input_boolean";
@@ -102,9 +101,7 @@ const HELPERS: HelperCreators = {
};
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends DirtyStateProviderMixin<
Helper | undefined
>()(LitElement) {
export class DialogHelperDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: Helper;
@@ -140,7 +137,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
}
this._open = true;
await this.updateComplete;
@@ -297,7 +293,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${this._domain
? this.hass.localize(
"ui.panel.config.helpers.dialog.create_platform",
@@ -369,7 +364,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState(this._item);
}
private async _createItem(): Promise<void> {
@@ -389,7 +383,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message || "Unknown error";
@@ -417,7 +410,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
@@ -283,7 +283,7 @@ class DialogMatterAddDevice extends LitElement {
const savedStep = this._step;
try {
this._step = "commissioning";
await commissionMatterDevice(this.hass, code, true);
await commissionMatterDevice(this.hass, code);
} catch (_err) {
showToast(this, {
message: this.hass.localize(
@@ -211,7 +211,7 @@ class MatterOptionsPage extends LitElement {
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await commissionMatterDevice(this.hass, code, false);
await commissionMatterDevice(this.hass, code);
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
@@ -6,16 +6,13 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/dashboard-strategy-editor/hui-dashboard-strategy-element-editor";
import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy";
@customElement("dialog-lovelace-dashboard-configure-strategy")
export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardConfigureStrategyDialogParams;
@@ -32,7 +29,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
this._params = params;
this._data = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -53,7 +49,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
@@ -84,7 +80,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
private _handleConfigChanged(ev: CustomEvent): void {
this._data = ev.detail.config;
this._updateDirtyState(this._data!);
}
private async _save() {
@@ -97,7 +92,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
strategy: this._data,
});
this._submitting = false;
this._markDirtyStateClean();
this.closeDialog();
}
@@ -14,16 +14,13 @@ import type {
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -45,7 +42,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
@@ -55,12 +51,9 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
}
this._updateDirtyState(this._data!);
}
}
@@ -79,8 +72,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return nothing;
}
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const cancelButton = html`
@@ -104,11 +95,11 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${yamlMode
${this._params.dashboard?.mode === "yaml"
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
@@ -151,12 +142,10 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
<ha-button
slot="primaryAction"
@click=${this._updateDashboard}
.disabled=${!yamlMode &&
((this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting ||
!this.isDirtyState)}
?autofocus=${yamlMode}
.disabled=${(this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting}
?autofocus=${this._params.dashboard?.mode === "yaml"}
>
${this._params.urlPath
? this._params.dashboard?.mode === "storage"
@@ -262,7 +251,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
} else {
this._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -282,7 +270,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
@@ -305,7 +292,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
this._data as LovelaceDashboardCreateParams
);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
let localizedErrorMessage: string | undefined;
@@ -13,7 +13,6 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/ha-dialog";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { PanelMutableParams } from "../../../../data/panel";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { PanelDetailDialogParams } from "./show-dialog-panel-detail";
@@ -26,9 +25,7 @@ interface PanelDetailData {
}
@customElement("dialog-panel-detail")
export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>()(
LitElement
) {
export class DialogPanelDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PanelDetailDialogParams;
@@ -51,7 +48,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
show_in_sidebar: params.showInSidebar,
};
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -74,7 +70,7 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.edit_panel"
)}
@@ -118,7 +114,7 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
<ha-button
slot="primaryAction"
@click=${this._updatePanel}
.disabled=${titleInvalid || this._submitting || !this.isDirtyState}
.disabled=${titleInvalid || this._submitting}
>
${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
@@ -175,7 +171,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
this._data = ev.detail.value;
this._updateDirtyState(this._data!);
}
private async _handleError(err: any) {
@@ -233,7 +228,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
if (Object.keys(updates).length > 0) {
await this._params!.updatePanel(updates);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._handleError(err);
@@ -9,7 +9,6 @@ import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceResourcesMutableParams } from "../../../../data/lovelace/resource";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceResourceDetailsDialogParams } from "./show-dialog-lovelace-resource-detail";
@@ -31,9 +30,7 @@ const detectResourceType = (url?: string) => {
};
@customElement("dialog-lovelace-resource-detail")
export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
Partial<LovelaceResourcesMutableParams>
>()(LitElement) {
export class DialogLovelaceResourceDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceResourceDetailsDialogParams;
@@ -60,7 +57,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
};
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -87,7 +83,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${dialogTitle}
@closed=${this._dialogClosed}
>
@@ -123,10 +119,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
<ha-button
slot="primaryAction"
@click=${this._updateResource}
.disabled=${urlInvalid ||
!this._data?.res_type ||
this._submitting ||
!this.isDirtyState}
.disabled=${urlInvalid || !this._data?.res_type || this._submitting}
>
${this._params.resource
? this.hass!.localize(
@@ -210,7 +203,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
if (!this._data!.res_type) {
const type = detectResourceType(this._data!.url);
if (!type) {
this._updateDirtyState(this._data!);
return;
}
this._data = {
@@ -218,7 +210,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
res_type: type,
};
}
this._updateDirtyState(this._data!);
}
private async _updateResource() {
@@ -235,8 +226,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
this._data! as LovelaceResourcesMutableParams
);
}
this._markDirtyStateClean();
this.closeDialog();
this._params = undefined;
} catch (err: any) {
this._error = { base: err?.message || "Unknown error" };
} finally {
@@ -13,7 +13,6 @@ import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/input/ha-input";
import "../../../components/item/ha-row-item";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { adminChangeUsername } from "../../../data/auth";
import type { PersonMutableParams } from "../../../data/person";
import type { User } from "../../../data/user";
@@ -45,20 +44,8 @@ const cropOptions: CropOptions = {
aspectRatio: 1,
};
interface PersonFormState {
name: string;
picture: string | null;
userId: string | undefined;
deviceTrackers: string[];
isAdmin: boolean | undefined;
localOnly: boolean | undefined;
}
@customElement("dialog-person-detail")
class DialogPersonDetail
extends DirtyStateProviderMixin<PersonFormState>()(LitElement)
implements HassDialog
{
class DialogPersonDetail extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -117,21 +104,9 @@ class DialogPersonDetail
this._picture = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): PersonFormState {
return {
name: this._name,
picture: this._picture,
userId: this._userId,
deviceTrackers: this._deviceTrackers,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
};
}
public closeDialog() {
this._open = false;
return true;
@@ -159,7 +134,7 @@ class DialogPersonDetail
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")}
@@ -287,7 +262,7 @@ class DialogPersonDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting || !this.isDirtyState}
.disabled=${nameInvalid || this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -392,17 +367,14 @@ class DialogPersonDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private async _allowLoginChanged(ev): Promise<void> {
@@ -421,7 +393,6 @@ class DialogPersonDetail
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
},
name: this._name,
@@ -450,20 +421,17 @@ class DialogPersonDetail
this._user = undefined;
this._isAdmin = undefined;
this._localOnly = undefined;
this._updateDirtyState(this._currentState());
}
}
private _deviceTrackersChanged(ev: ValueChangedEvent<string[]>) {
this._error = undefined;
this._deviceTrackers = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private async _changePassword() {
@@ -559,7 +527,6 @@ class DialogPersonDetail
await this._params!.createEntry?.(values);
this._personExists = true;
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
+24 -27
View File
@@ -75,7 +75,6 @@ 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";
@@ -98,8 +97,8 @@ interface DeviceEntities {
type DeviceEntitiesLookup = Record<string, string[]>;
@customElement("ha-scene-editor")
export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
export class HaSceneEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -113,9 +112,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
@property({ attribute: false }) public scenes!: SceneEntity[];
@state() private _errors?: string;
@state() private _dirty = false;
private _sceneRevision = 0;
@state() private _errors?: string;
@state() private _yamlErrors?: string;
@@ -323,7 +322,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.disabled=${this._saving}
@click=${this._saveScene}
class=${classMap({
dirty: this.isDirtyState || !this.sceneId,
dirty: this._dirty || !this.sceneId,
saving: this._saving,
})}
>
@@ -642,7 +641,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
if (changedProps.has("sceneId") && !this.sceneId && this.hass) {
this._sceneRevision = 0;
this._dirty = false;
const initData = getSceneEditorInitData();
this._config = {
name: this.hass.localize("ui.panel.config.scene.editor.default_name"),
@@ -657,13 +656,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
category: "",
};
}
this._initDirtyTracking({ type: "shallow" }, 0);
if (
this._dirty =
initData !== undefined &&
(initData.areaId !== undefined || initData.config !== undefined)
) {
this._updateDirtyState(++this._sceneRevision);
}
(initData.areaId !== undefined || initData.config !== undefined);
}
if (changedProps.has("_entityRegistryEntries")) {
@@ -821,7 +816,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _enterLiveMode() {
if (this.isDirtyState) {
if (this._dirty) {
const result = await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.scene.editor.enter_live_mode_unsaved"
@@ -855,14 +850,13 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
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;
}
@@ -917,8 +911,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
(entity: SceneEntity) => entity.attributes.id === this.sceneId
);
this._sceneRevision = 0;
this._initDirtyTracking({ type: "shallow" }, 0);
this._dirty = false;
this._config = config;
}
@@ -967,7 +960,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this._entities = [...this._entities, entityId];
this._single_entities.push(entityId);
this._storeState(entityId);
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
}
private _deleteEntity(ev: Event) {
@@ -985,7 +978,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (this._config!.metadata) {
delete this._config!.metadata[deleteEntityId];
}
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
}
private _pickDevice(device_id: string) {
@@ -1001,7 +994,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
deviceEntities.forEach((entityId) => {
this._storeState(entityId);
});
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
}
private _devicePicked(ev: CustomEvent) {
@@ -1025,7 +1018,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
delete this._config!.entities[entityId];
});
}
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
}
private _stateChanged(event: HassEvent) {
@@ -1033,7 +1026,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
event.context.id !== this._activateContextId &&
this._entities.includes(event.data.entity_id)
) {
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
}
}
@@ -1079,7 +1072,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (this.isDirtyState) {
if (this._dirty) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
@@ -1246,7 +1239,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
}
this._markDirtyStateClean();
this._dirty = false;
if (isNewScene) {
navigate(`/config/scene/edit/${id}`, { replace: true });
}
@@ -1301,7 +1294,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
updateConfig: async (newConfig, entityRegistryUpdate) => {
this._config = newConfig;
this._entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
this.requestUpdate();
resolve(true);
},
@@ -1320,7 +1313,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
updateConfig: async (newConfig, entityRegistryUpdate) => {
this._config = newConfig;
this._entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(++this._sceneRevision);
this._dirty = true;
this.requestUpdate();
resolve(true);
},
@@ -1329,6 +1322,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
});
}
protected get isDirty() {
return this._dirty;
}
protected async promptDiscardChanges() {
return this._confirmUnsavedChanged();
}
@@ -1,15 +1,10 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } 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";
@@ -20,9 +15,7 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
@property({ type: Boolean }) public dirty = false;
protected get _config(): BlueprintScriptConfig {
return this.config;
@@ -42,7 +35,7 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this._dirtyState?.isDirty ? "dirty" : ""}
class=${this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveScript}
>
+14 -17
View File
@@ -107,10 +107,6 @@ 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);
@@ -381,6 +377,7 @@ 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>
@@ -392,6 +389,7 @@ 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}
@@ -472,7 +470,7 @@ export class HaScriptEditor extends SubscribeMixin(
<ha-button
slot="fab"
size="l"
class=${!this.readOnly && this.isDirtyState ? "dirty" : ""}
class=${!this.readOnly && this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveScript}
>
@@ -524,6 +522,7 @@ 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 = [];
@@ -532,15 +531,12 @@ 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(
@@ -550,6 +546,7 @@ export class HaScriptEditor extends SubscribeMixin(
this.scriptId = regEntry.unique_id;
}
this.currentEntityId = this.entityId;
this.dirty = false;
this.readOnly = true;
}
}
@@ -585,7 +582,7 @@ export class HaScriptEditor extends SubscribeMixin(
this.config = ev.detail.value;
this.errors = undefined;
this._updateDirtyState(this.config!);
this.dirty = true;
}
private async _runScript() {
@@ -672,7 +669,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
this._manualEditor?.addFields();
this._updateDirtyState(this.config!);
this.dirty = true;
}
private _preprocessYaml() {
@@ -681,18 +678,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.isDirtyState) {
if (!this.dirty) {
return true;
}
@@ -703,7 +700,7 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
const id = this.scriptId || String(Date.now());
@@ -818,7 +815,7 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
resolve(true);
},
@@ -837,7 +834,7 @@ export class HaScriptEditor extends SubscribeMixin(
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this.dirty = true;
this.requestUpdate();
resolve();
},
@@ -933,7 +930,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
}
this._markDirtyStateClean();
this.dirty = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -983,7 +980,7 @@ export class HaScriptEditor extends SubscribeMixin(
private _applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this.dirty = true;
}
private _undo() {
+2 -42
View File
@@ -20,24 +20,12 @@ import {
createUser,
deleteUser,
} from "../../../data/user";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AddUserDialogParams } from "./show-dialog-add-user";
interface AddUserFormState {
name?: string;
username?: string;
password?: string;
passwordConfirm?: string;
isAdmin?: boolean;
localOnly?: boolean;
}
@customElement("dialog-add-user")
export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
LitElement
) {
export class DialogAddUser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -82,18 +70,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
}
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
username: this._username,
password: "",
passwordConfirm: "",
isAdmin: false,
localOnly: false,
}
);
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
@@ -113,7 +89,7 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.users.add_user.caption"
)}
@@ -266,7 +242,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
if (parts.length) {
this._username = parts[0].toLowerCase();
this._publishDirtyState();
}
}
@@ -274,30 +249,16 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
this._error = undefined;
const target = ev.target as HaInput;
this[`_${target.name}`] = target.value;
this._publishDirtyState();
}
private async _adminChanged(ev: Event): Promise<void> {
const target = ev.target as HaSwitch;
this._isAdmin = target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev: Event): void {
const target = ev.target as HaSwitch;
this._localOnly = target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
username: this._username,
password: this._password,
passwordConfirm: this._passwordConfirm,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
});
}
private async _createUser(ev: Event) {
@@ -345,7 +306,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
},
];
this._params!.userAddedCallback(user);
this._markDirtyStateClean();
this._close();
}
@@ -9,7 +9,6 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import { adminChangePassword } from "../../../data/auth";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@@ -44,9 +43,7 @@ interface FormData {
}
@customElement("dialog-admin-change-password")
class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
LitElement
) {
class DialogAdminChangePassword extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: AdminChangePasswordDialogParams;
@@ -68,10 +65,7 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
this._userId = params.userId;
this._data = undefined;
this._error = undefined;
this._submitting = false;
this._success = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, {});
}
public closeDialog(): void {
@@ -123,7 +117,7 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.users.change_password.caption"
)}
@@ -179,7 +173,6 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
private _valueChanged(ev) {
this._data = ev.detail.value;
this._updateDirtyState(this._data ?? {});
this._validate();
}
@@ -192,7 +185,6 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
this._userId!,
this._data.new_password
);
this._markDirtyStateClean();
this._success = true;
} catch (err: any) {
showToast(this, {
+162 -184
View File
@@ -24,23 +24,13 @@ import {
showAlertDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showAdminChangePasswordDialog } from "./show-dialog-admin-change-password";
import type { UserDetailDialogParams } from "./show-dialog-user-detail";
interface UserDetailFormState {
name: string;
isAdmin?: boolean;
localOnly?: boolean;
isActive?: boolean;
}
@customElement("dialog-user-detail")
class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
LitElement
) {
class DialogUserDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -67,15 +57,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
this._localOnly = params.entry.local_only;
this._isActive = params.entry.is_active;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
}
);
await this.updateComplete;
}
@@ -88,164 +69,177 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${user.name}
@closed=${this._dialogClosed}
>
<div>
${this._error
? html`<div class="error">${this._error}</div>`
: nothing}
${
this._error
? html`<div class="error">${this._error}</div>`
: nothing
}
<div class="secondary">
${this.hass.localize("ui.panel.config.users.editor.id")}:
${user.id}<br />
</div>
${badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
</div>
`}
<div class="form">
${!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
${
badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
</div>
`
: nothing}
${!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-row-item>
${!this._isAdmin && !user.system_generated
}
<div class="form">
${
!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
${
!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-switch>
</ha-row-item>
${
!this._isAdmin && !user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.users_privileges_note"
)}
</ha-alert>
`
: nothing
}
</div>
${
user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.users_privileges_note"
"ui.panel.config.users.editor.system_generated_read_only_users"
)}
</ha-alert>
`
: nothing}
</div>
${user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.editor.system_generated_read_only_users"
)}
</ha-alert>
`
: nothing}
: nothing
}
</div>
<ha-dialog-footer slot="footer">
@@ -254,19 +248,18 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
variant="danger"
appearance="plain"
@click=${this._deleteEntry}
.disabled=${this._submitting ||
user.system_generated ||
user.is_owner}
.disabled=${
this._submitting || user.system_generated || user.is_owner
}
>
${this.hass!.localize("ui.panel.config.users.editor.delete_user")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!this._name ||
this._submitting ||
user.system_generated ||
!this.isDirtyState}
.disabled=${
!this._name || this._submitting || user.system_generated
}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -278,31 +271,18 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._publishDirtyState();
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._publishDirtyState();
}
private _activeChanged(ev): void {
this._isActive = ev.target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
});
}
private async _updateEntry() {
@@ -316,7 +296,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
],
local_only: this._localOnly,
});
this._markDirtyStateClean();
this._close();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -329,7 +308,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
this._markDirtyStateClean();
this._close();
}
} finally {
@@ -11,7 +11,6 @@ import "../../../components/ha-form/ha-form";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type {
AssistPipeline,
AssistPipelineMutableParams,
@@ -29,9 +28,7 @@ import type { VoiceAssistantPipelineDetailsDialogParams } from "./show-dialog-vo
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("dialog-voice-assistant-pipeline-detail")
export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
Partial<AssistPipeline>
>()(LitElement) {
export class DialogVoiceAssistantPipelineDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VoiceAssistantPipelineDetailsDialogParams;
@@ -65,7 +62,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
this._hideWakeWord =
this._params.hideWakeWord || !this._data.wake_word_entity;
this._initDirtyTracking({ type: "deep" }, this._data);
return;
}
@@ -102,7 +98,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
stt_engine: this._params.pipeline?.stt_engine || sstDefault,
tts_engine: this._params.pipeline?.tts_engine || ttsDefault,
};
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -150,7 +145,7 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${!this._hideWakeWord ||
@@ -239,7 +234,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
slot="primaryAction"
@click=${this._updatePipeline}
.loading=${this._submitting}
.disabled=${!this.isDirtyState}
>
${isExistingPipeline
? this.hass.localize(
@@ -272,7 +266,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
value[key] = ev.detail.value[key];
});
this._data = { ...this._data, ...value };
this._updateDirtyState(this._data);
}
private async _updatePipeline() {
@@ -306,7 +299,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
// eslint-disable-next-line no-console
console.error("No createPipeline function provided");
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -8,7 +8,6 @@ import "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-button";
import type { HomeZoneMutableParams } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
@@ -22,9 +21,7 @@ const SCHEMA = [
];
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
LitElement
) {
class DialogHomeZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -46,7 +43,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
longitude: this.hass.config.longitude,
radius: this.hass.config.radius,
};
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -75,7 +71,7 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
header-title=${this.hass!.localize("ui.common.edit_item", {
name: this._data.name,
})}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -98,7 +94,7 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting || !this.isDirtyState}
.disabled=${!valid || this._submitting}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -124,7 +120,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
value.radius = value.location.radius;
delete value.location;
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (): string => "";
+3 -8
View File
@@ -11,15 +11,12 @@ import "../../../components/ha-button";
import type { SchemaUnion } from "../../../components/ha-form/types";
import type { ZoneMutableParams } from "../../../data/zone";
import { getZoneEditorInitData } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
LitElement
) {
class DialogZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -56,7 +53,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
radius: 100,
};
}
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -97,7 +93,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
name: this._params.entry.name,
})
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -135,7 +131,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting || !this.isDirtyState}
.disabled=${!valid || this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -193,7 +189,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
delete value.icon;
}
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (
+16 -5
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 { navigate } from "../../common/navigate";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
@@ -34,6 +34,8 @@ 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";
@@ -115,13 +117,22 @@ 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}
.backButton=${!!this._showBack}
>
<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>`}
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
+17 -4
View File
@@ -1,8 +1,11 @@
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";
@@ -87,12 +90,22 @@ class PanelLight extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<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>`}
<div slot="title">${this.hass.localize("panel.light")}</div>
${this._lovelace
? html`
+16 -5
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 { navigate } from "../../common/navigate";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
@@ -18,6 +18,8 @@ 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";
@@ -63,12 +65,21 @@ 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}
.backButton=${!!this._showBack}
>
<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>`}
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@@ -32,7 +32,6 @@ import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
import "../../../../components/ha-icon-button";
@@ -190,11 +189,9 @@ export class HuiEnergyDevicesGraphCard
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
click-label-for-more-info
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
</div>
</ha-card>
@@ -546,20 +543,11 @@ export class HuiEnergyDevicesGraphCard
chartData.splice(this._config.max_devices);
}
this._legendData = chartData.map((d) => {
const id = (d as any).id as string;
return {
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
// Untracked is synthetic and external statistics aren't real entities,
// so their labels can't open more-info; fall back to toggling visibility.
noLabelClick:
id === "untracked" ||
isExternalStatistic(id) ||
!(id in this.hass.states),
};
});
this._legendData = chartData.map((d) => ({
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
}));
// filter out hidden stats in place
for (let i = chartData.length - 1; i >= 0; i--) {
if (this._hiddenStats.includes((chartData[i] as any).id)) {
@@ -591,11 +579,7 @@ export class HuiEnergyDevicesGraphCard
e.detail.event?.target?.type === "tspan" // label
) {
const id = (e.detail.data as any).id as string;
if (
id !== "untracked" &&
!isExternalStatistic(id) &&
this.hass.states[id]
) {
if (id !== "untracked") {
fireEvent(this, "hass-more-info", {
entityId: id,
});
@@ -603,16 +587,6 @@ export class HuiEnergyDevicesGraphCard
}
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const entityId = ev.detail.id;
if (isExternalStatistic(entityId) || !this.hass.states[entityId]) {
return;
}
fireEvent(this, "hass-more-info", { entityId });
}
private _handleChartTypeChange(): void {
if (!this._chartType) {
return;
@@ -49,9 +49,6 @@ const addEntityId = (entities: Set<string>, entity) => {
};
const addEntities = (entities: Set<string>, obj) => {
if (!obj) {
return;
}
if (obj.entity) {
addEntityId(entities, obj.entity);
}
@@ -80,7 +80,12 @@ class HuiTimestampDisplay extends LitElement {
if (!changedProperties.has("format") || !this._connected) {
return;
}
this._startInterval();
if (INTERVAL_FORMAT.includes("relative")) {
this._startInterval();
} else {
this._clearInterval();
}
}
private get _format(): string {
@@ -486,10 +486,15 @@ export class HaCardConditionEditor extends LitElement {
--expansion-panel-content-padding: 0;
}
.icon-badge-wrapper {
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
display: none;
}
@media (min-width: 870px) {
.icon-badge-wrapper {
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
}
}
h3 {
margin: 0;
@@ -12,7 +12,6 @@ import "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -28,9 +27,7 @@ import type { DashboardStrategyEditorDialogParams } from "./show-dialog-dashboar
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
@customElement("dialog-dashboard-strategy-editor")
class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
class DialogDashboardStrategyEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DashboardStrategyEditorDialogParams;
@@ -52,7 +49,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
this._params = params;
this._strategyConfig = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._strategyConfig);
await this.updateComplete;
}
@@ -72,7 +68,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
ev.stopPropagation();
this._guiModeAvailable = ev.detail.guiModeAvailable;
this._strategyConfig = ev.detail.config as LovelaceStrategyConfig;
this._updateDirtyState(this._strategyConfig);
}
private _handleGUIModeChanged(ev: HASSDomEvent<GUIModeChangedEvent>): void {
@@ -87,7 +82,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
strategy: this._strategyConfig!,
});
showSaveSuccessToast(this, this.hass);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -143,7 +137,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${title || "-"}
header-subtitle=${ifDefined(this._params.title)}
width="large"
@@ -202,11 +195,7 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -30,7 +30,6 @@ import {
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -54,7 +53,7 @@ const TABS = ["tab-settings", "tab-visibility"] as const;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
extends LitElement
implements HassDialog<EditSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -97,7 +96,6 @@ export class HuiDialogEditSection
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
this._initDirtyTracking({ type: "deep" }, this._config);
}
public closeDialog() {
@@ -161,7 +159,7 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
@@ -233,11 +231,7 @@ export class HuiDialogEditSection
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -248,7 +242,6 @@ export class HuiDialogEditSection
private _configChanged(ev: CustomEvent): void {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _handleTabChanged(ev: CustomEvent): void {
@@ -406,7 +399,6 @@ export class HuiDialogEditSection
return;
}
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _ignoreKeydown(ev: KeyboardEvent) {
@@ -431,7 +423,6 @@ export class HuiDialogEditSection
);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
this.closeDialog();
}
+14 -23
View File
@@ -1,3 +1,4 @@
import { undoDepth } from "@codemirror/commands";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@@ -16,7 +17,6 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { Lovelace } from "./types";
@@ -33,9 +33,7 @@ const strategyStruct = type({
});
@customElement("hui-editor")
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
LitElement
) {
class LovelaceFullConfigEditor extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -46,6 +44,8 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
@state() private _saving?: boolean;
@state() private _changed?: boolean;
private _config?: LovelaceRawConfig;
private _yamlError?: string;
@@ -66,10 +66,10 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
slot="actionItems"
class="save-button
${classMap({
saved: this._saving === false || this.isDirtyState,
saved: this._saving === false || this._changed === true,
})}"
>
${this.isDirtyState
${this._changed
? this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.unsaved_changes"
)
@@ -78,7 +78,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
<ha-button
slot="actionItems"
@click=${this._handleSave}
.disabled=${!this.isDirtyState}
.disabled=${!this._changed}
>${this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.save"
)}</ha-button
@@ -98,7 +98,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._setValue();
this.yamlEditor.setValue(this.lovelace!.rawConfig);
}
protected updated(changedProps: PropertyValues<this>) {
@@ -110,19 +110,10 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
oldLovelace.rawConfig !== this.lovelace.rawConfig &&
!deepEqual(oldLovelace.rawConfig, this.lovelace.rawConfig)
) {
this._setValue();
this.yamlEditor.setValue(this.lovelace!.rawConfig);
}
}
private _setValue() {
this.yamlEditor.setValue(this.lovelace!.rawConfig);
// Baseline the dirty check against the loaded YAML so it resets on save.
this._initDirtyTracking(
{ type: "custom", compare: (a, b) => a === b },
this.yamlEditor.yaml
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -167,17 +158,17 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
private _yamlChanged(ev: CustomEvent) {
this._config = ev.detail.isValid ? ev.detail.value : undefined;
this._yamlError = ev.detail.errorMsg;
this._updateDirtyState(this.yamlEditor.yaml);
if (this.isDirtyState && !window.onbeforeunload) {
this._changed = undoDepth(this.yamlEditor.codemirror!.state) > 0;
if (this._changed && !window.onbeforeunload) {
window.onbeforeunload = () => true;
} else if (!this.isDirtyState && window.onbeforeunload) {
} else if (!this._changed && window.onbeforeunload) {
window.onbeforeunload = null;
}
}
private async _closeEditor() {
if (
this.isDirtyState &&
this._changed &&
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.lovelace.editor.raw_editor.confirm_unsaved_changes"
@@ -288,7 +279,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
});
}
window.onbeforeunload = null;
this._markDirtyStateClean();
this._changed = false;
this._saving = false;
}
+104 -20
View File
@@ -1,9 +1,12 @@
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-top-app-bar-fixed";
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";
@@ -87,27 +90,41 @@ class PanelMaintenance extends LitElement {
this._setLovelace();
};
private _back(ev: Event) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<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>
<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}
`;
}
@@ -148,10 +165,77 @@ 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,6 +6,7 @@ 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";
@@ -22,6 +23,7 @@ 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, nothing } from "lit";
import { css, html, LitElement } 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,6 +15,7 @@ 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";
@@ -99,7 +100,7 @@ class PanelMediaBrowser extends LitElement {
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: nothing}
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<h1 class="page-title" slot="title">
${!this._currentItem
? this.hass.localize(
+17 -4
View File
@@ -1,8 +1,11 @@
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";
@@ -87,12 +90,22 @@ class PanelSecurity extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<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>`}
<div slot="title">${this.hass.localize("panel.security")}</div>
${this._lovelace
? html`
+2
View File
@@ -32,6 +32,7 @@ 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";
@@ -189,6 +190,7 @@ 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">
+10 -18
View File
@@ -60,24 +60,16 @@ export const createLogMessage = async (
// - a possible list of aggregated errors
if (error instanceof Error) {
lines.push(error.toString() || messageFallback);
let stackLines: (string | undefined)[];
try {
stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
} catch {
// stacktrace-js cannot always parse a stack (for example a DOMException
// with no, or an unrecognized, stack), so fall back to the raw stack
// instead of letting the error logger itself throw.
stackLines = error.stack ? [error.stack] : [];
}
const stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
lines.push(...(stackLines.length > 0 ? stackLines : [stackFallback]));
// @ts-expect-error Requires library bump to ES2022
if (error.cause) {
+1
View File
@@ -2750,6 +2750,7 @@
"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",
+15 -29
View File
@@ -77,19 +77,15 @@ 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);
url.searchParams.set("token", _brandsAccessToken);
if (_brandsAccessToken) {
url.searchParams.set("token", _brandsAccessToken);
}
return url.toString();
};
@@ -97,44 +93,34 @@ 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);
url.searchParams.set("token", _brandsAccessToken);
if (_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 {
parsedUrl = new URL(url, hassUrl);
const parsedUrl = new URL(url, hassUrl);
if (!parsedUrl.pathname.startsWith("/api/brands/")) {
return url;
}
parsedUrl.searchParams.set("token", _brandsAccessToken);
return parsedUrl.toString();
} 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 => {
+251
View File
@@ -0,0 +1,251 @@
/**
* E2E tests for the HA test app (port 8095).
*
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The test app is built with __DEMO__=true which enables hash-based routing.
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
// Scenario selection uses query params: /?scenario=foo (always at root).
/** Navigate to a panel (hash routing) and wait for app to initialize. */
async function goToPanel(page: Page, path: string) {
// Paths starting with /? are root-level (scenario selection); panel paths
// need to use hash routing (/#/panelname).
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await page.waitForSelector("ha-test", { state: "attached" });
// Wait for the app to finish initialising (hassConnected sets panels)
await page.waitForFunction(() => Boolean((window as any).__mockHass));
}
// ---------------------------------------------------------------------------
// App shell
// ---------------------------------------------------------------------------
test.describe("App shell", () => {
test("page loads and ha-test element mounts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/");
await expect(page.locator("ha-test")).toBeAttached();
expect(errors).toHaveLength(0);
});
test("sidebar renders with expected panels", async ({ page }) => {
await goToPanel(page, "/lovelace");
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
for (const urlPath of ["lovelace", "energy", "history"]) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
)
).toBeAttached();
}
// Config has its own special element with id="sidebar-config"
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
)
).toBeAttached();
});
test("non-admin user does NOT see config panel in sidebar", async ({
page,
}) => {
// Navigate to a panel route so the sidebar actually renders, then apply
// the non-admin scenario via query param.
await goToPanel(page, "/?scenario=non-admin#/lovelace");
// Wait for the sidebar to mount before asserting on its contents.
await expect(
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
).toBeAttached({ timeout: SHELL_TIMEOUT });
// Config panel is adminOnly — should not appear for non-admin.
const configLink = page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
);
await expect(configLink).not.toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Panel navigation
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
test("navigates to lovelace dashboard", async ({ page }) => {
await goToPanel(page, "/lovelace");
await expect(
page.locator("ha-panel-lovelace, hui-root").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to energy panel", async ({ page }) => {
await goToPanel(page, "/energy");
await expect(
page.locator("ha-panel-energy, energy-view").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to history panel", async ({ page }) => {
await goToPanel(page, "/history");
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to developer-tools panel", async ({ page }) => {
// Since 2026.2 developer-tools is part of the config panel
await goToPanel(page, "/config/developer-tools");
await expect(
page.locator("ha-panel-config, developer-tools-main").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
});
test("navigates to profile panel", async ({ page }) => {
await goToPanel(page, "/profile");
await expect(
page.locator("ha-panel-profile, ha-config-user-profile").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// Lovelace
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
await expect(page.locator("hui-card, hui-tile-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("admin user sees edit button", async ({ page }) => {
await goToPanel(page, "/lovelace");
// The edit FAB / menu button is present for admins
await expect(
page.locator("[data-testid='edit-mode-button'], ha-menu-button")
).toBeAttached({ timeout: QUICK_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
test("opens more-info dialog for a light entity", async ({ page }) => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
// Fire the standard hass-more-info event from the app root. The HA shell
// listens for this and opens ha-more-info-dialog via its dialog manager.
await page.evaluate(() => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light" },
bubbles: true,
composed: true,
})
);
});
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
// Confirm it actually rendered our entity, not a generic empty dialog.
await expect(dialog.locator("span.title")).toContainText("Test Light", {
timeout: QUICK_TIMEOUT,
});
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
test.describe("Theming", () => {
test("dark theme sets darkMode flag", async ({ page }) => {
await goToPanel(page, "/?scenario=dark-theme#/lovelace");
// The dark-theme scenario sets selectedTheme.dark = true, which causes
// _applyTheme() to set themes.darkMode = true on the element.
await page.waitForFunction(
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
{ timeout: QUICK_TIMEOUT }
);
});
test("custom theme applies CSS variables", async ({ page }) => {
await goToPanel(page, "/?scenario=custom-theme#/lovelace");
// The custom-theme scenario sets --primary-color to #e91e63. Wait until
// _applyTheme has propagated the value to <html> before reading it — the
// scenario fires before hassConnected, but the variable lands on :root in
// the same tick mockTheme is called.
await page.waitForFunction(
() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim() !== "",
{ timeout: QUICK_TIMEOUT }
);
const primaryColor = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim()
);
// Compare normalised — some browsers serialise as rgb().
expect(primaryColor.toLowerCase()).toMatch(
/#e91e63|rgb\(233,\s*30,\s*99\)/
);
});
});
// ---------------------------------------------------------------------------
// Config panel
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/config");
await expect(
page.locator("ha-panel-config, ha-config-dashboard").first()
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
// Filter known pre-existing errors from vendor code
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
});
+1
View File
@@ -0,0 +1 @@
import "./ha-test";
+53
View File
@@ -0,0 +1,53 @@
import type { Panels } from "../../../../src/types";
export const e2eTestPanels: Panels = {
lovelace: {
component_name: "lovelace",
icon: "mdi:view-dashboard",
title: "home",
config: { mode: "storage" },
url_path: "lovelace",
},
map: {
component_name: "lovelace",
icon: "mdi:tooltip-account",
title: "map",
config: { mode: "storage" },
url_path: "map",
},
energy: {
component_name: "energy",
icon: "mdi:lightning-bolt",
title: "energy",
config: null,
url_path: "energy",
},
history: {
component_name: "history",
icon: "mdi:chart-box",
title: "history",
config: null,
url_path: "history",
},
config: {
component_name: "config",
icon: "mdi:cog",
title: "config",
config: null,
url_path: "config",
},
profile: {
component_name: "profile",
icon: null,
title: null,
config: null,
url_path: "profile",
},
"developer-tools": {
component_name: "developer-tools",
icon: "mdi:hammer",
title: "developer_tools",
config: null,
url_path: "developer-tools",
},
};
+137
View File
@@ -0,0 +1,137 @@
import { customElement } from "lit/decorators";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
import { navigate } from "../../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../../../src/types";
import { demoSections } from "../../../../demo/src/configs/sections";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockAssist } from "../../../../demo/src/stubs/assist";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import { mockCloud } from "../../../../demo/src/stubs/cloud";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockEvents } from "../../../../demo/src/stubs/events";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
import { mockPersistentNotification } from "../../../../demo/src/stubs/persistent_notification";
import { mockRecorder } from "../../../../demo/src/stubs/recorder";
import { mockSensor } from "../../../../demo/src/stubs/sensor";
import { mockSystemLog } from "../../../../demo/src/stubs/system_log";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockTranslations } from "../../../../demo/src/stubs/translations";
import { mockUpdate } from "../../../../demo/src/stubs/update";
import { e2eTestPanels } from "./ha-test-panels";
import { scenarios } from "./scenarios";
declare global {
interface Window {
__mockHass: MockHomeAssistant;
}
}
@customElement("ha-test")
export class HaTest extends HomeAssistantAppEl {
protected async _initializeHass() {
const scenarioName =
new URLSearchParams(window.location.search).get("scenario") ?? "default";
const scenario = Object.prototype.hasOwnProperty.call(
scenarios,
scenarioName
)
? scenarios[scenarioName as keyof typeof scenarios]
: scenarios.default;
const initial: Partial<MockHomeAssistant> = {
// Use the full panel map (history + config + developer-tools enabled)
panels: e2eTestPanels,
panelUrl: (() => {
const path = window.location.pathname;
const dividerPos = path.indexOf("/", 1);
return dividerPos === -1
? path.substring(1)
: path.substring(1, dividerPos);
})(),
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
this._updateHass(hassUpdate),
};
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
() => this.hass!.localize
);
// Register all stubs
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
mockSensor(hass);
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockEnergy(hass);
mockUpdate(hass);
mockCloud(hass);
mockAssist(hass);
mockAreaRegistry(hass);
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
mockPersistentNotification(hass);
// Load default entities from the sections config
hass.addEntities(energyEntities());
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
}
);
// Apply scenario customisations (may add entities, change user, set theme,
// navigate to a panel, etc.)
await scenario(hass);
// Expose mock handle for Playwright tests to call imperatively
window.__mockHass = hass;
// SPA navigation
document.body.addEventListener(
"click",
(e) => {
const href = isNavigationClick(e);
if (!href) return;
e.preventDefault();
navigate(href);
},
{ capture: true }
);
this.hassConnected();
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-test": HaTest;
}
}

Some files were not shown because too many files have changed in this diff Show More