mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 12:52:07 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d24a84e8e3 | |||
| 1af4985714 | |||
| dff5767b27 | |||
| f40ddceadc | |||
| de9a6d82de | |||
| b691ebe336 | |||
| 79347ecc34 | |||
| 01340c5e03 | |||
| f3abf60a19 | |||
| 748f616d15 | |||
| b4c88520f2 | |||
| ecada55a33 | |||
| f443c0ba74 | |||
| 30414703c1 | |||
| e0e93e0fa9 | |||
| b7bdad3d21 | |||
| bc55bc727d | |||
| 7ec5f157c3 | |||
| c1a21dff29 | |||
| 2d19d40277 | |||
| 73a028fa1a | |||
| 78074f7305 | |||
| 1428a5b14e | |||
| abd438fc47 | |||
| 27e2dc91ad | |||
| da9ce03987 |
@@ -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();
|
||||
}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -234,6 +234,12 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
+21
-9
@@ -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,13 +43,13 @@
|
||||
"@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.9",
|
||||
"@formatjs/intl-datetimeformat": "7.4.8",
|
||||
"@formatjs/intl-displaynames": "7.3.10",
|
||||
"@formatjs/intl-durationformat": "0.10.14",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
@@ -131,14 +140,16 @@
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.62.0",
|
||||
"@html-eslint/eslint-plugin": "0.61.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.13",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Load a resource and get a promise when loading done.
|
||||
// From: https://davidwalsh.name/javascript-loader
|
||||
|
||||
const _load = (tag: "link" | "script", url: string, type?: "module") =>
|
||||
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
|
||||
// This promise will be used by Promise.all to determine success or failure
|
||||
new Promise((resolve, reject) => {
|
||||
const element = document.createElement(tag);
|
||||
@@ -33,4 +33,5 @@ const _load = (tag: "link" | "script", url: string, type?: "module") =>
|
||||
});
|
||||
export const loadCSS = (url: string) => _load("link", url);
|
||||
export const loadJS = (url: string) => _load("script", url);
|
||||
export const loadImg = (url: string) => _load("img", url);
|
||||
export const loadModule = (url: string) => _load("script", url, "module");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Scroll to a specific y coordinate.
|
||||
*
|
||||
* Copied from paper-scroll-header-panel.
|
||||
*
|
||||
* @method scroll
|
||||
* @param {number} top The coordinate to scroll to, along the y-axis.
|
||||
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
|
||||
*/
|
||||
export default function scrollToTarget(element, target) {
|
||||
// the scroll event will trigger _updateScrollState directly,
|
||||
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
|
||||
// Calling _updateScrollState will ensure that the states are synced correctly.
|
||||
const top = 0;
|
||||
const scroller = target;
|
||||
const easingFn = function easeOutQuad(t, b, c, d) {
|
||||
t /= d;
|
||||
return -c * t * (t - 2) + b;
|
||||
};
|
||||
const animationId = Math.random();
|
||||
const duration = 200;
|
||||
const startTime = Date.now();
|
||||
const currentScrollTop = scroller.scrollTop;
|
||||
const deltaScrollTop = top - currentScrollTop;
|
||||
element._currentAnimationId = animationId;
|
||||
(function updateFrame() {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - startTime;
|
||||
if (elapsedTime > duration) {
|
||||
scroller.scrollTop = top;
|
||||
} else if (element._currentAnimationId === animationId) {
|
||||
scroller.scrollTop = easingFn(
|
||||
elapsedTime,
|
||||
currentScrollTop,
|
||||
deltaScrollTop,
|
||||
duration
|
||||
);
|
||||
requestAnimationFrame(updateFrame.bind(element));
|
||||
}
|
||||
}).call(element);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { Map, TileLayer } from "leaflet";
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletDrawModuleType = typeof import("leaflet-draw");
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
@@ -43,6 +45,17 @@ export const setupLeafletMap = async (
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
export const replaceTileLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: Map,
|
||||
tileLayer: TileLayer
|
||||
): TileLayer => {
|
||||
map.removeLayer(tileLayer);
|
||||
tileLayer = createTileLayer(leaflet);
|
||||
tileLayer.addTo(map);
|
||||
return tileLayer;
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** An empty image which can be set as src of an img element. */
|
||||
export const emptyImageBase64 =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -19,40 +19,6 @@ import type { LocalizeFunc } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
|
||||
|
||||
// Domains whose state is a timezone-agnostic date and/or time string.
|
||||
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
|
||||
|
||||
// Domains whose state is a timestamp.
|
||||
const TIMESTAMP_DOMAINS = new Set([
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
|
||||
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
|
||||
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
export const computeStateDisplay = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
@@ -172,10 +138,21 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
const TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const type = MONETARY_TYPE_MAP[part.type];
|
||||
const type = TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
@@ -214,7 +191,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
return [{ type: "value", value: value }];
|
||||
}
|
||||
|
||||
if (DATE_TIME_DOMAINS.has(domain)) {
|
||||
if (["date", "input_datetime", "time"].includes(domain)) {
|
||||
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
|
||||
// Attributes aren't available, we have to use `state`.
|
||||
|
||||
@@ -273,7 +250,23 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
|
||||
// state is a timestamp
|
||||
if (
|
||||
TIMESTAMP_DOMAINS.has(domain) ||
|
||||
[
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
|
||||
@@ -4,10 +4,9 @@ import { updateIsInstalling } from "../../data/update";
|
||||
|
||||
export const updateIcon = (stateObj: HassEntity, state?: string) => {
|
||||
const compareState = state ?? stateObj.state;
|
||||
// An install can be in progress even when the state is "off", e.g. when
|
||||
// downgrading firmware. Show the installing icon regardless of state.
|
||||
if (updateIsInstalling(stateObj as UpdateEntity)) {
|
||||
return "mdi:package-down";
|
||||
}
|
||||
return compareState === "on" ? "mdi:package-up" : "mdi:package";
|
||||
return compareState === "on"
|
||||
? updateIsInstalling(stateObj as UpdateEntity)
|
||||
? "mdi:package-down"
|
||||
: "mdi:package-up"
|
||||
: "mdi:package";
|
||||
};
|
||||
|
||||
@@ -40,25 +40,6 @@ export const numberFormatToLocale = (
|
||||
}
|
||||
};
|
||||
|
||||
// Constructing an Intl.NumberFormat is comparatively expensive, and these
|
||||
// formatters are created on every numeric state render. The number of distinct
|
||||
// (locale, options) combinations is small and bounded in practice, so cache the
|
||||
// instances instead of rebuilding them on every call.
|
||||
const numberFormatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
const getNumberFormatter = (
|
||||
locale: string | string[] | undefined,
|
||||
options: Intl.NumberFormatOptions
|
||||
): Intl.NumberFormat => {
|
||||
const key = JSON.stringify([locale, options]);
|
||||
let formatter = numberFormatCache.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, options);
|
||||
numberFormatCache.set(key, formatter);
|
||||
}
|
||||
return formatter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
|
||||
*
|
||||
@@ -94,7 +75,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format !== NumberFormat.none &&
|
||||
!Number.isNaN(Number(num))
|
||||
) {
|
||||
return getNumberFormatter(
|
||||
return new Intl.NumberFormat(
|
||||
locale,
|
||||
getDefaultFormatOptions(num, options)
|
||||
).formatToParts(Number(num));
|
||||
@@ -106,7 +87,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format === NumberFormat.none
|
||||
) {
|
||||
// If NumberFormat is none, use en-US format without grouping.
|
||||
return getNumberFormatter(
|
||||
return new Intl.NumberFormat(
|
||||
"en-US",
|
||||
getDefaultFormatOptions(num, {
|
||||
...options,
|
||||
|
||||
@@ -11,12 +11,6 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
}
|
||||
|
||||
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
|
||||
// A document node cannot have a textarea appended directly (only the single
|
||||
// documentElement is allowed), so fall back to its body. Shadow roots and
|
||||
// elements can hold the textarea directly, which keeps execCommand working
|
||||
// inside dialogs that trap focus.
|
||||
const container: Node =
|
||||
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
|
||||
|
||||
const el = document.createElement("textarea");
|
||||
el.value = str;
|
||||
@@ -25,8 +19,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
el.style.top = "0";
|
||||
el.style.left = "0";
|
||||
el.style.opacity = "0";
|
||||
container.appendChild(el);
|
||||
root.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
container.removeChild(el);
|
||||
root.removeChild(el);
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import { clamp } from "../number/clamp";
|
||||
|
||||
// Count columns from the container's real width (not the viewport) so a
|
||||
// docked sidebar is accounted for, like the dashboard sections view.
|
||||
const MIN_COLUMN_WIDTH = 320;
|
||||
const DEFAULT_COLUMN_GAP = 16;
|
||||
|
||||
const parsePx = (value: string) => parseInt(value, 10) || 0;
|
||||
|
||||
export const createColumnsController = (
|
||||
host: ReactiveControllerHost & Element,
|
||||
maxColumns: number
|
||||
) =>
|
||||
new ResizeController<number>(host, {
|
||||
target: null,
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return maxColumns;
|
||||
}
|
||||
const width = entry.contentRect.width;
|
||||
const gap =
|
||||
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
|
||||
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||
return clamp(columns, 1, maxColumns);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -394,18 +394,6 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data!);
|
||||
// Index datasets by id and name so each legend item is an O(1) lookup
|
||||
// instead of scanning every dataset twice. Charts can have many series.
|
||||
const datasetById = new Map<unknown, (typeof datasets)[number]>();
|
||||
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
|
||||
for (const dataset of datasets) {
|
||||
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
|
||||
datasetById.set(dataset.id, dataset);
|
||||
}
|
||||
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
|
||||
datasetByName.set(dataset.name, dataset);
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
@@ -425,10 +413,10 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let id = "";
|
||||
let value = "";
|
||||
let noLabelClick = false;
|
||||
const name = typeof item === "string" ? item : (item.name ?? "");
|
||||
let id: string;
|
||||
if (typeof item === "string") {
|
||||
id = item;
|
||||
} else {
|
||||
@@ -438,7 +426,9 @@ export class HaChartBase extends LitElement {
|
||||
noLabelClick = item.noLabelClick ?? false;
|
||||
}
|
||||
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
|
||||
const dataset = datasetById.get(id) ?? datasetByName.get(id);
|
||||
const dataset =
|
||||
datasets.find((d) => d.id === id) ??
|
||||
datasets.find((d) => d.name === id);
|
||||
itemStyle = {
|
||||
color: dataset?.color as string,
|
||||
...(dataset?.itemStyle as { borderColor?: string }),
|
||||
@@ -1530,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 {
|
||||
@@ -1570,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;
|
||||
|
||||
@@ -147,14 +147,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this.hass.config
|
||||
);
|
||||
const datapoints: Record<string, any>[] = [];
|
||||
// Index the hovered points by series so the per-dataset lookup below is
|
||||
// O(1) instead of scanning `params` for every dataset on each mouse move.
|
||||
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
|
||||
for (const p of params) {
|
||||
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
|
||||
paramsBySeriesIndex.set(p.seriesIndex, p);
|
||||
}
|
||||
}
|
||||
this._chartData.forEach((dataset, index) => {
|
||||
if (
|
||||
dataset.tooltip?.show === false ||
|
||||
@@ -162,7 +154,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const param = paramsBySeriesIndex.get(index);
|
||||
const param = params.find(
|
||||
(p: Record<string, any>) => p.seriesIndex === index
|
||||
);
|
||||
if (param) {
|
||||
datapoints.push(param);
|
||||
return;
|
||||
@@ -446,10 +440,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
this._chartTime = new Date();
|
||||
const endTime = this.endTime;
|
||||
// Work with numeric epoch timestamps (ms) instead of Date objects below.
|
||||
// Charts can hold a huge number of points, and allocating a Date per point
|
||||
// is needless GC pressure; the "time" axis consumes numbers natively.
|
||||
const endTimeMs = endTime.getTime();
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
entityStates.forEach((states, dataIdx) => {
|
||||
@@ -461,9 +451,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
const data: LineSeriesOption[] = [];
|
||||
|
||||
const pushData = (timestamp: number, datavalues: any[] | null) => {
|
||||
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
||||
if (!datavalues) return;
|
||||
if (timestamp > endTimeMs) {
|
||||
if (timestamp > endTime) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
@@ -634,11 +624,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
series.push(targetHigh, targetLow);
|
||||
pushData(entityState.last_changed, series);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
} else {
|
||||
const target = safeParseFloat(entityState.attributes.temperature);
|
||||
series.push(target);
|
||||
pushData(entityState.last_changed, series);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
}
|
||||
});
|
||||
} else if (domain === "humidifier") {
|
||||
@@ -756,27 +746,31 @@ export class StateHistoryChartLine extends LitElement {
|
||||
} else {
|
||||
series.push(entityState.state === "on" ? current : null);
|
||||
}
|
||||
pushData(entityState.last_changed, series);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: number;
|
||||
let lastNullDate: number | null = null;
|
||||
let lastDate: Date;
|
||||
let lastNullDate: Date | null = null;
|
||||
|
||||
// Process chart data.
|
||||
// When state is `unknown`, calculate the value and break the line.
|
||||
const processData = (entityState: LineChartState) => {
|
||||
const value = safeParseFloat(entityState.state);
|
||||
const date = entityState.last_changed;
|
||||
const date = new Date(entityState.last_changed);
|
||||
if (value !== null && lastNullDate) {
|
||||
const dateTime = date.getTime();
|
||||
const lastNullDateTime = lastNullDate.getTime();
|
||||
const lastDateTime = lastDate?.getTime();
|
||||
const tmpValue =
|
||||
(value - lastValue) *
|
||||
((lastNullDate - lastDate) / (date - lastDate)) +
|
||||
((lastNullDateTime - lastDateTime) /
|
||||
(dateTime - lastDateTime)) +
|
||||
lastValue;
|
||||
pushData(lastNullDate, [tmpValue]);
|
||||
pushData(lastNullDate + 1, [null]);
|
||||
pushData(new Date(lastNullDateTime + 1), [null]);
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
@@ -815,17 +809,17 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
// Add an entry for final values
|
||||
pushData(endTimeMs, prevValues);
|
||||
pushData(endTime, prevValues);
|
||||
|
||||
// For sensors, append current state if viewing recent data
|
||||
const nowMs = Date.now();
|
||||
const now = new Date();
|
||||
// allow 1s of leeway for "now"
|
||||
const isUpToNow = nowMs - endTimeMs <= 1000;
|
||||
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
|
||||
if (domain === "sensor" && isUpToNow && data.length === 1) {
|
||||
const stateObj = this.hass.states[states.entity_id];
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([nowMs, currentValue]);
|
||||
data[0].data!.push([now, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,16 +215,10 @@ export class HaDataTable extends LitElement {
|
||||
if (clear) {
|
||||
this._checkedRows = [];
|
||||
}
|
||||
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
|
||||
const rowLookup = new Map(
|
||||
(this._filteredData || []).map((data) => [data[this.id], data])
|
||||
);
|
||||
const checkedRows = new Set(this._checkedRows);
|
||||
ids.forEach((id) => {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
const row = this._filteredData?.find((data) => data[this.id] === id);
|
||||
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
|
||||
@@ -183,7 +183,6 @@ export class HaControlSelectMenu extends LitElement {
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
font-family: var(--ha-font-family-body, inherit);
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
letter-spacing: 0.25px;
|
||||
|
||||
@@ -12,20 +12,6 @@ import type {
|
||||
HaFormSelectSchema,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* The underlying select returns option values as strings. Map a selected value
|
||||
* back to its original option value so the source type is retained (for example
|
||||
* a number coming from a backend `vol.In` schema), falling back to the value
|
||||
* itself when no option matches.
|
||||
*/
|
||||
export const matchSelectOptionValue = (
|
||||
options: HaFormSelectSchema["options"],
|
||||
value: string
|
||||
): HaFormSelectData => {
|
||||
const option = options.find((opt) => String(opt[0]) === String(value));
|
||||
return option ? option[0] : value;
|
||||
};
|
||||
|
||||
@customElement("ha-form-select")
|
||||
export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -80,18 +66,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
let value: HaFormSelectData | undefined = ev.detail.value;
|
||||
|
||||
if (value === "") {
|
||||
value = undefined;
|
||||
} else if (value != null) {
|
||||
value = matchSelectOptionValue(this.schema.options, value);
|
||||
}
|
||||
let value: string | undefined = ev.detail.value;
|
||||
|
||||
if (value === this.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
});
|
||||
|
||||
@@ -41,8 +41,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
),
|
||||
esphome: () =>
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
matter: () =>
|
||||
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
|
||||
@@ -354,9 +354,7 @@ export class HaSerialPortSelector extends LitElement {
|
||||
}
|
||||
|
||||
private get _selectorDomain(): string | undefined {
|
||||
// `domain` is the integration domain even in options flows, where the flow
|
||||
// handler is the config entry id instead.
|
||||
return this.context?.domain;
|
||||
return this.context?.handler;
|
||||
}
|
||||
|
||||
private _memoRecommendedDomains = memoizeOne(
|
||||
|
||||
@@ -2,19 +2,12 @@ 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 { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-icon-button-arrow-prev";
|
||||
import "./ha-menu-button";
|
||||
|
||||
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
|
||||
|
||||
export const haTopAppBarFixedStyles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
--total-top-app-bar-height: calc(
|
||||
var(--header-height, 0px) + var(--sub-row-height, 0px)
|
||||
);
|
||||
@@ -25,11 +18,10 @@ export const haTopAppBarFixedStyles = css`
|
||||
box-sizing: border-box;
|
||||
color: var(--app-header-text-color, #fff);
|
||||
background-color: var(--app-header-background-color, var(--primary-color));
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 100%;
|
||||
width: var(--ha-top-app-bar-width, 100%);
|
||||
z-index: 4;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
@@ -121,17 +113,17 @@ export const haTopAppBarFixedStyles = css`
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: calc(
|
||||
height: calc(
|
||||
100vh - var(--total-top-app-bar-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
padding-top: calc(
|
||||
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
|
||||
);
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([narrow]) .top-app-bar-fixed-adjust {
|
||||
@@ -143,16 +135,12 @@ 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;
|
||||
|
||||
@query(".sub-row") protected _subRowElement?: HTMLElement;
|
||||
|
||||
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
|
||||
|
||||
@state() private _hasSubRow = false;
|
||||
|
||||
private _scrollTarget?: HTMLElement | Window;
|
||||
@@ -161,13 +149,14 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
|
||||
@property({ attribute: false })
|
||||
public get scrollTarget(): HTMLElement | Window {
|
||||
return this._scrollTarget || this._scrollElement || window;
|
||||
return this._scrollTarget || window;
|
||||
}
|
||||
|
||||
public set scrollTarget(value: HTMLElement | Window) {
|
||||
const old = this.scrollTarget;
|
||||
this._unregisterListeners();
|
||||
this._scrollTarget = value;
|
||||
this._updateBarPosition();
|
||||
this.requestUpdate("scrollTarget", old);
|
||||
if (this.isConnected) {
|
||||
this._registerListeners();
|
||||
@@ -189,6 +178,7 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
if (this.hasUpdated) {
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -210,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>`
|
||||
@@ -233,22 +225,8 @@ 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 ha-scrollbar">
|
||||
return html`<div class="top-app-bar-fixed-adjust">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
@@ -257,6 +235,7 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -274,6 +253,13 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this._unregisterListeners();
|
||||
}
|
||||
|
||||
protected _updateBarPosition() {
|
||||
if (this._barElement) {
|
||||
this._barElement.style.position =
|
||||
this.scrollTarget === window ? "" : "absolute";
|
||||
}
|
||||
}
|
||||
|
||||
protected _syncScrollState = () => {
|
||||
const scrollTop =
|
||||
this.scrollTarget instanceof Window
|
||||
@@ -282,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",
|
||||
@@ -333,10 +314,7 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
|
||||
};
|
||||
|
||||
static override styles: CSSResultGroup = [
|
||||
haStyleScrollbar,
|
||||
haTopAppBarFixedStyles,
|
||||
];
|
||||
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -85,25 +85,15 @@ export class HaTTSVoicePicker extends LitElement {
|
||||
await listTTSVoices(this.hass, this.engineId, this.language)
|
||||
).voices;
|
||||
|
||||
const valueIsValid =
|
||||
this.value &&
|
||||
this._voices?.some((voice) => voice.voice_id === this.value);
|
||||
|
||||
if (valueIsValid) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The current value is missing or no longer valid for the loaded voices.
|
||||
// When a voice is required, auto-select the first one (the <ha-select>
|
||||
// already displays it) so the value is propagated to the parent;
|
||||
// otherwise clear it.
|
||||
const newValue =
|
||||
this.required && this._voices?.length
|
||||
? this._voices[0].voice_id
|
||||
: undefined;
|
||||
|
||||
if (newValue !== this.value) {
|
||||
this.value = newValue;
|
||||
if (
|
||||
!this._voices ||
|
||||
!this._voices.find((voice) => voice.voice_id === this.value)
|
||||
) {
|
||||
this.value = undefined;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
<div
|
||||
class=${classMap({
|
||||
"top-app-bar-fixed-adjust": true,
|
||||
"ha-scrollbar": true,
|
||||
"top-app-bar-fixed-adjust--pane": this.pane,
|
||||
})}
|
||||
>
|
||||
@@ -131,7 +130,12 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
|
||||
.top-app-bar-fixed-adjust--pane {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: calc(
|
||||
100vh - var(--total-top-app-bar-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
.pane {
|
||||
@@ -163,7 +167,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust--pane .content {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import timezones from "google-timezones-json";
|
||||
|
||||
export const createTimezoneListEl = () => {
|
||||
const list = document.createElement("datalist");
|
||||
list.id = "timezones";
|
||||
Object.keys(timezones).forEach((key) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = key;
|
||||
option.innerText = timezones[key];
|
||||
list.appendChild(option);
|
||||
});
|
||||
return list;
|
||||
};
|
||||
@@ -73,44 +73,30 @@ export const getEntities = (
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
|
||||
// These run over every entity, so use Sets for O(1) membership instead of
|
||||
// repeated Array.includes scans.
|
||||
if (includeEntities) {
|
||||
const includeEntitiesSet = new Set(includeEntities);
|
||||
entityIds = entityIds.filter((entityId) =>
|
||||
includeEntitiesSet.has(entityId)
|
||||
includeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeEntities) {
|
||||
const excludeEntitiesSet = new Set(excludeEntities);
|
||||
entityIds = entityIds.filter(
|
||||
(entityId) => !excludeEntitiesSet.has(entityId)
|
||||
(entityId) => !excludeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDomains) {
|
||||
const includeDomainsSet = new Set(includeDomains);
|
||||
entityIds = entityIds.filter((eid) =>
|
||||
includeDomainsSet.has(computeDomain(eid))
|
||||
includeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
const excludeDomainsSet = new Set(excludeDomains);
|
||||
entityIds = entityIds.filter(
|
||||
(eid) => !excludeDomainsSet.has(computeDomain(eid))
|
||||
(eid) => !excludeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
// These values are the same for every entity, so compute them once instead
|
||||
// of inside the map over (potentially thousands of) entities.
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const domainNames = new Map<string, string>();
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
@@ -124,12 +110,12 @@ export const getEntities = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
if (domainName === undefined) {
|
||||
domainName = domainToName(hass.localize, domain);
|
||||
domainNames.set(domain, domainName);
|
||||
}
|
||||
const domainName = domainToName(hass.localize, computeDomain(entityId));
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
|
||||
+6
-8
@@ -725,18 +725,16 @@ export const mergeHistoryResults = (
|
||||
}
|
||||
|
||||
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
|
||||
const historyDataByEntity = new Map(
|
||||
historyItem.data.map((d) => [d.entity_id, d])
|
||||
);
|
||||
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
|
||||
const entities = new Set([
|
||||
...historyDataByEntity.keys(),
|
||||
...ltsDataByEntity.keys(),
|
||||
...historyItem.data.map((d) => d.entity_id),
|
||||
...ltsItem.data.map((d) => d.entity_id),
|
||||
]);
|
||||
|
||||
for (const entity of entities) {
|
||||
const historyDataItem = historyDataByEntity.get(entity);
|
||||
const ltsDataItem = ltsDataByEntity.get(entity);
|
||||
const historyDataItem = historyItem.data.find(
|
||||
(d) => d.entity_id === entity
|
||||
);
|
||||
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
|
||||
|
||||
if (!historyDataItem || !ltsDataItem) {
|
||||
newLineItem.data.push(historyDataItem || ltsDataItem!);
|
||||
|
||||
@@ -43,6 +43,11 @@ export const lightSupportsColorMode = (
|
||||
mode: LightColorMode
|
||||
) => entity.attributes.supported_color_modes?.includes(mode) || false;
|
||||
|
||||
export const lightIsInColorMode = (entity: LightEntity) =>
|
||||
(entity.attributes.color_mode &&
|
||||
modesSupportingColor.includes(entity.attributes.color_mode)) ||
|
||||
false;
|
||||
|
||||
export const lightSupportsColor = (entity: LightEntity) =>
|
||||
entity.attributes.supported_color_modes?.some((mode) =>
|
||||
modesSupportingColor.includes(mode)
|
||||
@@ -154,3 +159,5 @@ export const computeDefaultFavoriteColors = (
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
export const formatTempColor = (value: number) => `${value} K`;
|
||||
|
||||
+1
-3
@@ -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 = (
|
||||
|
||||
+21
-32
@@ -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,
|
||||
@@ -231,24 +221,6 @@ export const computeUpdateStateDisplay = (
|
||||
const state = stateObj.state;
|
||||
const attributes = stateObj.attributes;
|
||||
|
||||
// An install can be in progress even when the state is "off", e.g. when
|
||||
// downgrading firmware (installed_version is newer than latest_version).
|
||||
// Show the installing status regardless of state in that case.
|
||||
if (updateIsInstalling(stateObj)) {
|
||||
const supportsProgress =
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage !== null;
|
||||
if (supportsProgress) {
|
||||
return hass.localize("ui.card.update.installing_with_progress", {
|
||||
progress: formatNumber(attributes.update_percentage!, hass.locale, {
|
||||
maximumFractionDigits: attributes.display_precision,
|
||||
minimumFractionDigits: attributes.display_precision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return hass.localize("ui.card.update.installing");
|
||||
}
|
||||
|
||||
if (state === "off") {
|
||||
const isSkipped =
|
||||
attributes.latest_version &&
|
||||
@@ -259,6 +231,23 @@ export const computeUpdateStateDisplay = (
|
||||
return hass.formatEntityState(stateObj);
|
||||
}
|
||||
|
||||
if (state === "on") {
|
||||
if (updateIsInstalling(stateObj)) {
|
||||
const supportsProgress =
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage !== null;
|
||||
if (supportsProgress) {
|
||||
return hass.localize("ui.card.update.installing_with_progress", {
|
||||
progress: formatNumber(attributes.update_percentage!, hass.locale, {
|
||||
maximumFractionDigits: attributes.display_precision,
|
||||
minimumFractionDigits: attributes.display_precision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return hass.localize("ui.card.update.installing");
|
||||
}
|
||||
}
|
||||
|
||||
return hass.formatEntityState(stateObj);
|
||||
};
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -403,7 +403,6 @@ class DataEntryFlowDialog extends LitElement {
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
.domain=${this._params.domain ?? this._step.handler}
|
||||
@flow-step-footer-state-changed=${this
|
||||
._handleFooterStateChanged}
|
||||
></step-flow-form>
|
||||
|
||||
@@ -35,10 +35,6 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// The integration domain this flow belongs to. Unlike `step.handler`, this is
|
||||
// the domain even for options flows (where the handler is the config entry id).
|
||||
@property({ attribute: false }) public domain?: string;
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _stepData?: Record<string, any>;
|
||||
@@ -112,7 +108,7 @@ class StepFlowForm extends LitElement {
|
||||
.computeHelper=${this._helperCallback}
|
||||
.computeError=${this._errorCallback}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.context=${{ handler: step.handler, domain: this.domain }}
|
||||
.context=${{ handler: step.handler }}
|
||||
></ha-form>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -1103,7 +1104,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
.title .breadcrumb {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-family: var(--ha-font-family-heading, inherit);
|
||||
line-height: 16px;
|
||||
--mdc-icon-size: 16px;
|
||||
padding: var(--ha-space-1);
|
||||
|
||||
@@ -22,7 +22,6 @@ interface EntityInfo {
|
||||
entityId: string;
|
||||
entityName: string | undefined;
|
||||
areaId: string | undefined;
|
||||
deviceId: string | undefined;
|
||||
}
|
||||
|
||||
@customElement("more-info-content")
|
||||
@@ -121,7 +120,7 @@ class MoreInfoContent extends LitElement {
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
const { area } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
@@ -129,8 +128,7 @@ class MoreInfoContent extends LitElement {
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
return { entityId, entityName, areaId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
@@ -142,20 +140,10 @@ class MoreInfoContent extends LitElement {
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
// Build name and state content config based on conditions
|
||||
const name: EntityNameItem[] = [{ type: "device" }];
|
||||
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
if (!allSameEntityName) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -261,22 +261,15 @@ export class HaConfigAppsInstalled extends LitElement {
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--ha-space-3) var(--ha-space-2);
|
||||
background: var(--sidebar-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -34,7 +34,6 @@ import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import { groupBy } from "../../../common/util/group-by";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import { createColumnsController } from "../../../common/util/responsive-columns";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -140,8 +139,6 @@ const NAVIGATION_ACTIONS: {
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MAX_COLUMNS = 3;
|
||||
|
||||
@customElement("ha-config-area-page")
|
||||
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -162,8 +159,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _logbookTime = { recent: 86400 };
|
||||
|
||||
private _columnsController = createColumnsController(this, MAX_COLUMNS);
|
||||
|
||||
private _memberships = memoizeOne(
|
||||
(
|
||||
areaId: string,
|
||||
@@ -293,28 +288,29 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
Object.values(this.hass.devices),
|
||||
this._entityReg
|
||||
);
|
||||
const { devices, entities } = memberships;
|
||||
const quickLinkCounts = this._getQuickLinkCounts(
|
||||
memberships,
|
||||
this._related
|
||||
);
|
||||
|
||||
// Compute the display names on shallow copies so we can sort and render by
|
||||
// them without mutating the shared registry objects.
|
||||
const devices = memberships.devices.map((entry) => ({
|
||||
...entry,
|
||||
name: computeDeviceNameDisplay(
|
||||
entry,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
),
|
||||
}));
|
||||
sortDeviceRegistryByName(devices, this.hass.locale.language);
|
||||
|
||||
const entities = memberships.entities.map((entry) => ({
|
||||
...entry,
|
||||
name: computeEntityRegistryName(this.hass, entry),
|
||||
}));
|
||||
sortEntityRegistryByName(entities, this.hass.locale.language);
|
||||
// Pre-compute the entity and device names, so we can sort by them
|
||||
if (devices) {
|
||||
devices.forEach((entry) => {
|
||||
entry.name = computeDeviceNameDisplay(
|
||||
entry,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
);
|
||||
});
|
||||
sortDeviceRegistryByName(devices, this.hass.locale.language);
|
||||
}
|
||||
if (entities) {
|
||||
entities.forEach((entry) => {
|
||||
entry.name = computeEntityRegistryName(this.hass, entry);
|
||||
});
|
||||
sortEntityRegistryByName(entities, this.hass.locale.language);
|
||||
}
|
||||
|
||||
// Group entities by domain
|
||||
const groupedEntities = groupBy(entities, (entity) =>
|
||||
@@ -362,267 +358,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
)
|
||||
);
|
||||
|
||||
const infoColumn = html`
|
||||
${area.picture
|
||||
? html`<div class="img-container">
|
||||
<img alt=${area.name} src=${area.picture} />
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.edit_settings"
|
||||
)}
|
||||
class="img-edit-btn"
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
${area.picture && !this._newTriggersConditions
|
||||
? nothing
|
||||
: html`<div class="action-buttons">
|
||||
${area.picture
|
||||
? nothing
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.add_picture")}
|
||||
</ha-button>`}
|
||||
${this._newTriggersConditions
|
||||
? html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._showAddToDialog}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>`}
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
>${devices.length
|
||||
? html`<ha-list>
|
||||
${devices.map(
|
||||
(device) => html`
|
||||
<a href="/config/devices/device/${device.id}">
|
||||
<ha-list-item hasMeta>
|
||||
<span>${device.name}</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</a>
|
||||
`
|
||||
)}
|
||||
</ha-list>`
|
||||
: html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize("ui.panel.config.devices.no_devices")}
|
||||
</div>
|
||||
`}
|
||||
</ha-card>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.linked_entities_caption"
|
||||
)}
|
||||
>
|
||||
${nonAutomatedEntities.length
|
||||
? html`<ha-list>
|
||||
${nonAutomatedEntities.map(
|
||||
(entity) => html`
|
||||
<ha-list-item
|
||||
@click=${this._openEntity}
|
||||
.entity=${entity}
|
||||
hasMeta
|
||||
>
|
||||
<span>${entity.name}</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}</ha-list
|
||||
>`
|
||||
: html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.no_linked_entities"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</ha-card>
|
||||
`;
|
||||
|
||||
const relatedColumn = html`
|
||||
${isComponentLoaded(this.hass.config, "automation")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.automations_heading"
|
||||
)}
|
||||
>
|
||||
${groupedAutomations?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${groupedAutomations.map((automation) =>
|
||||
this._renderAutomation(
|
||||
automation.name,
|
||||
automation.entity
|
||||
)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${relatedAutomations?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${relatedAutomations.map((automation) =>
|
||||
this._renderAutomation(
|
||||
automation.name,
|
||||
automation.entity
|
||||
)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${!groupedAutomations?.length && !relatedAutomations?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.no_automations"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
${isComponentLoaded(this.hass.config, "scene")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.scene.scenes_heading"
|
||||
)}
|
||||
>
|
||||
${groupedScenes?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${groupedScenes.map((scene) =>
|
||||
this._renderScene(scene.name, scene.entity)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${relatedScenes?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${relatedScenes.map((scene) =>
|
||||
this._renderScene(scene.name, scene.entity)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${!groupedScenes?.length && !relatedScenes?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.scene.no_scenes"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
${isComponentLoaded(this.hass.config, "script")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.script.scripts_heading"
|
||||
)}
|
||||
>
|
||||
${groupedScripts?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
${groupedScripts.map((script) =>
|
||||
this._renderScript(script.name, script.entity)
|
||||
)}`
|
||||
: ""}
|
||||
${relatedScripts?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
${relatedScripts.map((script) =>
|
||||
this._renderScript(script.name, script.entity)
|
||||
)}`
|
||||
: ""}
|
||||
${!groupedScripts?.length && !relatedScripts?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.script.no_scripts"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const logbookColumn = html`
|
||||
${isComponentLoaded(this.hass.config, "logbook")
|
||||
? html`
|
||||
<ha-card outlined .header=${this.hass.localize("panel.logbook")}>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._allEntities(memberships)}
|
||||
.deviceIds=${this._allDeviceIds(memberships.devices)}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
></ha-logbook>
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
// In 2 columns the logbook goes on the right, under the shorter
|
||||
// automations/scenes/scripts column, to balance the column heights.
|
||||
const columns =
|
||||
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
|
||||
|
||||
const columnContents =
|
||||
columns >= 3
|
||||
? [[infoColumn], [relatedColumn], [logbookColumn]]
|
||||
: columns === 2
|
||||
? [[infoColumn], [relatedColumn, logbookColumn]]
|
||||
: [[infoColumn, relatedColumn, logbookColumn]];
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
@@ -667,10 +402,266 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="container" ${this._columnsController.target()}>
|
||||
${columnContents.map(
|
||||
(contents) => html`<div class="column">${contents}</div>`
|
||||
)}
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
${area.picture
|
||||
? html`<div class="img-container">
|
||||
<img alt=${area.name} src=${area.picture} />
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.edit_settings"
|
||||
)}
|
||||
class="img-edit-btn"
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
${area.picture && !this._newTriggersConditions
|
||||
? nothing
|
||||
: html`<div class="action-buttons">
|
||||
${area.picture
|
||||
? nothing
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiImagePlus}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.add_picture"
|
||||
)}
|
||||
</ha-button>`}
|
||||
${this._newTriggersConditions
|
||||
? html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._showAddToDialog}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>`}
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
>${devices.length
|
||||
? html`<ha-list>
|
||||
${devices.map(
|
||||
(device) => html`
|
||||
<a href="/config/devices/device/${device.id}">
|
||||
<ha-list-item hasMeta>
|
||||
<span>${device.name}</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</a>
|
||||
`
|
||||
)}
|
||||
</ha-list>`
|
||||
: html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.no_devices"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</ha-card>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.linked_entities_caption"
|
||||
)}
|
||||
>
|
||||
${nonAutomatedEntities.length
|
||||
? html`<ha-list>
|
||||
${nonAutomatedEntities.map(
|
||||
(entity) => html`
|
||||
<ha-list-item
|
||||
@click=${this._openEntity}
|
||||
.entity=${entity}
|
||||
hasMeta
|
||||
>
|
||||
<span>${entity.name}</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}</ha-list
|
||||
>`
|
||||
: html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.no_linked_entities"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</ha-card>
|
||||
</div>
|
||||
<div class="column">
|
||||
${isComponentLoaded(this.hass.config, "automation")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.automations_heading"
|
||||
)}
|
||||
>
|
||||
${groupedAutomations?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${groupedAutomations.map((automation) =>
|
||||
this._renderAutomation(
|
||||
automation.name,
|
||||
automation.entity
|
||||
)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${relatedAutomations?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${relatedAutomations.map((automation) =>
|
||||
this._renderAutomation(
|
||||
automation.name,
|
||||
automation.entity
|
||||
)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${!groupedAutomations?.length && !relatedAutomations?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.no_automations"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
${isComponentLoaded(this.hass.config, "scene")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.scene.scenes_heading"
|
||||
)}
|
||||
>
|
||||
${groupedScenes?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${groupedScenes.map((scene) =>
|
||||
this._renderScene(scene.name, scene.entity)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${relatedScenes?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
<ha-list>
|
||||
${relatedScenes.map((scene) =>
|
||||
this._renderScene(scene.name, scene.entity)
|
||||
)}</ha-list
|
||||
>`
|
||||
: ""}
|
||||
${!groupedScenes?.length && !relatedScenes?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.scene.no_scenes"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
${isComponentLoaded(this.hass.config, "script")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.devices.script.scripts_heading"
|
||||
)}
|
||||
>
|
||||
${groupedScripts?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.assigned_to_area"
|
||||
)}:
|
||||
</h3>
|
||||
${groupedScripts.map((script) =>
|
||||
this._renderScript(script.name, script.entity)
|
||||
)}`
|
||||
: ""}
|
||||
${relatedScripts?.length
|
||||
? html`<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.areas.targeting_area"
|
||||
)}:
|
||||
</h3>
|
||||
${relatedScripts.map((script) =>
|
||||
this._renderScript(script.name, script.entity)
|
||||
)}`
|
||||
: ""}
|
||||
${!groupedScripts?.length && !relatedScripts?.length
|
||||
? html`
|
||||
<div class="no-entries">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.script.no_scripts"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="column">
|
||||
${isComponentLoaded(this.hass.config, "logbook")
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("panel.logbook")}
|
||||
>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._allEntities(memberships)}
|
||||
.deviceIds=${this._allDeviceIds(memberships.devices)}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
></ha-logbook>
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -914,31 +905,30 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-4);
|
||||
margin: auto;
|
||||
max-width: 1280px;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
margin-top: var(--ha-space-8);
|
||||
margin-bottom: var(--ha-space-8);
|
||||
max-width: 1000px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.column {
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: 33%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.fullwidth {
|
||||
padding: var(--ha-space-2);
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.column > *:not(:first-child) {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:host([narrow]) .column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host([narrow]) .container {
|
||||
|
||||
@@ -18,22 +18,13 @@ import {
|
||||
} from "../../../../data/automation";
|
||||
import { MODES, isMaxMode } from "../../../../data/script";
|
||||
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 { documentationUrl } from "../../../../util/documentation-url";
|
||||
import type { AutomationModeDialog } from "./show-dialog-automation-mode";
|
||||
|
||||
interface AutomationModeState {
|
||||
mode: (typeof MODES)[number];
|
||||
max?: number;
|
||||
}
|
||||
|
||||
@customElement("ha-dialog-automation-mode")
|
||||
class DialogAutomationMode
|
||||
extends DirtyStateProviderMixin<AutomationModeState>()(LitElement)
|
||||
implements HassDialog
|
||||
{
|
||||
class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -51,10 +42,6 @@ class DialogAutomationMode
|
||||
this._newMax = isMaxMode(this._newMode)
|
||||
? params.config.max || AUTOMATION_DEFAULT_MAX
|
||||
: undefined;
|
||||
this._initDirtyTracking(
|
||||
{ type: "shallow" },
|
||||
{ mode: this._newMode, max: this._newMax }
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -83,7 +70,6 @@ class DialogAutomationMode
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -137,11 +123,7 @@ class DialogAutomationMode
|
||||
>
|
||||
${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.panel.config.automation.editor.change_mode"
|
||||
)}
|
||||
@@ -159,7 +141,6 @@ class DialogAutomationMode
|
||||
} else if (!this._newMax) {
|
||||
this._newMax = AUTOMATION_DEFAULT_MAX;
|
||||
}
|
||||
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
|
||||
}
|
||||
|
||||
private _valueChanged(ev: InputEvent) {
|
||||
@@ -167,7 +148,6 @@ class DialogAutomationMode
|
||||
const target = ev.target as HaInput;
|
||||
if (target.name === "max") {
|
||||
this._newMax = Number(target.value);
|
||||
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { GenDataTaskResult } from "../../../../data/ai_task";
|
||||
import type { AutomationConfig } from "../../../../data/automation";
|
||||
import type { ScriptConfig } from "../../../../data/script";
|
||||
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 {
|
||||
@@ -39,18 +38,8 @@ import type {
|
||||
SaveDialogParams,
|
||||
} from "./show-dialog-automation-save";
|
||||
|
||||
interface AutomationSaveState {
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
entryUpdates: EntityRegistryUpdate;
|
||||
}
|
||||
|
||||
@customElement("ha-dialog-automation-save")
|
||||
class DialogAutomationSave
|
||||
extends DirtyStateProviderMixin<AutomationSaveState>()(LitElement)
|
||||
implements HassDialog
|
||||
{
|
||||
class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -92,16 +81,6 @@ class DialogAutomationSave
|
||||
this._entryUpdates.labels.length > 0 ? "labels" : "",
|
||||
this._entryUpdates.area ? "area" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
name: this._newName,
|
||||
description: this._newDescription,
|
||||
icon: this._newIcon,
|
||||
entryUpdates: this._entryUpdates,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -273,7 +252,6 @@ class DialogAutomationSave
|
||||
.open=${this._open}
|
||||
@closed=${this._dialogClosed}
|
||||
header-title=${this._params.title || title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._params.hideInputs
|
||||
? nothing
|
||||
@@ -303,11 +281,7 @@ class DialogAutomationSave
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!!this._params.config.alias && !this.isDirtyState}
|
||||
>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
${this.hass.localize(
|
||||
this._params.config.alias && !this._params.onDiscard
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
@@ -325,28 +299,17 @@ class DialogAutomationSave
|
||||
this._visibleOptionals = [...this._visibleOptionals, option];
|
||||
}
|
||||
|
||||
private _trackDirtyState() {
|
||||
this._updateDirtyState({
|
||||
name: this._newName,
|
||||
description: this._newDescription,
|
||||
icon: this._newIcon,
|
||||
entryUpdates: this._entryUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
private _registryEntryChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const id: string = ev.target.id;
|
||||
const value = ev.detail.value;
|
||||
|
||||
this._entryUpdates = { ...this._entryUpdates, [id]: value };
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private _iconChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this._newIcon = ev.detail.value || undefined;
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
@@ -357,7 +320,6 @@ class DialogAutomationSave
|
||||
} else {
|
||||
this._newName = target.value;
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private _handleDiscard() {
|
||||
@@ -425,7 +387,6 @@ class DialogAutomationSave
|
||||
this._visibleOptionals = [...this._visibleOptionals, "labels"];
|
||||
}
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -164,9 +164,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiHelpCircleOutline}
|
||||
class="help-icon"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</a>`
|
||||
: nothing}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -251,13 +251,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
if (filteredAutomations === null) {
|
||||
return [];
|
||||
}
|
||||
// Build lookups once instead of scanning the registries for every row.
|
||||
const entityRegLookup = new Map(
|
||||
entityReg.map((reg) => [reg.entity_id, reg])
|
||||
);
|
||||
const labelLookup = labelReg
|
||||
? new Map(labelReg.map((label) => [label.label_id, label]))
|
||||
: undefined;
|
||||
return (
|
||||
filteredAutomations
|
||||
? automations.filter((automation) =>
|
||||
@@ -265,11 +258,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
)
|
||||
: automations
|
||||
).map((automation) => {
|
||||
const entityRegEntry = entityRegLookup.get(automation.entity_id);
|
||||
const entityRegEntry = entityReg.find(
|
||||
(reg) => reg.entity_id === automation.entity_id
|
||||
);
|
||||
const category = entityRegEntry?.categories.automation;
|
||||
const labels = labelReg && entityRegEntry?.labels;
|
||||
const label_entries = (labels || [])
|
||||
.map((lbl) => labelLookup!.get(lbl)!)
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
|
||||
.filter(Boolean);
|
||||
const assistants = getEntityVoiceAssistantsIds(
|
||||
entityReg,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -55,9 +55,6 @@ export class HaConversationTrigger
|
||||
@click=${this._removeOption}
|
||||
slot="end"
|
||||
.path=${mdiClose}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.conversation.delete"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-input>
|
||||
`
|
||||
@@ -81,9 +78,6 @@ export class HaConversationTrigger
|
||||
@click=${this._addOption}
|
||||
slot="end"
|
||||
.path=${mdiPlus}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.conversation.add_sentence"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-input>`;
|
||||
}
|
||||
|
||||
@@ -201,9 +201,6 @@ export class HaPlatformTrigger extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiHelpCircleOutline}
|
||||
class="help-icon"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</a>`
|
||||
: nothing}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -84,10 +84,7 @@ export class CloudRegister extends LitElement {
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<a href="https://www.nabucasa.com" target="_blank"
|
||||
>Nabu Casa, Inc</a
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,12 +37,6 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public showAttributes = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
public showDevice = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
public showArea = true;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@@ -63,15 +57,11 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
|
||||
protected render() {
|
||||
const showAttributes = !this.narrow && this.showAttributes;
|
||||
const showDevice = !this.narrow && this.showDevice;
|
||||
const showArea = !this.narrow && this.showArea;
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
entities: true,
|
||||
"hide-attributes": !showAttributes,
|
||||
"hide-device": !showDevice,
|
||||
"hide-area": !showArea,
|
||||
"hide-extra": this.narrow,
|
||||
})}
|
||||
role="table"
|
||||
@@ -91,14 +81,14 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header" role="columnheader" ?hidden=${!showDevice}>
|
||||
<div class="header" role="columnheader">
|
||||
<span class="padded">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.entities.picker.headers.device"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header" role="columnheader" ?hidden=${!showArea}>
|
||||
<div class="header" role="columnheader">
|
||||
<span class="padded">
|
||||
${this._i18n.localize("ui.panel.config.generic.headers.area")}
|
||||
</span>
|
||||
@@ -365,24 +355,6 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hide-device .filter-devices {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-device .row .header:nth-child(3),
|
||||
.hide-device .row .cell:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-area .filter-areas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-area .row .header:nth-child(4),
|
||||
.hide-area .row .cell:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-attributes .filter-attributes {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -87,18 +87,6 @@ class HaPanelDevState extends LitElement {
|
||||
})
|
||||
private _showAttributes = true;
|
||||
|
||||
@storage({
|
||||
key: "devToolsShowDevice",
|
||||
state: true,
|
||||
})
|
||||
private _showDevice = true;
|
||||
|
||||
@storage({
|
||||
key: "devToolsShowArea",
|
||||
state: true,
|
||||
})
|
||||
private _showArea = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state()
|
||||
@@ -169,32 +157,14 @@ class HaPanelDevState extends LitElement {
|
||||
)}
|
||||
</h1>
|
||||
${!this.narrow
|
||||
? html`
|
||||
<div class="filters-toggles">
|
||||
<ha-checkbox
|
||||
.checked=${this._showDevice}
|
||||
@change=${this._saveDeviceCheckboxState}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.entities.picker.headers.device"
|
||||
)}
|
||||
</ha-checkbox>
|
||||
<ha-checkbox
|
||||
.checked=${this._showArea}
|
||||
@change=${this._saveAreaCheckboxState}
|
||||
>
|
||||
${this._i18n.localize("ui.panel.config.generic.headers.area")}
|
||||
</ha-checkbox>
|
||||
</div>
|
||||
<ha-checkbox
|
||||
.checked=${this._showAttributes}
|
||||
@change=${this._saveAttributeCheckboxState}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.developer-tools.tabs.states.attributes"
|
||||
)}
|
||||
</ha-checkbox>
|
||||
`
|
||||
? html`<ha-checkbox
|
||||
.checked=${this._showAttributes}
|
||||
@change=${this._saveAttributeCheckboxState}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.developer-tools.tabs.states.attributes"
|
||||
)}
|
||||
</ha-checkbox>`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-expansion-panel
|
||||
@@ -310,8 +280,6 @@ class HaPanelDevState extends LitElement {
|
||||
.entities=${entities}
|
||||
.virtualize=${entities.length > VIRTUALIZE_THRESHOLD}
|
||||
.showAttributes=${this._showAttributes}
|
||||
.showDevice=${this._showDevice}
|
||||
.showArea=${this._showArea}
|
||||
@states-tool-entity-selected=${this._entitySelected}
|
||||
>
|
||||
<ha-input-search
|
||||
@@ -625,14 +593,6 @@ class HaPanelDevState extends LitElement {
|
||||
this._showAttributes = ev.target.checked;
|
||||
}
|
||||
|
||||
private _saveDeviceCheckboxState(ev) {
|
||||
this._showDevice = ev.target.checked;
|
||||
}
|
||||
|
||||
private _saveAreaCheckboxState(ev) {
|
||||
this._showArea = ev.target.checked;
|
||||
}
|
||||
|
||||
private _yamlChanged(ev) {
|
||||
this._stateAttributes = ev.detail.value;
|
||||
this._validJSON = ev.detail.isValid;
|
||||
@@ -657,25 +617,12 @@ class HaPanelDevState extends LitElement {
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.heading h1 {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.filters-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.heading .filters-toggles ha-checkbox {
|
||||
margin-right: 0;
|
||||
width: max-content;
|
||||
display: inline-flex;
|
||||
.heading ha-checkbox {
|
||||
margin-right: var(--ha-space-2);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
|
||||
@@ -38,7 +38,6 @@ import { stringCompare } from "../../../common/string/compare";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { groupBy } from "../../../common/util/group-by";
|
||||
import { createColumnsController } from "../../../common/util/responsive-columns";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -176,8 +175,6 @@ export interface DeviceAlert {
|
||||
|
||||
const DEVICE_ALERTS_INTERVAL = 30000;
|
||||
|
||||
const MAX_COLUMNS = 3;
|
||||
|
||||
@customElement("ha-config-device-page")
|
||||
export class HaConfigDevicePage extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -214,8 +211,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
private _logbookTime = { recent: 86400 };
|
||||
|
||||
private _columnsController = createColumnsController(this, MAX_COLUMNS);
|
||||
|
||||
private _integrations = memoizeOne(
|
||||
(
|
||||
device: DeviceRegistryEntry,
|
||||
@@ -754,176 +749,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
`
|
||||
: "";
|
||||
|
||||
const infoColumn = html`
|
||||
${this._deviceAlerts?.length
|
||||
? html`
|
||||
<div>
|
||||
${this._deviceAlerts.map(
|
||||
(alert) => html`
|
||||
<ha-alert .alertType=${alert.level}> ${alert.text} </ha-alert>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<ha-device-info-card .hass=${this.hass} .device=${device}>
|
||||
${deviceInfo}
|
||||
${firstDeviceAction || actions.length
|
||||
? html`
|
||||
<div class="card-actions" slot="actions">
|
||||
<ha-button
|
||||
href=${ifDefined(firstDeviceAction!.href)}
|
||||
rel=${ifDefined(
|
||||
firstDeviceAction!.target ? "noreferrer" : undefined
|
||||
)}
|
||||
appearance="plain"
|
||||
target=${ifDefined(firstDeviceAction!.target)}
|
||||
class=${ifDefined(firstDeviceAction!.classes)}
|
||||
.variant=${firstDeviceAction!.classes?.includes("warning")
|
||||
? "danger"
|
||||
: "brand"}
|
||||
.action=${firstDeviceAction!.action}
|
||||
@click=${this._deviceActionClicked}
|
||||
>
|
||||
${firstDeviceAction!.label}
|
||||
${firstDeviceAction!.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class=${ifDefined(firstDeviceAction!.classes)}
|
||||
.path=${firstDeviceAction!.icon}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
${firstDeviceAction!.trailingIcon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${firstDeviceAction!.trailingIcon}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
</ha-button>
|
||||
|
||||
${actions.length
|
||||
? html`
|
||||
<ha-dropdown
|
||||
@wa-select=${this._deviceActionSelected}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${actions.map((deviceAction, idx) => {
|
||||
const dropdownItem = html`<ha-dropdown-item
|
||||
.value=${idx}
|
||||
.data=${deviceAction}
|
||||
.variant=${deviceAction.classes?.includes("warning")
|
||||
? "danger"
|
||||
: "default"}
|
||||
>
|
||||
${deviceAction.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${deviceAction.icon}
|
||||
slot="icon"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ""}
|
||||
${deviceAction.label}
|
||||
${deviceAction.trailingIcon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="details"
|
||||
.path=${deviceAction.trailingIcon}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ""}
|
||||
</ha-dropdown-item>`;
|
||||
return deviceAction.href
|
||||
? html`<a
|
||||
href=${deviceAction.href}
|
||||
target=${ifDefined(deviceAction.target)}
|
||||
rel=${ifDefined(
|
||||
deviceAction.target ? "noreferrer" : undefined
|
||||
)}
|
||||
>${dropdownItem}
|
||||
</a>`
|
||||
: dropdownItem;
|
||||
})}
|
||||
</ha-dropdown>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-device-info-card>
|
||||
`;
|
||||
|
||||
const entitiesColumn = html`
|
||||
${(
|
||||
[
|
||||
"control",
|
||||
"sensor",
|
||||
"notify",
|
||||
"event",
|
||||
"assist",
|
||||
"config",
|
||||
"diagnostic",
|
||||
] as const
|
||||
).map((category) =>
|
||||
// Make sure we render controls if no other cards will be rendered
|
||||
entitiesByCategory[category].length > 0 ||
|
||||
(entities.length === 0 && category === "control")
|
||||
? html`
|
||||
<ha-device-entities-card
|
||||
.hass=${this.hass}
|
||||
.header=${this.hass.localize(
|
||||
`ui.panel.config.devices.entities.${category}`
|
||||
)}
|
||||
.deviceName=${deviceName}
|
||||
.entities=${entitiesByCategory[category]}
|
||||
.showHidden=${device.disabled_by !== null}
|
||||
>
|
||||
</ha-device-entities-card>
|
||||
`
|
||||
: ""
|
||||
)}
|
||||
<ha-device-via-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
></ha-device-via-devices-card>
|
||||
`;
|
||||
|
||||
const logbookColumn = isComponentLoaded(this.hass.config, "logbook")
|
||||
? html`
|
||||
<ha-card outlined>
|
||||
<h1 class="card-header">${this.hass.localize("panel.logbook")}</h1>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._entityIds(entities)}
|
||||
.deviceIds=${this._deviceIdInList(this.deviceId)}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
></ha-logbook>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing;
|
||||
|
||||
const columns =
|
||||
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
|
||||
|
||||
const columnContents =
|
||||
columns >= 3
|
||||
? [[infoColumn, relatedCard], [entitiesColumn], [logbookColumn]]
|
||||
: columns === 2
|
||||
? [[infoColumn, relatedCard, logbookColumn], [entitiesColumn]]
|
||||
: [[infoColumn, entitiesColumn, relatedCard, logbookColumn]];
|
||||
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
@@ -971,7 +796,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="container" ${this._columnsController.target()}>
|
||||
<div class="container">
|
||||
<div class="header fullwidth">
|
||||
${area
|
||||
? html`<div class="header-name">
|
||||
@@ -1024,9 +849,175 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
${columnContents.map(
|
||||
(contents) => html`<div class="column">${contents}</div>`
|
||||
)}
|
||||
<div class="column">
|
||||
${this._deviceAlerts?.length
|
||||
? html`
|
||||
<div>
|
||||
${this._deviceAlerts.map(
|
||||
(alert) => html`
|
||||
<ha-alert .alertType=${alert.level}>
|
||||
${alert.text}
|
||||
</ha-alert>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<ha-device-info-card .hass=${this.hass} .device=${device}>
|
||||
${deviceInfo}
|
||||
${firstDeviceAction || actions.length
|
||||
? html`
|
||||
<div class="card-actions" slot="actions">
|
||||
<ha-button
|
||||
href=${ifDefined(firstDeviceAction!.href)}
|
||||
rel=${ifDefined(
|
||||
firstDeviceAction!.target ? "noreferrer" : undefined
|
||||
)}
|
||||
appearance="plain"
|
||||
target=${ifDefined(firstDeviceAction!.target)}
|
||||
class=${ifDefined(firstDeviceAction!.classes)}
|
||||
.variant=${firstDeviceAction!.classes?.includes("warning")
|
||||
? "danger"
|
||||
: "brand"}
|
||||
.action=${firstDeviceAction!.action}
|
||||
@click=${this._deviceActionClicked}
|
||||
>
|
||||
${firstDeviceAction!.label}
|
||||
${firstDeviceAction!.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class=${ifDefined(firstDeviceAction!.classes)}
|
||||
.path=${firstDeviceAction!.icon}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
${firstDeviceAction!.trailingIcon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${firstDeviceAction!.trailingIcon}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
</ha-button>
|
||||
|
||||
${actions.length
|
||||
? html`
|
||||
<ha-dropdown
|
||||
@wa-select=${this._deviceActionSelected}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${actions.map((deviceAction, idx) => {
|
||||
const dropdownItem = html`<ha-dropdown-item
|
||||
.value=${idx}
|
||||
.data=${deviceAction}
|
||||
.variant=${deviceAction.classes?.includes(
|
||||
"warning"
|
||||
)
|
||||
? "danger"
|
||||
: "default"}
|
||||
>
|
||||
${deviceAction.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${deviceAction.icon}
|
||||
slot="icon"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ""}
|
||||
${deviceAction.label}
|
||||
${deviceAction.trailingIcon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="details"
|
||||
.path=${deviceAction.trailingIcon}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ""}
|
||||
</ha-dropdown-item>`;
|
||||
return deviceAction.href
|
||||
? html`<a
|
||||
href=${deviceAction.href}
|
||||
target=${ifDefined(deviceAction.target)}
|
||||
rel=${ifDefined(
|
||||
deviceAction.target
|
||||
? "noreferrer"
|
||||
: undefined
|
||||
)}
|
||||
>${dropdownItem}
|
||||
</a>`
|
||||
: dropdownItem;
|
||||
})}
|
||||
</ha-dropdown>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</ha-device-info-card>
|
||||
${!this.narrow ? relatedCard : ""}
|
||||
</div>
|
||||
<div class="column">
|
||||
${(
|
||||
[
|
||||
"control",
|
||||
"sensor",
|
||||
"notify",
|
||||
"event",
|
||||
"assist",
|
||||
"config",
|
||||
"diagnostic",
|
||||
] as const
|
||||
).map((category) =>
|
||||
// Make sure we render controls if no other cards will be rendered
|
||||
entitiesByCategory[category].length > 0 ||
|
||||
(entities.length === 0 && category === "control")
|
||||
? html`
|
||||
<ha-device-entities-card
|
||||
.hass=${this.hass}
|
||||
.header=${this.hass.localize(
|
||||
`ui.panel.config.devices.entities.${category}`
|
||||
)}
|
||||
.deviceName=${deviceName}
|
||||
.entities=${entitiesByCategory[category]}
|
||||
.showHidden=${device.disabled_by !== null}
|
||||
>
|
||||
</ha-device-entities-card>
|
||||
`
|
||||
: ""
|
||||
)}
|
||||
<ha-device-via-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
></ha-device-via-devices-card>
|
||||
</div>
|
||||
<div class="column">
|
||||
${this.narrow ? relatedCard : ""}
|
||||
${isComponentLoaded(this.hass.config, "logbook")
|
||||
? html`
|
||||
<ha-card outlined>
|
||||
<h1 class="card-header">
|
||||
${this.hass.localize("panel.logbook")}
|
||||
</h1>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._entityIds(entities)}
|
||||
.deviceIds=${this._deviceIdInList(this.deviceId)}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
></ha-logbook>
|
||||
</ha-card>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</hass-subpage>`;
|
||||
}
|
||||
@@ -1636,17 +1627,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-4);
|
||||
margin: auto;
|
||||
max-width: 1280px;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
max-width: 1000px;
|
||||
margin-top: var(--ha-space-8);
|
||||
margin-bottom: var(--ha-space-8);
|
||||
}
|
||||
@@ -1707,11 +1692,12 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
.column,
|
||||
.fullwidth {
|
||||
padding: var(--ha-space-2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.column {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: 33%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.fullwidth {
|
||||
width: 100%;
|
||||
@@ -1753,6 +1739,10 @@ export class HaConfigDevicePage extends LitElement {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
:host([narrow]) .column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -452,12 +452,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
outputDevices = outputDevices.filter((device) => !device.disabled_by);
|
||||
}
|
||||
|
||||
// Build a label lookup once instead of scanning labelReg for every
|
||||
// label of every device.
|
||||
const labelLookup = labelReg
|
||||
? new Map(labelReg.map((label) => [label.label_id, label]))
|
||||
: undefined;
|
||||
|
||||
const formattedOutputDevices = outputDevices.map((device) => {
|
||||
const deviceEntries = sortConfigEntries(
|
||||
device.config_entries
|
||||
@@ -468,7 +462,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
const labels = labelReg && device?.labels;
|
||||
const labelsEntries = (labels || [])
|
||||
.map((lbl) => labelLookup!.get(lbl))
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
|
||||
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
|
||||
|
||||
const { areaName } = computeDeviceAreaLabel(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user