Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 153290593b | |||
| 189477a1d4 | |||
| 8cd9a5adf6 | |||
| 4d3437b491 | |||
| ceb51714be | |||
| d2c868f904 | |||
| 9f34de5de6 | |||
| 6c9452aa5a | |||
| 2cf79853aa | |||
| 6152812138 | |||
| 5540a6c1ff | |||
| e04297f2bd | |||
| e89f76bbbb | |||
| 319ba3940e | |||
| b9920065a2 | |||
| 3bb5201d41 | |||
| a0648b85ff | |||
| 54f901c7c9 | |||
| 2483a917f8 | |||
| d9cae08f53 | |||
| 106b35d6cf | |||
| f12d305688 | |||
| d2326b4f62 | |||
| ea9424053a | |||
| 70ffef8807 | |||
| a32169f300 | |||
| b508760d24 | |||
| 541cab83de | |||
| 8e8f2bfa4c | |||
| bafe21ab48 | |||
| ee56d7d003 | |||
| 486b6bb561 | |||
| 9a9ceaebf2 | |||
| ff5bbf46ae | |||
| 47fb4a2def | |||
| 0e716e5078 | |||
| 9e9fdfbad6 | |||
| 5ebdb99ba7 | |||
| 1ca454cf02 | |||
| 859d23c187 | |||
| f9d205defe | |||
| 00cc4e2a5a | |||
| 6571feb556 | |||
| 4150bc0806 | |||
| 958e3f2575 | |||
| 3d3292e2ad | |||
| 75b9fb2e34 | |||
| 38f0ce306b | |||
| 1ffd19e20b | |||
| 9a216cae46 | |||
| 41e6408508 | |||
| 97e85bc06f | |||
| 5f2ad7fa01 | |||
| 7b6b70023b | |||
| 256a06e35f | |||
| 4e26c05ac6 | |||
| 04ee8ac415 | |||
| 63e144309c | |||
| 77039cda8e | |||
| ab5b4ed792 | |||
| a08905cd31 | |||
| a35349196f | |||
| dbdfdedd74 | |||
| a5c8547b2b | |||
| e373689a37 | |||
| 5edcdb8977 | |||
| 26b8921e8c | |||
| b8c201b6d3 | |||
| 4a6c23c93e | |||
| e2712cb0b0 | |||
| db52cd0d8e | |||
| 4891783c86 | |||
| b73732acdb | |||
| d950514104 | |||
| f37cf1e848 | |||
| a188ef1b7a | |||
| 087ef159df | |||
| e39e1b3f5b | |||
| ff583d2274 | |||
| d4de29e073 | |||
| 97dfed0cc4 | |||
| 8b3df752da | |||
| 8c0d547962 | |||
| 5e3d84f0ad | |||
| b4e30bdf63 | |||
| 4fcae4231c | |||
| 2aecf33955 | |||
| 5f26a2b3da | |||
| b08f5bcb34 | |||
| c329e5b827 | |||
| 97f591337d | |||
| e6e6e75f73 | |||
| ff334de0ca | |||
| 8dbe97b480 | |||
| 7bea54851d | |||
| 9298e00f20 | |||
| 70085d4bad | |||
| d83a553b62 | |||
| cab5c6af30 | |||
| d44d8a6dbd | |||
| 3cf1d94b92 | |||
| 9f5f849e32 | |||
| 27e9926363 | |||
| efe734892a | |||
| b3d79e312d | |||
| ecfef9e112 | |||
| ca960446f0 | |||
| a6eb722025 | |||
| f3ff01ace4 | |||
| d5e1a373ec | |||
| e1b9a1a185 | |||
| efe8eaa941 | |||
| 5856196ef3 | |||
| 2671a8c64b | |||
| 8620653a54 | |||
| c4f4cbd323 | |||
| 2e0df00f0f | |||
| ce02f8072d | |||
| c973aa7516 | |||
| 1e2328707c | |||
| 56368b88cd | |||
| fcd4f177c1 | |||
| 7423ae7316 | |||
| 4427c581f1 | |||
| cf86bb9821 | |||
| 897802dc16 | |||
| 95edd6c2c2 | |||
| dd65173c5a | |||
| cf26753f7d | |||
| d6ab8ffb16 | |||
| 2dc4b16eac | |||
| 1eba765bc2 | |||
| 398479ddd7 | |||
| c4fd7bb3e1 | |||
| 4cfc67a95e | |||
| e38d1964ca | |||
| ec8b5c77bd | |||
| 425f2775e2 | |||
| 3a3d8191a3 | |||
| 04fca68549 | |||
| 3046f3e47d | |||
| 35601a0900 | |||
| e7016c15af | |||
| 624521e30b | |||
| 4876bfa639 | |||
| 5dea0764b2 | |||
| 121ed7ac1f |
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
e2e-browserstack:
|
||||
name: E2E (BrowserStack)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
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 ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local, e2e-browserstack]
|
||||
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,
|
||||
});
|
||||
@@ -13,13 +13,11 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
process-only: "issues, prs"
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-inactive-days: "30"
|
||||
issue-lock-reason: ""
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-inactive-days: "1"
|
||||
pr-lock-reason: ""
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
|
||||
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -54,14 +54,7 @@ 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
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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,3 +1,4 @@
|
||||
/* global require, module, __dirname, process */
|
||||
const path = require("path");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
@@ -320,22 +321,4 @@ 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,13 +1,9 @@
|
||||
// @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,3 +1,4 @@
|
||||
/* 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,10 +45,3 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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,24 +268,3 @@ 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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -57,7 +57,9 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
if (descriptionContent === "") {
|
||||
hasDescription = false;
|
||||
} else {
|
||||
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
|
||||
descriptionContent = marked(descriptionContent)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/`/g, "\\`");
|
||||
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.resolve(galleryBuild, `${pageId}-description.ts`),
|
||||
|
||||
@@ -201,23 +201,3 @@ 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,7 +4,6 @@ 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";
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -211,22 +210,3 @@ 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,15 +50,4 @@ 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,3 +1,4 @@
|
||||
/* global require, module, __dirname */
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -337,11 +338,6 @@ 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,
|
||||
@@ -349,5 +345,4 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,12 +228,6 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -13,7 +13,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
|
||||
|
||||
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
|
||||
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
|
||||
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
|
||||
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user **has made changes to**. Instead it will animate "no" by a little shake.
|
||||
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
|
||||
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
|
||||
- Keep the labels short, for example `Save`, `Delete`, `Enable`.
|
||||
|
||||
@@ -21,22 +21,13 @@
|
||||
"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: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"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.2",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
@@ -49,15 +40,15 @@
|
||||
"@codemirror/view": "6.43.0",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.6",
|
||||
"@formatjs/intl-displaynames": "7.3.8",
|
||||
"@formatjs/intl-durationformat": "0.10.12",
|
||||
"@formatjs/intl-datetimeformat": "7.4.7",
|
||||
"@formatjs/intl-displaynames": "7.3.9",
|
||||
"@formatjs/intl-durationformat": "0.10.13",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.9",
|
||||
"@formatjs/intl-listformat": "8.3.8",
|
||||
"@formatjs/intl-listformat": "8.3.9",
|
||||
"@formatjs/intl-locale": "5.3.8",
|
||||
"@formatjs/intl-numberformat": "9.3.9",
|
||||
"@formatjs/intl-pluralrules": "6.3.8",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.8",
|
||||
"@formatjs/intl-numberformat": "9.3.10",
|
||||
"@formatjs/intl-pluralrules": "6.3.9",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.9",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -71,17 +62,16 @@
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.4.1",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.0.5",
|
||||
"@tsparticles/preset-links": "4.0.5",
|
||||
"@tsparticles/engine": "4.1.0",
|
||||
"@tsparticles/preset-links": "4.1.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
@@ -92,7 +82,7 @@
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.3.0",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
@@ -135,22 +125,20 @@
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.5",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@babel/plugin-transform-runtime": "7.29.7",
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@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",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.11",
|
||||
"@rspack/core": "2.0.4",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@rsdoctor/rspack-plugin": "1.5.12",
|
||||
"@rspack/core": "2.0.5",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
@@ -162,18 +150,15 @@
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.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.0",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
@@ -194,8 +179,8 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "4.4.2",
|
||||
"lint-staged": "17.0.5",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.6",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -206,10 +191,10 @@
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.15",
|
||||
"terser-webpack-plugin": "5.6.0",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.4",
|
||||
"typescript-eslint": "8.60.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.7",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260429.0"
|
||||
version = "20260527.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { transform } from "./transform";
|
||||
|
||||
interface ConsumeEntryConfig {
|
||||
@@ -26,6 +27,28 @@ const resolveAtPath = (host: unknown, path: readonly string[]) => {
|
||||
return cur;
|
||||
};
|
||||
|
||||
/** Reuse `previous` when every entry still references the same `HassEntity`. */
|
||||
export const preserveUnchangedEntityStatesRecord = <
|
||||
T extends Record<string, HassEntity | undefined>,
|
||||
>(
|
||||
previous: T | undefined,
|
||||
next: T
|
||||
): T => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
const nextKeys = Object.keys(next);
|
||||
if (Object.keys(previous).length !== nextKeys.length) {
|
||||
return next;
|
||||
}
|
||||
for (const key of nextKeys) {
|
||||
if (previous[key] !== next[key]) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
watchKey: string | undefined,
|
||||
@@ -63,27 +86,52 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Like {@link consumeEntityState} but for an array of entity IDs at
|
||||
* `entityIdPath`. Resolves to a `HassEntity[]` containing one entry per
|
||||
* currently-available entity (missing entities and non-string IDs are
|
||||
* filtered out; original order is preserved).
|
||||
* Like {@link consumeEntityState} but for one or more entity IDs at
|
||||
* `entityIdPath` (a string or string array; wrapped with {@link ensureArray}).
|
||||
* Resolves to a record keyed by entity ID containing the currently-available
|
||||
* entities (missing entities and non-string IDs are filtered out). Returns the
|
||||
* previous record when none of the selected entities changed.
|
||||
*/
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) =>
|
||||
composeDecorator<HassEntities, HassEntity[]>(
|
||||
statesContext,
|
||||
config.entityIdPath[0],
|
||||
function (states) {
|
||||
const ids = resolveAtPath(this, config.entityIdPath);
|
||||
if (!Array.isArray(ids) || !states) return undefined;
|
||||
const result: HassEntity[] = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result.push(state);
|
||||
}
|
||||
return result;
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
const watchKey = config.entityIdPath[0];
|
||||
const buildRecord = function (this: unknown, states: HassEntities) {
|
||||
const ids = ensureArray(resolveAtPath(this, config.entityIdPath));
|
||||
if (!ids || !states) return undefined;
|
||||
const result: Record<string, HassEntity> = {};
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result[id] = state;
|
||||
}
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (proto: unknown, propertyKey: string) => {
|
||||
const key = String(propertyKey);
|
||||
const transformDec = transform<
|
||||
HassEntities,
|
||||
Record<string, HassEntity> | undefined
|
||||
>({
|
||||
transformer: function (this: unknown, states: HassEntities) {
|
||||
const next = buildRecord.call(this, states);
|
||||
if (next === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const previous = (this as Record<string, unknown>)[
|
||||
`__transform_${key}`
|
||||
] as Record<string, HassEntity> | undefined;
|
||||
return preserveUnchangedEntityStatesRecord(previous, next);
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Consumes `entitiesContext` and narrows it to the
|
||||
|
||||
@@ -17,6 +17,19 @@ export interface NavigateOptions {
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
* on load instead of cleaning up the stale dialog state by going back.
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures all dialogs are closed before navigation.
|
||||
* Returns true if navigation can proceed, false if a dialog refused to close.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
import type { RelatedResult } from "../../data/search";
|
||||
|
||||
export interface RelatedIdSets {
|
||||
areas: Set<string>;
|
||||
devices: Set<string>;
|
||||
entities: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a set of related IDs for a given related result.
|
||||
* @param related - The related result to build the sets from.
|
||||
* @returns The related ID sets.
|
||||
*/
|
||||
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
|
||||
areas: new Set(related?.area || []),
|
||||
devices: new Set(related?.device || []),
|
||||
entities: new Set(related?.entity || []),
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable partition sort: related items float to the top,
|
||||
* preserving relative order (e.g. Fuse score) within each group.
|
||||
* @param items - The items to sort.
|
||||
* @returns The sorted items.
|
||||
*/
|
||||
export const sortRelatedFirst = (
|
||||
items: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] =>
|
||||
[...items].sort((a, b) => {
|
||||
const aRelated = Boolean(a.isRelated);
|
||||
const bRelated = Boolean(b.isRelated);
|
||||
if (aRelated === bRelated) {
|
||||
return 0;
|
||||
}
|
||||
return aRelated ? -1 : 1;
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../ha-tooltip";
|
||||
|
||||
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
|
||||
|
||||
@@ -12,6 +13,7 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
|
||||
*
|
||||
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
|
||||
* @attr {string} label - Accessible label announced by assistive technology.
|
||||
* @attr {string} message - Optional tooltip body shown on hover/focus.
|
||||
*/
|
||||
@customElement("ha-automation-row-live-test")
|
||||
export class HaAutomationRowLiveTest extends LitElement {
|
||||
@@ -19,6 +21,8 @@ export class HaAutomationRowLiveTest extends LitElement {
|
||||
|
||||
@property() public label = "";
|
||||
|
||||
@property() public message?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
@@ -27,6 +31,9 @@ export class HaAutomationRowLiveTest extends LitElement {
|
||||
tabindex="0"
|
||||
aria-label=${this.label}
|
||||
></div>
|
||||
${this.message
|
||||
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ECElementEvent,
|
||||
LegendComponentOption,
|
||||
LineSeriesOption,
|
||||
TooltipOption,
|
||||
XAXisOption,
|
||||
YAXisOption,
|
||||
} from "echarts/types/dist/shared";
|
||||
@@ -29,22 +30,59 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type {
|
||||
ECOption,
|
||||
HaECOption,
|
||||
HaECSeries,
|
||||
HaECSeriesItem,
|
||||
HaTooltipOption,
|
||||
} from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant, HomeAssistantUI } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
|
||||
|
||||
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
readonly unknown[]
|
||||
>;
|
||||
|
||||
const toEChartsFormatter = (
|
||||
fn: ReturnType<typeof wrapLitTooltipFormatter>
|
||||
): NonNullable<TooltipOption["formatter"]> =>
|
||||
fn as NonNullable<TooltipOption["formatter"]>;
|
||||
|
||||
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
|
||||
const { formatter, ...rest } = tooltip;
|
||||
const next: TooltipOption = { ...rest };
|
||||
if (typeof formatter === "function") {
|
||||
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
|
||||
} else if (formatter !== undefined) {
|
||||
next.formatter = formatter;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
|
||||
if (s.tooltip && typeof s.tooltip.formatter === "function") {
|
||||
return {
|
||||
...s,
|
||||
tooltip: convertHaTooltipFormatter(s.tooltip),
|
||||
} as RawSeriesOption;
|
||||
}
|
||||
return s as RawSeriesOption;
|
||||
};
|
||||
|
||||
export type CustomLegendOption = ECOption["legend"] & {
|
||||
type: "custom";
|
||||
data?: {
|
||||
@@ -66,9 +104,9 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data: ECOption["series"] = [];
|
||||
@property({ attribute: false }) public data: HaECSeries = [];
|
||||
|
||||
@property({ attribute: false }) public options?: ECOption;
|
||||
@property({ attribute: false }) public options?: HaECOption;
|
||||
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
@@ -614,7 +652,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
// Return an array of all IDs associated with the legend item of the primaryId
|
||||
private _getAllIdsFromLegend(
|
||||
options: ECOption | undefined,
|
||||
options: HaECOption | undefined,
|
||||
primaryId: string
|
||||
): string[] {
|
||||
if (!options) return [primaryId];
|
||||
@@ -634,7 +672,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
|
||||
// No known need to remove items at this time.
|
||||
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
|
||||
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
|
||||
if (!options) return;
|
||||
const legend = ensureArray(this.options?.legend || [])[0] as
|
||||
| LegendComponentOption
|
||||
@@ -757,22 +795,34 @@ export class HaChartBase extends LitElement {
|
||||
xAxis,
|
||||
};
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
if (isMobile && options.tooltip) {
|
||||
// mobile charts are full width so we need to confine the tooltip to the chart
|
||||
const tooltips = Array.isArray(options.tooltip)
|
||||
? options.tooltip
|
||||
: [options.tooltip];
|
||||
tooltips.forEach((tooltip) => {
|
||||
tooltip.confine = true;
|
||||
tooltip.appendTo = undefined;
|
||||
tooltip.triggerOn = "click";
|
||||
});
|
||||
options.tooltip = tooltips;
|
||||
if (options.tooltip) {
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
|
||||
// back into the caller's options.tooltip reference (callers may cache the
|
||||
// options object via memoizeOne, in which case in-place mutation would
|
||||
// pollute that cache across chart instances).
|
||||
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
|
||||
const next = convertHaTooltipFormatter(tooltip);
|
||||
if (isMobile) {
|
||||
// mobile charts are full width so we need to confine the tooltip to the chart
|
||||
next.confine = true;
|
||||
next.appendTo = undefined;
|
||||
next.triggerOn = "click";
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const haTooltip = options.tooltip;
|
||||
const processedTooltip = Array.isArray(haTooltip)
|
||||
? haTooltip.map(processTooltip)
|
||||
: processTooltip(haTooltip);
|
||||
return {
|
||||
...options,
|
||||
tooltip: processedTooltip,
|
||||
} as ECOption;
|
||||
}
|
||||
return options;
|
||||
return options as ECOption;
|
||||
}
|
||||
|
||||
private _createTheme(style: CSSStyleDeclaration) {
|
||||
@@ -956,30 +1006,16 @@ export class HaChartBase extends LitElement {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
| XAXisOption
|
||||
| undefined;
|
||||
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||
| YAXisOption
|
||||
| undefined;
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
let result = {
|
||||
...s,
|
||||
data,
|
||||
} as HaECSeriesItem;
|
||||
if (data && s.type === "line") {
|
||||
if (yAxis?.type === "log") {
|
||||
// set <=0 values to null so they render as gaps on a log graph
|
||||
return {
|
||||
...s,
|
||||
data: (data as LineSeriesOption["data"])!.map((v) =>
|
||||
Array.isArray(v)
|
||||
? [
|
||||
v[0],
|
||||
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
|
||||
...v.slice(2),
|
||||
]
|
||||
: v
|
||||
),
|
||||
};
|
||||
}
|
||||
if (s.sampling === "minmax") {
|
||||
if ((s as LineSeriesOption).sampling === "minmax") {
|
||||
const minX = xAxis?.min
|
||||
? xAxis.min instanceof Date
|
||||
? xAxis.min.getTime()
|
||||
@@ -994,8 +1030,8 @@ export class HaChartBase extends LitElement {
|
||||
? xAxis.max
|
||||
: undefined
|
||||
: undefined;
|
||||
return {
|
||||
...s,
|
||||
result = {
|
||||
...result,
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
@@ -1003,11 +1039,10 @@ export class HaChartBase extends LitElement {
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
};
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
const name = filterXSS(String(s.name ?? s.id ?? ""));
|
||||
return { ...s, name, data };
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
}
|
||||
@@ -1344,8 +1379,8 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _compareCustomLegendOptions(
|
||||
oldOptions: ECOption | undefined,
|
||||
newOptions: ECOption | undefined
|
||||
oldOptions: HaECOption | undefined,
|
||||
newOptions: HaECOption | undefined
|
||||
): boolean {
|
||||
const oldLegends = ensureArray(
|
||||
oldOptions?.legend || []
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-chart-tooltip-marker")
|
||||
class HaChartTooltipMarker extends LitElement {
|
||||
@property() public color = "";
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public rtl = false;
|
||||
|
||||
protected willUpdate(changed: PropertyValues) {
|
||||
if (changed.has("color")) {
|
||||
this.style.backgroundColor = this.color;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-start: initial;
|
||||
border-radius: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
:host([rtl]) {
|
||||
direction: rtl;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-chart-tooltip-marker": HaChartTooltipMarker;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { GraphSeriesOption } from "echarts/charts";
|
||||
import type { PropertyValues } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public tooltipFormatter?: (
|
||||
params: TopLevelFormatterParams
|
||||
) => string;
|
||||
) => TemplateResult | typeof nothing | null;
|
||||
|
||||
/**
|
||||
* Optional callback that returns additional searchable strings for a node.
|
||||
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(categories?: NetworkData["categories"]): ECOption => ({
|
||||
(categories?: NetworkData["categories"]): HaECOption => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
|
||||
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { NODE_SIZE } from "../trace/hat-graph-const";
|
||||
import "../ha-alert";
|
||||
|
||||
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
|
||||
});
|
||||
|
||||
render() {
|
||||
const options = {
|
||||
const options: HaECOption = {
|
||||
grid: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
|
||||
formatter: this._renderTooltip,
|
||||
appendTo: document.body,
|
||||
},
|
||||
} as ECOption;
|
||||
};
|
||||
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
@@ -103,12 +103,16 @@ export class HaSankeyChart extends LitElement {
|
||||
: data.value;
|
||||
if (data.id) {
|
||||
const node = this.data.nodes.find((n) => n.id === data.id);
|
||||
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
|
||||
return html`<ha-chart-tooltip-marker
|
||||
.color=${String(params.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${node?.label ?? data.id}<br />${value}`;
|
||||
}
|
||||
if (data.source && data.target) {
|
||||
const source = this.data.nodes.find((n) => n.id === data.source);
|
||||
const target = this.data.nodes.find((n) => n.id === data.target);
|
||||
return `${filterXSS(source?.label ?? data.source)} → ${filterXSS(target?.label ?? data.target)}<br>${value}`;
|
||||
return html`${source?.label ?? data.source} →
|
||||
${target?.label ?? data.target}<br />${value}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -5,10 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
|
||||
@@ -25,8 +24,6 @@ export interface SunburstNode {
|
||||
|
||||
@customElement("ha-sunburst-chart")
|
||||
export class HaSunburstChart extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data?: SunburstNode;
|
||||
|
||||
@property({ attribute: false }) public valueFormatter?: (
|
||||
@@ -50,13 +47,13 @@ export class HaSunburstChart extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const options = {
|
||||
const options: HaECOption = {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: this._renderTooltip,
|
||||
appendTo: document.body,
|
||||
},
|
||||
} as ECOption;
|
||||
};
|
||||
|
||||
return html`<ha-chart-base
|
||||
.data=${this._createData(this.data)}
|
||||
@@ -71,7 +68,10 @@ export class HaSunburstChart extends LitElement {
|
||||
const value = this.valueFormatter
|
||||
? this.valueFormatter(data.value)
|
||||
: data.value;
|
||||
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
|
||||
return html`<ha-chart-tooltip-marker
|
||||
.color=${String(params.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${data.name}<br />${value}`;
|
||||
};
|
||||
|
||||
private _createData = memoizeOne(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { nothing, render } from "lit";
|
||||
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
|
||||
|
||||
type WrappedTooltipFormatter = (
|
||||
params: unknown,
|
||||
ticket?: string
|
||||
) => HTMLElement | null;
|
||||
|
||||
export type { WrappedTooltipFormatter };
|
||||
|
||||
const litTooltipFormatterCache = new WeakMap<
|
||||
LitTooltipFormatter | WrappedTooltipFormatter,
|
||||
WrappedTooltipFormatter
|
||||
>();
|
||||
|
||||
export const wrapLitTooltipFormatter = (
|
||||
fn: LitTooltipFormatter | WrappedTooltipFormatter
|
||||
): WrappedTooltipFormatter => {
|
||||
const cached = litTooltipFormatterCache.get(fn);
|
||||
if (cached) return cached;
|
||||
const container = document.createElement("div");
|
||||
// display:contents keeps the wrapper layout-invisible so its children act as
|
||||
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
|
||||
container.style.display = "contents";
|
||||
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
|
||||
const result = (fn as LitTooltipFormatter)(params, ticket);
|
||||
// `nothing` and null/undefined must all suppress the tooltip. Returning
|
||||
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
|
||||
// comment marker behind so echarts would show an empty box; convert it to
|
||||
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
|
||||
if (result === null || result === undefined || result === nothing) {
|
||||
return null;
|
||||
}
|
||||
render(result, container);
|
||||
return container;
|
||||
};
|
||||
litTooltipFormatterCache.set(fn, wrapped);
|
||||
// Idempotent re-wrap: looking up the wrapped fn returns itself.
|
||||
litTooltipFormatterCache.set(wrapped, wrapped);
|
||||
return wrapped;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { VisualMapComponentOption } from "echarts/components";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
@@ -12,8 +12,9 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
getNumberFormatOptions,
|
||||
@@ -24,7 +25,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _datasetToDataIndex: number[] = [];
|
||||
|
||||
@state() private _chartOptions?: ECOption;
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@@ -141,12 +141,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const time = params[0].axisValue;
|
||||
const title =
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>";
|
||||
const title = formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const datapoints: Record<string, any>[] = [];
|
||||
this._chartData.forEach((dataset, index) => {
|
||||
if (
|
||||
@@ -177,52 +176,44 @@ export class StateHistoryChartLine extends LitElement {
|
||||
seriesName: dataset.name,
|
||||
seriesIndex: index,
|
||||
value: lastData,
|
||||
// HTML copied from echarts. May change based on options
|
||||
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
|
||||
color: dataset.color,
|
||||
});
|
||||
});
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
title +
|
||||
datapoints
|
||||
.map((param) => {
|
||||
const entityId = this._entityIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const entry = this.hass.entities[entityId];
|
||||
const stateValue = String(param.value[1]);
|
||||
let value = stateObj
|
||||
? this.hass.formatEntityState(stateObj, stateValue)
|
||||
: `${formatNumber(
|
||||
stateValue,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(undefined, entry)
|
||||
)}${unit}`;
|
||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
value += "<br> ";
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
param.value[0] < data.states[0].last_changed
|
||||
? `${this.hass.localize(
|
||||
"ui.components.history_charts.source_stats"
|
||||
)}`
|
||||
: `${this.hass.localize(
|
||||
"ui.components.history_charts.source_history"
|
||||
)}`;
|
||||
value += source;
|
||||
}
|
||||
|
||||
if (param.seriesName) {
|
||||
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
|
||||
}
|
||||
return `${param.marker} ${value}`;
|
||||
})
|
||||
.join("<br>")
|
||||
);
|
||||
return html`${title}${datapoints.map((param) => {
|
||||
const entityId = this._entityIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const entry = this.hass.entities[entityId];
|
||||
const stateValue = String(param.value[1]);
|
||||
const value = stateObj
|
||||
? this.hass.formatEntityState(stateObj, stateValue)
|
||||
: `${formatNumber(
|
||||
stateValue,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(undefined, entry)
|
||||
)}${unit}`;
|
||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
let statSuffix: TemplateResult | typeof nothing = nothing;
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
param.value[0] < data.states[0].last_changed
|
||||
? this.hass.localize("ui.components.history_charts.source_stats")
|
||||
: this.hass.localize("ui.components.history_charts.source_history");
|
||||
// Five non-breaking spaces indent the source label.
|
||||
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
|
||||
}
|
||||
return html`<br /><ha-chart-tooltip-marker
|
||||
.color=${String(param.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${param.seriesName
|
||||
? html`${param.seriesName}: `
|
||||
: nothing}${value}${statSuffix}`;
|
||||
})}`;
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
ECElementEvent,
|
||||
TooltipFormatterCallback,
|
||||
TooltipPositionCallbackParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
@@ -15,8 +14,9 @@ import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _chartData: CustomSeriesOption[] = [];
|
||||
|
||||
@state() private _chartOptions?: ECOption;
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.data=${this._chartData as ECOption["series"]}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
@@ -132,42 +132,35 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
return rect;
|
||||
};
|
||||
|
||||
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
|
||||
(params: TooltipPositionCallbackParams) => {
|
||||
const { value, name, marker, seriesName, color } = Array.isArray(params)
|
||||
? params[0]
|
||||
: params;
|
||||
const title = seriesName
|
||||
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||
: "";
|
||||
const durationInMs = value![2] - value![1];
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
|
||||
const { value, name, seriesName, color } = Array.isArray(params)
|
||||
? params[0]
|
||||
: params;
|
||||
const durationInMs = value![2] - value![1];
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
|
||||
const markerLocalized = !computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? marker
|
||||
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
|
||||
|
||||
const lines = [
|
||||
markerLocalized + name,
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![1]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![2]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formattedDuration,
|
||||
].join("<br>");
|
||||
return [title, lines].join("");
|
||||
};
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`${seriesName
|
||||
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||
: nothing}<ha-chart-tooltip-marker
|
||||
.color=${String(color ?? "")}
|
||||
.rtl=${rtl}
|
||||
></ha-chart-tooltip-marker
|
||||
>${name}<br />${formatDateTimeWithSeconds(
|
||||
new Date(value![1]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}<br />${formatDateTimeWithSeconds(
|
||||
new Date(value![2]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}<br />${formattedDuration}`;
|
||||
};
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ZRColor,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -34,12 +34,13 @@ import {
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
|
||||
@@ -126,7 +127,7 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@state() private _statisticIds: string[] = [];
|
||||
|
||||
@state() private _chartOptions?: ECOption;
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
|
||||
@state() private _hiddenStats = new Set<string>();
|
||||
|
||||
@@ -251,91 +252,101 @@ export class StatisticsChart extends LitElement {
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
return params
|
||||
.map((param, index: number) => {
|
||||
if (rendered[param.seriesIndex]) return "";
|
||||
rendered[param.seriesIndex] = true;
|
||||
const rows: {
|
||||
time?: string;
|
||||
color: string;
|
||||
seriesName?: string;
|
||||
value: string;
|
||||
}[] = [];
|
||||
for (const param of params) {
|
||||
if (rendered[param.seriesIndex]) continue;
|
||||
rendered[param.seriesIndex] = true;
|
||||
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
let rawValue: string;
|
||||
let rawTime: string;
|
||||
if (chartIsBar) {
|
||||
// For bar charts value is always second value.
|
||||
rawValue = String(param.value[1]);
|
||||
// Time value is third value (un-shifted date) if given, otherwise first value
|
||||
let startTime: Date;
|
||||
let endTime: Date | undefined;
|
||||
if (param.value[2]) {
|
||||
startTime = new Date(param.value[2]);
|
||||
if (param.value[3]) {
|
||||
endTime = new Date(param.value[3]);
|
||||
}
|
||||
} else {
|
||||
startTime = new Date(param.value[0]);
|
||||
}
|
||||
if (
|
||||
period === "year" ||
|
||||
period === "month" ||
|
||||
period === "week" ||
|
||||
period === "day"
|
||||
) {
|
||||
// For year/month/day periods, show only the date
|
||||
rawTime =
|
||||
formatDate(startTime, this.hass.locale, this.hass.config) +
|
||||
(endTime && period !== "day"
|
||||
? ` – ${formatDate(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
} else {
|
||||
// For other time periods, include time in render, and optionally show range
|
||||
// if we have an end time.
|
||||
rawTime =
|
||||
formatDateTimeWithSeconds(
|
||||
startTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) +
|
||||
(endTime
|
||||
? ` – ${formatTimeWithSeconds(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
let rawValue: string;
|
||||
let rawTime: string;
|
||||
if (chartIsBar) {
|
||||
// For bar charts value is always second value.
|
||||
rawValue = String(param.value[1]);
|
||||
// Time value is third value (un-shifted date) if given, otherwise first value
|
||||
let startTime: Date;
|
||||
let endTime: Date | undefined;
|
||||
if (param.value[2]) {
|
||||
startTime = new Date(param.value[2]);
|
||||
if (param.value[3]) {
|
||||
endTime = new Date(param.value[3]);
|
||||
}
|
||||
} else {
|
||||
// For lines max series can have 3 values, as the second value is the max-min to form a band
|
||||
rawValue = String(param.value[2] ?? param.value[1]);
|
||||
// Time value is always first value
|
||||
rawTime = `${formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)} <br>`;
|
||||
startTime = new Date(param.value[0]);
|
||||
}
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
const value = `${formatNumber(
|
||||
rawValue,
|
||||
if (
|
||||
period === "year" ||
|
||||
period === "month" ||
|
||||
period === "week" ||
|
||||
period === "day"
|
||||
) {
|
||||
// For year/month/day periods, show only the date
|
||||
rawTime =
|
||||
formatDate(startTime, this.hass.locale, this.hass.config) +
|
||||
(endTime && period !== "day"
|
||||
? ` – ${formatDate(endTime, this.hass.locale, this.hass.config)}`
|
||||
: "");
|
||||
} else {
|
||||
// For other time periods, include time in render, and optionally show range
|
||||
// if we have an end time.
|
||||
rawTime =
|
||||
formatDateTimeWithSeconds(
|
||||
startTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) +
|
||||
(endTime
|
||||
? ` – ${formatTimeWithSeconds(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "");
|
||||
}
|
||||
} else {
|
||||
// For lines max series can have 3 values, as the second value is the max-min to form a band
|
||||
rawValue = String(param.value[2] ?? param.value[1]);
|
||||
// Time value is always first value
|
||||
rawTime = formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
options
|
||||
)}${unit}`;
|
||||
this.hass.config
|
||||
);
|
||||
}
|
||||
|
||||
const time = index === 0 ? rawTime : "";
|
||||
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("<br>");
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
|
||||
|
||||
rows.push({
|
||||
time: rows.length === 0 ? rawTime : undefined,
|
||||
color: String(param.color ?? ""),
|
||||
seriesName: param.seriesName,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return nothing;
|
||||
|
||||
return html`${rows.map(
|
||||
(row, i) =>
|
||||
html`${row.time
|
||||
? html`${row.time}<br />`
|
||||
: nothing}<ha-chart-tooltip-marker
|
||||
.color=${row.color}
|
||||
></ha-chart-tooltip-marker>
|
||||
${row.seriesName}:
|
||||
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
|
||||
)}`;
|
||||
};
|
||||
|
||||
private _createOptions() {
|
||||
|
||||
@@ -107,17 +107,15 @@ export class HaDevicePicker extends LitElement {
|
||||
excludeDevices?: string[],
|
||||
value?: string
|
||||
) =>
|
||||
getDevices(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
getDevices(this.hass, configEntryLookup, {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value
|
||||
)
|
||||
value,
|
||||
})
|
||||
);
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
|
||||
|
||||
@@ -309,7 +309,29 @@ export class HaEntityPicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeDeviceClasses?: string[],
|
||||
includeUnitOfMeasurement?: string[],
|
||||
includeEntities?: string[],
|
||||
excludeEntities?: string[],
|
||||
value?: string
|
||||
) =>
|
||||
getEntities(hass, {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
})
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
const items = this._getEntitiesMemoized(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
|
||||
import {
|
||||
mdiChartLine,
|
||||
mdiHelpCircleOutline,
|
||||
mdiPencil,
|
||||
mdiShape,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
@@ -53,6 +58,16 @@ const SEARCH_KEYS = [
|
||||
{ name: "id", weight: 2 },
|
||||
];
|
||||
|
||||
export interface StatisticElementChangedEvent {
|
||||
statisticId: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"edit-statistics-element": StatisticElementChangedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-statistic-picker")
|
||||
export class HaStatisticPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -130,6 +145,8 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
(!this.hasUpdated && !this.statisticIds) ||
|
||||
@@ -341,6 +358,15 @@ export class HaStatisticPicker extends LitElement {
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${this.canEdit
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
.value=${statisticId}
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
.path=${mdiPencil}
|
||||
@click=${this._editItem}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -350,6 +376,12 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
|
||||
|
||||
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
|
||||
ev.stopPropagation();
|
||||
const statisticId = (ev.currentTarget as any).value;
|
||||
fireEvent(this, "edit-statistics-element", { statisticId });
|
||||
}
|
||||
|
||||
private _computeItem(statisticId: string): StatisticComboBoxItem {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ValueChangedEvent, HomeAssistant } from "../../types";
|
||||
import "./ha-statistic-picker";
|
||||
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
|
||||
|
||||
@customElement("ha-statistics-picker")
|
||||
class HaStatisticsPicker extends LitElement {
|
||||
@@ -59,6 +60,8 @@ class HaStatisticsPicker extends LitElement {
|
||||
})
|
||||
public ignoreRestrictionsOnFirstStatistic = false;
|
||||
|
||||
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
@@ -99,7 +102,9 @@ class HaStatisticsPicker extends LitElement {
|
||||
.statisticIds=${this.statisticIds}
|
||||
.excludeStatistics=${this.value}
|
||||
.allowCustomEntity=${this.allowCustomEntity}
|
||||
.canEdit=${this.canEdit}
|
||||
@value-changed=${this._statisticChanged}
|
||||
@edit-statistics-element=${this._editItem}
|
||||
></ha-statistic-picker>
|
||||
</div>
|
||||
`
|
||||
@@ -122,6 +127,17 @@ class HaStatisticsPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
|
||||
const statisticId = ev.detail.statisticId;
|
||||
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
|
||||
fireEvent(this, "edit-detail-element", {
|
||||
subElementConfig: {
|
||||
index,
|
||||
type: "row",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private get _currentStatistics() {
|
||||
return this.value || [];
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ class StateInfo extends LitElement {
|
||||
)}:
|
||||
</span>
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
@@ -55,7 +54,6 @@ class StateInfo extends LitElement {
|
||||
)}:
|
||||
</span>
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_updated}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
@@ -63,7 +61,6 @@ class StateInfo extends LitElement {
|
||||
</ha-tooltip>
|
||||
<ha-relative-time
|
||||
id="relative-time"
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { addDays, differenceInMilliseconds, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { absoluteTime } from "../common/datetime/absolute_time";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
|
||||
const SAFE_MARGIN = 5 * 1000;
|
||||
|
||||
@customElement("ha-absolute-time")
|
||||
class HaAbsoluteTime extends ReactiveElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public datetime?: string | Date;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
private _timeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -62,13 +78,17 @@ class HaAbsoluteTime extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _updateAbsolute(): void {
|
||||
if (!this._i18n || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.datetime) {
|
||||
this.innerHTML = this.hass.localize("ui.components.absolute_time.never");
|
||||
this.innerHTML = this._i18n.localize("ui.components.absolute_time.never");
|
||||
} else {
|
||||
this.innerHTML = absoluteTime(
|
||||
new Date(this.datetime),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
this._i18n.locale,
|
||||
this._config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
mdiInformationOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-icon-button";
|
||||
@@ -39,7 +40,9 @@ class HaAlert extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public dismissable = false;
|
||||
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize?: LocalizeFunc;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -68,7 +71,7 @@ class HaAlert extends LitElement {
|
||||
${this.dismissable
|
||||
? html`<ha-icon-button
|
||||
@click=${this._dismissClicked}
|
||||
.label=${this.localize!("ui.common.dismiss_alert")}
|
||||
.label=${this._localize?.("ui.common.dismiss_alert")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
|
||||
import { createAreaRegistryEntry } from "../data/area/area_registry";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -104,7 +105,29 @@ export class HaAreaPicker extends LitElement {
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private _getAreasMemoized = memoizeOne(getAreas);
|
||||
private _getAreasMemoized = memoizeOne(
|
||||
(
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
haStates: HomeAssistant["states"],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[]
|
||||
) =>
|
||||
getAreas(haAreas, haFloors, haDevices, haEntities, haStates, {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
})
|
||||
);
|
||||
|
||||
// Recompute value renderer when the areas change
|
||||
private _computeValueRenderer = memoizeOne(
|
||||
|
||||
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
separateCheckboxClick = false;
|
||||
|
||||
async onChange(event) {
|
||||
event.stopPropagation();
|
||||
super.onChange(event);
|
||||
fireEvent(this, event.type);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
mdiStateMachine,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import type { HassConfig, Connection } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { configContext, connectionContext } from "../data/context";
|
||||
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@@ -36,12 +39,24 @@ export const CONDITION_ICONS = {
|
||||
|
||||
@customElement("ha-condition-icon")
|
||||
export class HaConditionIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public condition?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<{ config: HassConfig }, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
@transform<{ connection: Connection }, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection?: Connection;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
@@ -51,13 +66,13 @@ export class HaConditionIcon extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this._connection || !this._config) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = conditionIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this._connection,
|
||||
this._config,
|
||||
this.condition
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import type { Blueprints } from "../data/blueprint";
|
||||
@@ -20,6 +22,10 @@ import "./ha-list";
|
||||
export class HaFilterBlueprints extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property() public type?: "automation" | "script";
|
||||
@@ -54,7 +60,7 @@ export class HaFilterBlueprints extends LitElement {
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this.hass.localize("ui.panel.config.blueprint.caption")}
|
||||
${this._localize("ui.panel.config.blueprint.caption")}
|
||||
${this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
subscribeCategoryRegistry,
|
||||
updateCategoryRegistryEntry,
|
||||
} from "../data/category_registry";
|
||||
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { confirmDeleteCategory } from "../panels/config/category/confirm-delete-category";
|
||||
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -199,17 +199,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _deleteCategory(id: string) {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.category.editor.confirm_delete"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.category.editor.confirm_delete_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirm) {
|
||||
if (!(await confirmDeleteCategory(this, this.hass))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -22,6 +24,10 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
|
||||
export class HaFilterVoiceAssistants extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
// the list of selected voiceAssistantIds
|
||||
@property({ attribute: false }) public value: string[] = [];
|
||||
|
||||
@@ -44,9 +50,7 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.dashboard.voice_assistants.main"
|
||||
)}
|
||||
${this._localize("ui.panel.config.dashboard.voice_assistants.main")}
|
||||
${this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
|
||||
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left-start"
|
||||
| "left-end" = "bottom-start";
|
||||
| "left-end" = "bottom";
|
||||
|
||||
/** If set picker shows an add button instead of textbox when value isn't set */
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@@ -121,7 +121,6 @@ export class HaIconPicker extends LitElement {
|
||||
.label=${this.label}
|
||||
.value=${this._value}
|
||||
.searchFn=${this._filterIcons}
|
||||
popover-placement="bottom-start"
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
<slot name="start"></slot>
|
||||
|
||||
@@ -50,7 +50,9 @@ class HaLabel extends LitElement {
|
||||
<div class="container" .id=${this._elementId}>
|
||||
<span class="content">
|
||||
<slot name="icon"></slot>
|
||||
<slot></slot>
|
||||
<span class="label-content">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -113,6 +115,10 @@ class HaLabel extends LitElement {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.label-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:host([dense]) {
|
||||
height: 20px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
@@ -126,6 +132,29 @@ class HaLabel extends LitElement {
|
||||
margin-inline-start: -4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
:host(.text-ellipsis) {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
:host(.text-ellipsis) .container {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:host(.text-ellipsis) span.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
:host(.text-ellipsis) .label-content {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -152,7 +152,6 @@ export class HaLanguagePicker extends LitElement {
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
popover-placement="bottom-end"
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${this.hass?.localize(
|
||||
"ui.components.language-picker.no_languages"
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { parseISO } from "date-fns";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { relativeTime } from "../common/datetime/relative_time";
|
||||
import { capitalizeFirstLetter } from "../common/string/capitalize-first-letter";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import type { HomeAssistantInternationalization } from "../types";
|
||||
|
||||
@customElement("ha-relative-time")
|
||||
class HaRelativeTime extends ReactiveElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public datetime?: string | Date;
|
||||
|
||||
@property({ type: Boolean }) public capitalize = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -57,15 +61,19 @@ class HaRelativeTime extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _updateRelative(): void {
|
||||
if (!this._i18n) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.datetime) {
|
||||
this.innerHTML = this.hass.localize("ui.components.relative_time.never");
|
||||
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
|
||||
} else {
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(date, this.hass.locale);
|
||||
const relTime = relativeTime(date, this._i18n.locale);
|
||||
this.innerHTML = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { internationalizationContext, uiContext } from "../data/context";
|
||||
import "./radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "./radio/ha-radio-group";
|
||||
import "./radio/ha-radio-option";
|
||||
@@ -26,8 +27,6 @@ export interface SelectBoxOption {
|
||||
|
||||
@customElement("ha-select-box")
|
||||
export class HaSelectBox extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public options: SelectBoxOption[] = [];
|
||||
|
||||
@property({ attribute: false }) public value?: string;
|
||||
@@ -40,6 +39,14 @@ export class HaSelectBox extends LitElement {
|
||||
@property({ type: Boolean, attribute: "stacked_image" })
|
||||
public stackedImage = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
protected _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
render() {
|
||||
const maxColumns = this.maxColumns ?? 3;
|
||||
const columns = Math.min(maxColumns, this.options.length);
|
||||
@@ -62,11 +69,11 @@ export class HaSelectBox extends LitElement {
|
||||
const disabled = option.disabled || this.disabled || false;
|
||||
const selected = option.value === this.value;
|
||||
|
||||
const isDark = this.hass?.themes.darkMode || false;
|
||||
const isRTL = this.hass
|
||||
const isDark = this._ui?.themes.darkMode || false;
|
||||
const isRTL = this._i18n
|
||||
? computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
)
|
||||
: false;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { AttributeSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../entity/ha-entity-attribute-picker";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
|
||||
@customElement("ha-selector-attribute")
|
||||
export class HaSelectorAttribute extends LitElement {
|
||||
@@ -27,6 +29,10 @@ export class HaSelectorAttribute extends LitElement {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["context", "filter_entity"] })
|
||||
private _filterEntityStates?: Record<string, HassEntity>;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-entity-attribute-picker
|
||||
@@ -73,7 +79,7 @@ export class HaSelectorAttribute extends LitElement {
|
||||
const entityIds = ensureArray(this.context.filter_entity);
|
||||
|
||||
invalid = !entityIds.some((entityId) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const stateObj = this._filterEntityStates?.[entityId];
|
||||
return (
|
||||
stateObj &&
|
||||
this.value in stateObj.attributes &&
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../../common/translations/localize";
|
||||
import type {
|
||||
AutomationBehavior,
|
||||
AutomationBehaviorConditionMode,
|
||||
AutomationBehaviorSelector,
|
||||
AutomationBehaviorTriggerMode,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-input-helper-text";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
import "../ha-select-box";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
|
||||
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
"any",
|
||||
"each",
|
||||
"first",
|
||||
"last",
|
||||
"all",
|
||||
];
|
||||
|
||||
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
|
||||
|
||||
@customElement("ha-selector-automation_behavior")
|
||||
export class HaSelectorAutomationBehavior extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selector!: AutomationBehaviorSelector;
|
||||
|
||||
@@ -39,6 +40,9 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@consumeLocalize()
|
||||
protected _localize?: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
const { mode } = this.selector.automation_behavior ?? {};
|
||||
const modeKey = mode ?? "trigger";
|
||||
@@ -60,7 +64,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-select-box
|
||||
.hass=${this.hass}
|
||||
.options=${options}
|
||||
.value=${this.value ?? ""}
|
||||
max_columns="1"
|
||||
@@ -95,8 +98,10 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
|
||||
return (
|
||||
this._localize?.(
|
||||
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
|
||||
) || behavior
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-formfield";
|
||||
import "../ha-switch";
|
||||
import "../ha-input-helper-text";
|
||||
|
||||
@customElement("ha-selector-boolean")
|
||||
export class HaBooleanSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public value = false;
|
||||
|
||||
@property() public placeholder?: any;
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { ButtonToggleSelector, SelectOption } from "../../data/selector";
|
||||
import type { HomeAssistant, ToggleButton } from "../../types";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ToggleButton,
|
||||
} from "../../types";
|
||||
import "../ha-button-toggle-group";
|
||||
|
||||
@customElement("ha-selector-button_toggle")
|
||||
export class HaButtonToggleSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public selector!: ButtonToggleSelector;
|
||||
|
||||
@@ -48,11 +60,7 @@ export class HaButtonToggleSelector extends LitElement {
|
||||
|
||||
if (this.selector.button_toggle?.sort) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
a.label,
|
||||
b.label,
|
||||
this.hass.locale.language
|
||||
)
|
||||
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isTemplate } from "../../common/string/has-template";
|
||||
import type { ChooseSelector, Selector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import "../ha-button-toggle-group";
|
||||
import "./ha-selector";
|
||||
|
||||
@@ -28,6 +30,9 @@ export class HaChooseSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@consumeLocalize()
|
||||
protected _localize?: LocalizeFunc;
|
||||
|
||||
@state() public _activeChoice?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
@@ -62,7 +67,7 @@ export class HaChooseSelector extends LitElement {
|
||||
.buttons=${this._toggleButtons(
|
||||
this.selector.choose.choices,
|
||||
this.selector.choose.translation_key,
|
||||
this.hass.localize
|
||||
this._localize
|
||||
)}
|
||||
.active=${this._activeChoice}
|
||||
@value-changed=${this._choiceChanged}
|
||||
@@ -83,7 +88,7 @@ export class HaChooseSelector extends LitElement {
|
||||
(
|
||||
choices: ChooseSelector["choose"]["choices"],
|
||||
translationKey?: string,
|
||||
_localize?: HomeAssistant["localize"]
|
||||
_localize?: LocalizeFunc
|
||||
) =>
|
||||
Object.keys(choices).map((choice) => ({
|
||||
label:
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import type { DateSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import "../ha-date-input";
|
||||
import type { HaDateInput } from "../ha-date-input";
|
||||
|
||||
@customElement("ha-selector-date")
|
||||
export class HaDateSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public selector!: DateSelector;
|
||||
|
||||
@@ -31,7 +40,7 @@ export class HaDateSelector extends LitElement {
|
||||
return html`
|
||||
<ha-date-input
|
||||
.label=${this.label}
|
||||
.locale=${this.hass.locale}
|
||||
.locale=${this._locale}
|
||||
.disabled=${this.disabled}
|
||||
.value=${typeof this.value === "string" ? this.value : undefined}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import type { DateTimeSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import "../ha-date-input";
|
||||
import type { HaDateInput } from "../ha-date-input";
|
||||
import "../ha-time-input";
|
||||
@@ -11,7 +15,12 @@ import type { HaTimeInput } from "../ha-time-input";
|
||||
|
||||
@customElement("ha-selector-datetime")
|
||||
export class HaDateTimeSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public selector!: DateTimeSelector;
|
||||
|
||||
@@ -41,7 +50,7 @@ export class HaDateTimeSelector extends LitElement {
|
||||
<div class="input">
|
||||
<ha-date-input
|
||||
.label=${this.label}
|
||||
.locale=${this.hass.locale}
|
||||
.locale=${this._locale}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.value=${values?.[0]}
|
||||
@@ -51,7 +60,7 @@ export class HaDateTimeSelector extends LitElement {
|
||||
<ha-time-input
|
||||
enable-second
|
||||
.value=${values?.[1] || "00:00:00"}
|
||||
.locale=${this.hass.locale}
|
||||
.locale=${this._locale}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
@value-changed=${this._valueChanged}
|
||||
|
||||
@@ -2,14 +2,11 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: DurationSelector;
|
||||
|
||||
@property({ attribute: false }) public value?:
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { removeFile, uploadFile } from "../../data/file_upload";
|
||||
import type { FileSelector } from "../../data/selector";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import "../ha-file-upload";
|
||||
|
||||
@customElement("ha-selector-file")
|
||||
@@ -25,6 +27,9 @@ export class HaFileSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@consumeLocalize()
|
||||
protected _localize?: LocalizeFunc;
|
||||
|
||||
@state() private _filename?: { fileId: string; name: string };
|
||||
|
||||
@state() private _busy = false;
|
||||
@@ -42,7 +47,7 @@ export class HaFileSelector extends LitElement {
|
||||
.uploading=${this._busy}
|
||||
.value=${this.value
|
||||
? this._filename?.name ||
|
||||
this.hass.localize("ui.components.selectors.file.unknown_file")
|
||||
this._localize!("ui.components.selectors.file.unknown_file")
|
||||
: undefined}
|
||||
@file-picked=${this._uploadFile}
|
||||
@change=${this._removeFile}
|
||||
@@ -72,7 +77,7 @@ export class HaFileSelector extends LitElement {
|
||||
fireEvent(this, "value-changed", { value: fileId });
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize("ui.components.selectors.file.upload_failed", {
|
||||
text: this._localize!("ui.components.selectors.file.upload_failed", {
|
||||
reason: err.message || err,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { getSignedPath } from "../../data/auth";
|
||||
import type { MediaPickedEvent } from "../../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
@@ -13,14 +12,10 @@ import {
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
import "../ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../ha-form/types";
|
||||
import "../media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import "../ha-picture-upload";
|
||||
@@ -54,8 +49,6 @@ export class HaMediaSelector extends LitElement {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
@state() private _thumbnailUrl?: string | null;
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
@@ -68,35 +61,6 @@ export class HaMediaSelector extends LitElement {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("value")) {
|
||||
const thumbnail = this.value?.metadata?.thumbnail;
|
||||
const oldThumbnail = (changedProps.get("value") as this["value"])
|
||||
?.metadata?.thumbnail;
|
||||
if (thumbnail === oldThumbnail) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail && isBrandUrl(thumbnail)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
this._thumbnailUrl = brandsUrl(
|
||||
{
|
||||
domain: extractDomainFromBrandUrl(thumbnail),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
} else if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else {
|
||||
this._thumbnailUrl = thumbnail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -186,10 +150,12 @@ export class HaMediaSelector extends LitElement {
|
||||
),
|
||||
})}
|
||||
image"
|
||||
style=${this._thumbnailUrl
|
||||
? `background-image: url(${this._thumbnailUrl});`
|
||||
: ""}
|
||||
></div>
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
@@ -410,13 +376,11 @@ export class HaMediaSelector extends LitElement {
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
background-size: contain;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { PeriodKey, PeriodSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -41,6 +42,9 @@ export class HaPeriodSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@consumeLocalize()
|
||||
protected _localize?: LocalizeFunc;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
selectedPeriodKey: PeriodKey | undefined,
|
||||
@@ -78,7 +82,7 @@ export class HaPeriodSelector extends LitElement {
|
||||
const schema = this._schema(
|
||||
typeof data.period === "string" ? (data.period as PeriodKey) : undefined,
|
||||
this.selector,
|
||||
this.hass.localize
|
||||
this._localize!
|
||||
);
|
||||
|
||||
return html`
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "@lit/context";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { SelectOption, SelectSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-checkbox";
|
||||
@@ -25,6 +32,13 @@ import "../radio/ha-radio-option";
|
||||
export class HaSelectSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public selector!: SelectSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
@@ -75,11 +89,7 @@ export class HaSelectSelector extends LitElement {
|
||||
|
||||
if (this.selector.select?.sort) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
a.label,
|
||||
b.label,
|
||||
this.hass.locale.language
|
||||
)
|
||||
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +106,6 @@ export class HaSelectSelector extends LitElement {
|
||||
.value=${this.value as string | undefined}
|
||||
@value-changed=${this._selectChanged}
|
||||
.maxColumns=${this.selector.select?.box_max_columns}
|
||||
.hass=${this.hass}
|
||||
></ha-select-box>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
@@ -168,6 +169,9 @@ export class HaSelectorSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public required = true;
|
||||
|
||||
@consumeLocalize()
|
||||
protected _localize?: LocalizeFunc;
|
||||
|
||||
private _yamlMode = false;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -236,7 +240,7 @@ export class HaSelectorSelector extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
const schema = this._schema(type, this.hass.localize);
|
||||
const schema = this._schema(type, this._localize!);
|
||||
|
||||
return html`<div>
|
||||
<p>${this.label ? this.label : ""}</p>
|
||||
@@ -290,7 +294,7 @@ export class HaSelectorSelector extends LitElement {
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: any): string =>
|
||||
this.hass.localize(
|
||||
this._localize!(
|
||||
`ui.components.selectors.selector.${schema.name}` as LocalizeKeys
|
||||
) || schema.name;
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import type { TimeSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import "../ha-time-input";
|
||||
import type { HaTimeInput } from "../ha-time-input";
|
||||
|
||||
@customElement("ha-selector-time")
|
||||
export class HaTimeSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimeSelector;
|
||||
|
||||
@@ -31,7 +40,7 @@ export class HaTimeSelector extends LitElement {
|
||||
return html`
|
||||
<ha-time-input
|
||||
.value=${typeof this.value === "string" ? this.value : undefined}
|
||||
.locale=${this.hass.locale}
|
||||
.locale=${this._locale}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
clearable
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import type { Connection, HassConfig } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { configContext, connectionContext } from "../data/context";
|
||||
import {
|
||||
DEFAULT_SERVICE_ICON,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
serviceIcon,
|
||||
} from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-service-icon")
|
||||
export class HaServiceIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public service?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<{ config: HassConfig }, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
@transform<{ connection: Connection }, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection?: Connection;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
@@ -28,20 +43,18 @@ export class HaServiceIcon extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this._connection || !this._config) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.service
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
const icon = serviceIcon(this._connection, this._config, this.service).then(
|
||||
(icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
}
|
||||
return this._renderFallback();
|
||||
});
|
||||
);
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Connection, HassConfig } from "home-assistant-js-websocket";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { configContext, connectionContext } from "../data/context";
|
||||
import { serviceSectionIcon } from "../data/icons";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
import { serviceSectionIcon } from "../data/icons";
|
||||
|
||||
@customElement("ha-service-section-icon")
|
||||
export class HaServiceSectionIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public service?: string;
|
||||
|
||||
@property() public section?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<{ config: HassConfig }, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
@transform<{ connection: Connection }, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection?: Connection;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
@@ -25,13 +40,13 @@ export class HaServiceSectionIcon extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this._connection || !this._config) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceSectionIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this._connection,
|
||||
this._config,
|
||||
this.service,
|
||||
this.section
|
||||
).then((icn) => {
|
||||
|
||||
@@ -539,11 +539,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
rtl: isRTL,
|
||||
})}
|
||||
>
|
||||
<ha-user-badge
|
||||
slot="start"
|
||||
.user=${this.hass.user}
|
||||
.hass=${this.hass}
|
||||
></ha-user-badge>
|
||||
<ha-user-badge slot="start" .user=${this.hass.user}></ha-user-badge>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.user ? this.hass.user.name : ""}</span
|
||||
>
|
||||
|
||||
@@ -130,11 +130,56 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _newTarget?: TargetItem;
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(getDevices);
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeDevices?: string[],
|
||||
value?: string,
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getDevices(hass, configEntryLookup, {
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
})
|
||||
);
|
||||
|
||||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeDeviceClasses?: string[],
|
||||
includeUnitOfMeasurement?: string[],
|
||||
includeEntities?: string[],
|
||||
excludeEntities?: string[],
|
||||
value?: string,
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getEntities(hass, {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
idPrefix,
|
||||
})
|
||||
);
|
||||
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
|
||||
@@ -919,7 +964,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
undefined,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
|
||||
@@ -82,7 +82,6 @@ export class HaThemePicker extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
@value-changed=${this._changed}
|
||||
popover-placement="bottom"
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const haTopAppBarFixedStyles = css`
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
transition:
|
||||
box-shadow var(--ha-animation-duration-normal) ease,
|
||||
width var(--ha-animation-duration-normal) ease,
|
||||
padding-left var(--ha-animation-duration-normal) ease,
|
||||
padding-right var(--ha-animation-duration-normal) ease;
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
mdiWeatherSunny,
|
||||
mdiWebhook,
|
||||
} from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import type { Connection, HassConfig } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { configContext, connectionContext } from "../data/context";
|
||||
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@@ -50,12 +53,24 @@ export const TRIGGER_ICONS = {
|
||||
|
||||
@customElement("ha-trigger-icon")
|
||||
export class HaTriggerIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public trigger?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<{ config: HassConfig }, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
@transform<{ connection: Connection }, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection?: Connection;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
@@ -65,20 +80,18 @@ export class HaTriggerIcon extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this._connection || !this._config) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = triggerIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.trigger
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
|
||||
(icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
}
|
||||
return this._renderFallback();
|
||||
});
|
||||
);
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from "../../util/brands-url";
|
||||
|
||||
const SMALL_THUMBNAIL_THRESHOLD = 16;
|
||||
|
||||
const isSvgUrl = (url: string): boolean =>
|
||||
/\.svg(\?|#|$)/i.test(url) || url.startsWith("data:image/svg+xml");
|
||||
|
||||
const resolveThumbnailURL = (
|
||||
hass: HomeAssistant,
|
||||
thumbnailUrl: string
|
||||
): Promise<string> => {
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
return Promise.resolve(
|
||||
brandsUrl(
|
||||
{
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: hass.themes?.darkMode,
|
||||
},
|
||||
hass.auth.data.hassUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
if (thumbnailUrl.startsWith("/")) {
|
||||
// Local thumbnails require authentication; fetch and inline as base64.
|
||||
return hass
|
||||
.fetchWithAuth(thumbnailUrl)
|
||||
.then((response) => response.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(thumbnailUrl);
|
||||
};
|
||||
|
||||
@customElement("ha-media-browser-thumbnail")
|
||||
export class HaMediaBrowserThumbnail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public url?: string;
|
||||
|
||||
@state() private _resolvedUrl?: string;
|
||||
|
||||
@state() private _small = false;
|
||||
|
||||
@state() private _brand = false;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("url")) {
|
||||
this._resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolve(): Promise<void> {
|
||||
this._small = false;
|
||||
this._brand = !!this.url && isBrandUrl(this.url);
|
||||
if (!this.url) {
|
||||
this._resolvedUrl = undefined;
|
||||
return;
|
||||
}
|
||||
const requested = this.url;
|
||||
try {
|
||||
const resolved = await resolveThumbnailURL(this.hass, requested);
|
||||
if (requested !== this.url) return;
|
||||
this._resolvedUrl = resolved;
|
||||
this._probeSize(resolved);
|
||||
} catch (_err) {
|
||||
if (requested === this.url) this._resolvedUrl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _probeSize(url: string): void {
|
||||
// SVGs (including brand icons) scale natively; pixelated rendering would
|
||||
// break vector output.
|
||||
if (this.url && isBrandUrl(this.url)) return;
|
||||
if (isSvgUrl(url)) return;
|
||||
const img = new Image();
|
||||
img.addEventListener("load", () => {
|
||||
if (this._resolvedUrl !== url) return;
|
||||
if (
|
||||
img.naturalWidth > 0 &&
|
||||
img.naturalWidth <= SMALL_THUMBNAIL_THRESHOLD
|
||||
) {
|
||||
this._small = true;
|
||||
}
|
||||
});
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._resolvedUrl) return nothing;
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
image: true,
|
||||
small: this._small,
|
||||
brand: this._brand,
|
||||
})}
|
||||
style="background-image: url(${this._resolvedUrl})"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: var(--ha-media-browser-thumbnail-fit, contain);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.image.brand {
|
||||
background-size: 40%;
|
||||
}
|
||||
.image.small {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-browser-thumbnail": HaMediaBrowserThumbnail;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
@@ -39,11 +38,6 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from "../../util/brands-url";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../entity/ha-entity-picker";
|
||||
import "../ha-alert";
|
||||
@@ -52,6 +46,7 @@ import "../ha-card";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
import "../ha-spinner";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
@@ -411,12 +406,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
: MediaClassBrowserSettings.directory;
|
||||
|
||||
const backgroundImage = currentItem.thumbnail
|
||||
? this._getThumbnailURLorBase64(currentItem.thumbnail).then(
|
||||
(value) => `url(${value})`
|
||||
)
|
||||
: "none";
|
||||
|
||||
return html`
|
||||
${
|
||||
currentItem.can_play
|
||||
@@ -431,13 +420,11 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
<div class="header-content">
|
||||
${currentItem.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="img"
|
||||
style="background-image: ${until(
|
||||
backgroundImage,
|
||||
""
|
||||
)}"
|
||||
>
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
@@ -638,12 +625,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
|
||||
const backgroundImage = child.thumbnail
|
||||
? this._getThumbnailURLorBase64(child.thumbnail).then(
|
||||
(value) => `url(${value})`
|
||||
)
|
||||
: "none";
|
||||
|
||||
return html`
|
||||
<div class="child" .item=${child} @click=${this._childClicked}>
|
||||
<ha-card outlined>
|
||||
@@ -655,10 +636,13 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
"centered-image": ["app", "directory"].includes(
|
||||
child.media_class
|
||||
),
|
||||
"brand-image": isBrandUrl(child.thumbnail),
|
||||
})} image"
|
||||
style="background-image: ${until(backgroundImage, "")}"
|
||||
></div>
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${child.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
@@ -703,13 +687,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
|
||||
const currentItem = this._currentItem;
|
||||
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
|
||||
|
||||
const backgroundImage =
|
||||
mediaClass.show_list_images && child.thumbnail
|
||||
? this._getThumbnailURLorBase64(child.thumbnail).then(
|
||||
(value) => `url(${value})`
|
||||
)
|
||||
: "none";
|
||||
const showImage = mediaClass.show_list_images && !!child.thumbnail;
|
||||
|
||||
return html`
|
||||
<ha-list-item
|
||||
@@ -717,7 +695,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
.item=${child}
|
||||
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
|
||||
>
|
||||
${backgroundImage === "none" && !child.can_play
|
||||
${!showImage && !child.can_play
|
||||
? html`<ha-svg-icon
|
||||
.path=${MediaClassBrowserSettings[
|
||||
child.media_class === "directory"
|
||||
@@ -731,9 +709,14 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
graphic: true,
|
||||
thumbnail: mediaClass.show_list_images === true,
|
||||
})}
|
||||
style="background-image: ${until(backgroundImage, "")}"
|
||||
slot="graphic"
|
||||
>
|
||||
${showImage
|
||||
? html`<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${child.thumbnail}
|
||||
></ha-media-browser-thumbnail>`
|
||||
: nothing}
|
||||
${child.can_play
|
||||
? html`<ha-icon-button
|
||||
class="play ${classMap({
|
||||
@@ -753,51 +736,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private async _getThumbnailURLorBase64(
|
||||
thumbnailUrl: string | undefined
|
||||
): Promise<string> {
|
||||
if (!thumbnailUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
return brandsUrl(
|
||||
{
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
}
|
||||
|
||||
if (thumbnailUrl.startsWith("/")) {
|
||||
// Thumbnails served by local API require authentication
|
||||
return new Promise((resolve, reject) => {
|
||||
this.hass
|
||||
.fetchWithAuth(thumbnailUrl!)
|
||||
// Since we are fetching with an authorization header, we cannot just put the
|
||||
// URL directly into the document; we need to embed the image. We could do this
|
||||
// using blob URLs, but then we would need to keep track of them in order to
|
||||
// release them properly. Instead, we embed the thumbnail using base64.
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
resolve(typeof result === "string" ? result : "");
|
||||
};
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
private _actionClicked = (ev: MouseEvent): void => {
|
||||
ev.stopPropagation();
|
||||
const item = (ev.currentTarget as any).item;
|
||||
@@ -1048,14 +986,20 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.header-content .img {
|
||||
position: relative;
|
||||
height: 175px;
|
||||
width: 175px;
|
||||
margin-right: 16px;
|
||||
background-size: cover;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.4s,
|
||||
height 0.4s;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.header-content .img ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.header-info {
|
||||
display: flex;
|
||||
@@ -1191,18 +1135,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
|
||||
.centered-image {
|
||||
margin: 0 8px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.brand-image {
|
||||
background-size: 40%;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
|
||||
.children ha-card .icon-holder {
|
||||
@@ -1278,17 +1216,21 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
ha-list-item .graphic {
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
ha-list-item .graphic ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
ha-list-item .graphic .play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
|
||||
@@ -99,6 +99,8 @@ export class HaRadioOption extends Radio {
|
||||
--ha-radio-option-checked-background-color,
|
||||
var(--ha-color-fill-primary-normal-resting)
|
||||
);
|
||||
color: var(--checked-icon-color);
|
||||
border-color: var(--checked-icon-color);
|
||||
}
|
||||
|
||||
[part~="label"] {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./hat-logbook-note";
|
||||
@@ -17,6 +19,9 @@ export class HaTraceLogbook extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public logbookEntries!: LogbookEntry[];
|
||||
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return this.logbookEntries.length
|
||||
? html`
|
||||
@@ -26,13 +31,10 @@ export class HaTraceLogbook extends LitElement {
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note
|
||||
.hass=${this.hass}
|
||||
.domain=${this.trace.domain}
|
||||
></hat-logbook-note>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
: html`<div class="padded-box">
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
"ui.panel.config.automation.trace.path.no_logbook_entries"
|
||||
)}
|
||||
</div>`;
|
||||
|
||||
@@ -374,10 +374,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note
|
||||
.hass=${this.hass}
|
||||
.domain=${this.trace.domain}
|
||||
></hat-logbook-note>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
: html`<div class="padded-box">
|
||||
${this.hass!.localize(
|
||||
|
||||
@@ -28,10 +28,7 @@ export class HaTraceTimeline extends LitElement {
|
||||
allow-pick
|
||||
>
|
||||
</hat-trace-timeline>
|
||||
<hat-logbook-note
|
||||
.hass=${this.hass}
|
||||
.domain=${this.trace.domain}
|
||||
></hat-logbook-note>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { css, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
@customElement("hat-logbook-note")
|
||||
class HatLogbookNote extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public domain: "automation" | "script" = "automation";
|
||||
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
render() {
|
||||
if (this.domain === "script") {
|
||||
return this.hass.localize(
|
||||
return this._localize(
|
||||
"ui.panel.config.automation.trace.messages.not_all_entries_are_related_script_note"
|
||||
);
|
||||
}
|
||||
return this.hass.localize(
|
||||
return this._localize(
|
||||
"ui.panel.config.automation.trace.messages.not_all_entries_are_related_automation_note"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { BasePerson } from "../../data/person";
|
||||
import { computeUserInitials } from "../../data/user";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { connectionContext } from "../../data/context";
|
||||
|
||||
@customElement("ha-person-badge")
|
||||
class PersonBadge extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public person?: BasePerson;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this.person) {
|
||||
return nothing;
|
||||
@@ -19,10 +22,10 @@ class PersonBadge extends LitElement {
|
||||
|
||||
const picture = this.person.picture;
|
||||
|
||||
if (picture) {
|
||||
if (picture && this._connection) {
|
||||
return html`<div
|
||||
style=${styleMap({
|
||||
backgroundImage: `url(${this.hass.hassUrl(picture)})`,
|
||||
backgroundImage: `url(${this._connection.hassUrl(picture)})`,
|
||||
})}
|
||||
class="picture"
|
||||
></div>`;
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import type { User } from "../../data/user";
|
||||
import { computeUserInitials } from "../../data/user";
|
||||
import type { CurrentUser, HomeAssistant } from "../../types";
|
||||
import { connectionContext, statesContext } from "../../data/context";
|
||||
import type { CurrentUser } from "../../types";
|
||||
|
||||
@customElement("ha-user-badge")
|
||||
class UserBadge extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public user?: User | CurrentUser;
|
||||
|
||||
@state() private _personPicture?: string;
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
private _personEntityId?: string;
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states?: HassEntities;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
@state() private _personEntityId?: string;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["_personEntityId"] })
|
||||
private _personState?: HassEntity;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("user")) {
|
||||
this._getPersonPicture();
|
||||
return;
|
||||
}
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
// Re-scan for the user's person entity when the user changes, or when the
|
||||
// states change while we don't have a (still-present) person entity. Once
|
||||
// resolved, `_personState` keeps the picture up to date via
|
||||
// `consumeEntityState`, so there's no need to rescan on every state update.
|
||||
if (
|
||||
this._personEntityId &&
|
||||
oldHass &&
|
||||
this.hass.states[this._personEntityId] !==
|
||||
oldHass.states[this._personEntityId]
|
||||
changedProps.has("user") ||
|
||||
(changedProps.has("_states") &&
|
||||
(!this._personEntityId || !this._states?.[this._personEntityId]))
|
||||
) {
|
||||
const entityState = this.hass.states[this._personEntityId];
|
||||
if (entityState) {
|
||||
this._personPicture = entityState.attributes.entity_picture;
|
||||
} else {
|
||||
this._getPersonPicture();
|
||||
}
|
||||
} else if (!this._personEntityId && oldHass) {
|
||||
this._getPersonPicture();
|
||||
this._updatePersonEntityId();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this.user) {
|
||||
if (!this.user) {
|
||||
return nothing;
|
||||
}
|
||||
const picture = this._personPicture;
|
||||
const picture =
|
||||
this._personEntityId &&
|
||||
(this._personState?.attributes.entity_picture as string | undefined);
|
||||
|
||||
if (picture) {
|
||||
if (picture && this._connection) {
|
||||
return html`<div
|
||||
style=${styleMap({
|
||||
backgroundImage: `url(${this.hass.hassUrl(picture)})`,
|
||||
backgroundImage: `url(${this._connection.hassUrl(picture)})`,
|
||||
})}
|
||||
class="picture"
|
||||
></div>`;
|
||||
@@ -64,20 +69,18 @@ class UserBadge extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _getPersonPicture() {
|
||||
private _updatePersonEntityId() {
|
||||
this._personEntityId = undefined;
|
||||
this._personPicture = undefined;
|
||||
if (!this.hass || !this.user) {
|
||||
if (!this.user || !this._states) {
|
||||
return;
|
||||
}
|
||||
for (const entity of Object.values(this.hass.states)) {
|
||||
for (const entity of Object.values(this._states)) {
|
||||
if (
|
||||
entity.attributes.user_id === this.user.id &&
|
||||
computeStateDomain(entity) === "person"
|
||||
) {
|
||||
this._personEntityId = entity.entity_id;
|
||||
this._personPicture = entity.attributes.entity_picture;
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +64,7 @@ class HaUserPicker extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-user-badge
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.user=${user}
|
||||
></ha-user-badge>
|
||||
<ha-user-badge slot="start" .user=${user}></ha-user-badge>
|
||||
<span slot="headline">${user.name}</span>
|
||||
`;
|
||||
};
|
||||
@@ -94,11 +90,7 @@ class HaUserPicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
<ha-user-badge
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.user=${item.user}
|
||||
></ha-user-badge>
|
||||
<ha-user-badge slot="start" .user=${item.user}></ha-user-badge>
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
@@ -15,20 +15,33 @@ import {
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../entity/entity_registry";
|
||||
|
||||
export interface GetAreasOptions {
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
includeDeviceClasses?: string[];
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
excludeAreas?: string[];
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export const getAreas = (
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
haStates: HomeAssistant["states"],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
idPrefix = ""
|
||||
options?: GetAreasOptions
|
||||
): PickerComboBoxItem[] => {
|
||||
const {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
idPrefix = "",
|
||||
} = options ?? {};
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type {
|
||||
Connection,
|
||||
HassEntityAttributeBase,
|
||||
@@ -96,7 +95,7 @@ export interface TriggerList {
|
||||
|
||||
export interface BaseTrigger {
|
||||
alias?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
/** @deprecated Use `trigger` instead */
|
||||
platform?: string;
|
||||
trigger: string;
|
||||
@@ -242,7 +241,7 @@ export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
|
||||
interface BaseCondition {
|
||||
condition: string;
|
||||
alias?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
enabled?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
@@ -486,17 +485,28 @@ export const migrateAutomationTrigger = (
|
||||
}
|
||||
delete trigger.platform;
|
||||
}
|
||||
|
||||
if ("options" in trigger) {
|
||||
if (trigger.options && "behavior" in trigger.options) {
|
||||
if (trigger.options.behavior === "any") {
|
||||
trigger.options.behavior = "each";
|
||||
} else if (trigger.options.behavior === "last") {
|
||||
trigger.options.behavior = "all";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trigger;
|
||||
};
|
||||
|
||||
export const flattenTriggers = (
|
||||
triggers: undefined | Trigger | Trigger[]
|
||||
): Exclude<Trigger, TriggerList>[] => {
|
||||
): Trigger[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flatTriggers: Exclude<Trigger, TriggerList>[] = [];
|
||||
const flatTriggers: Trigger[] = [];
|
||||
|
||||
ensureArray(triggers).forEach((t) => {
|
||||
if ("triggers" in t) {
|
||||
@@ -610,7 +620,7 @@ export interface AutomationClipboard {
|
||||
export interface BaseSidebarConfig {
|
||||
delete: () => void;
|
||||
close: (focus?: boolean) => void;
|
||||
editComment: () => void;
|
||||
editNote: () => void;
|
||||
}
|
||||
|
||||
export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
||||
@@ -672,7 +682,7 @@ export interface OptionSidebarConfig extends BaseSidebarConfig {
|
||||
rename: () => void;
|
||||
duplicate: () => void;
|
||||
defaultOption?: boolean;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
|
||||
@@ -698,7 +708,3 @@ export interface ShowAutomationEditorParams {
|
||||
data?: Partial<AutomationConfig>;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export const automationConfigContext = createContext<
|
||||
AutomationConfig | undefined
|
||||
>("automationConfig");
|
||||
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
LegacyTrigger,
|
||||
Trigger,
|
||||
} from "./automation";
|
||||
import { flattenTriggers } from "./automation";
|
||||
import { getConditionDomain, getConditionObjectId } from "./condition";
|
||||
import type {
|
||||
DeviceCondition,
|
||||
@@ -108,41 +107,6 @@ const formatNumericLimitValue = (
|
||||
: value;
|
||||
};
|
||||
|
||||
export interface TriggerInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
triggerType: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const getTriggerInfos = (
|
||||
triggers: Trigger[] | undefined,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
): TriggerInfo[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
const map = new Map<string, TriggerInfo>();
|
||||
for (const t of flattenTriggers(triggers)) {
|
||||
if (isTriggerList(t) || !t.id) {
|
||||
continue;
|
||||
}
|
||||
const existing = map.get(t.id);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
map.set(t.id, {
|
||||
id: t.id,
|
||||
label: describeTrigger(t, hass, entityRegistry),
|
||||
triggerType: t.trigger,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const describeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -256,6 +256,7 @@ export const normalizeSubscriptionEventData = (
|
||||
dtstart: eventStart,
|
||||
dtend: eventEnd,
|
||||
description: eventData.description ?? undefined,
|
||||
location: eventData.location ?? undefined,
|
||||
uid: eventData.uid ?? undefined,
|
||||
recurrence_id: eventData.recurrence_id ?? undefined,
|
||||
rrule: eventData.rrule ?? undefined,
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export interface CategoryRegistryEntry {
|
||||
export interface CategoryRegistryEntry extends RegistryEntry {
|
||||
category_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
|
||||
@@ -40,7 +40,6 @@ export const createConfigFlow = (
|
||||
"config/config_entries/flow",
|
||||
{
|
||||
handler,
|
||||
show_advanced_options: Boolean(hass.userData?.showAdvanced),
|
||||
entry_id,
|
||||
},
|
||||
HEADERS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
@@ -10,10 +11,12 @@ import type {
|
||||
HomeAssistantRegistries,
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import type { RelatedIdSets } from "../../common/search/related-context";
|
||||
import type { ConfigEntry } from "../config_entries";
|
||||
import type { EntityRegistryEntry } from "../entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../integration";
|
||||
import type { LabelRegistryEntry } from "../label/label_registry";
|
||||
import type { ItemType } from "../search";
|
||||
|
||||
/**
|
||||
* Entity, device, area, and floor registries
|
||||
@@ -94,6 +97,11 @@ export const areasContext = createContext<HomeAssistant["areas"]>("areas");
|
||||
*/
|
||||
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
|
||||
|
||||
/**
|
||||
* Whether the main Home Assistant viewport is using the narrow layout.
|
||||
*/
|
||||
export const narrowViewportContext = createContext<boolean>("narrowViewport");
|
||||
|
||||
// #region lazy-contexts
|
||||
|
||||
/**
|
||||
@@ -162,3 +170,30 @@ export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
|
||||
export const authContext = createContext<HomeAssistant["auth"]>("auth");
|
||||
|
||||
// #endregion deprecated-contexts
|
||||
|
||||
// #region related-context
|
||||
|
||||
export interface RelatedContextItem {
|
||||
itemType: ItemType;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved related entities/devices/areas for the current page context.
|
||||
* Set by `RelatedContextProvider` when a page fires `hass-related-context`.
|
||||
* Cleared on navigation.
|
||||
*/
|
||||
export const relatedContext = createContext<RelatedIdSets | undefined>(
|
||||
"related"
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"hass-related-context": RelatedContextItem | undefined;
|
||||
}
|
||||
interface HTMLElementEventMap {
|
||||
"hass-related-context": HASSDomEvent<RelatedContextItem | undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion related-context
|
||||
|
||||