mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-28 00:33:55 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ea7038fc0 | |||
| 9fc2bfeeed | |||
| e39a09c5ca |
@@ -32,7 +32,7 @@ jobs:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
|
||||
@@ -27,17 +27,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+13
-13
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,17 +26,17 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,23 +52,6 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
currentArtifact = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchTranslations(currentArtifact);
|
||||
} catch (err) {
|
||||
// Local builds should work offline or without valid GitHub credentials,
|
||||
// so fall back to English only. CI must fail instead of silently
|
||||
// building without translations.
|
||||
if (process.env.CI) {
|
||||
throw err;
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings = [];
|
||||
@@ -147,6 +130,11 @@ async function fetchTranslations(currentArtifact) {
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
writings.push(
|
||||
createExtractDir.then(
|
||||
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
|
||||
)
|
||||
);
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
@@ -172,12 +160,7 @@ async function fetchTranslations(currentArtifact) {
|
||||
await new Promise((resolve, reject) => {
|
||||
extractStream.on("close", resolve).on("error", reject);
|
||||
});
|
||||
|
||||
// Record the artifact only after successful extraction, so a failed fetch
|
||||
// is retried by the next build instead of being considered current.
|
||||
await createExtractDir;
|
||||
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
gulp.task(
|
||||
"setup-and-fetch-nightly-translations",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Demo Agent Instructions
|
||||
|
||||
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
|
||||
|
||||
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
|
||||
|
||||
## Running the demo
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
yarn dev:demo # dev server on http://localhost:8090
|
||||
yarn dev:demo --background # detached; also supports --status/--stop/--logs
|
||||
```
|
||||
|
||||
## Opening a specific demo
|
||||
|
||||
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
|
||||
|
||||
## Opening a specific page
|
||||
|
||||
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<slug>#/<path>
|
||||
```
|
||||
|
||||
Useful paths:
|
||||
|
||||
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
|
||||
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
|
||||
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
|
||||
|
||||
Example — the water tab of the energy dashboard of the second demo:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
|
||||
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
|
||||
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
|
||||
- `src/stubs/`: Mocked WebSocket/REST APIs.
|
||||
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -1,9 +1,6 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
@@ -15,53 +12,33 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
arsaboo: () => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
teachingbirds: () =>
|
||||
import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
kernehed: () => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
jimpower: () => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
};
|
||||
|
||||
export const demos = Object.keys(demoConfigs);
|
||||
|
||||
const initialDemo = () => {
|
||||
const slug = new URLSearchParams(window.location.search).get("demo");
|
||||
return slug && demos.includes(slug) ? slug : demos[0];
|
||||
};
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
() => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
() => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
];
|
||||
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemo = initialDemo();
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemo]();
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
|
||||
export const setDemoConfig = async (
|
||||
hass: MockHomeAssistant,
|
||||
lovelace: Lovelace,
|
||||
demo: string
|
||||
index: number
|
||||
) => {
|
||||
const confProm = demoConfigs[demo]();
|
||||
const confProm = demoConfigs[index]();
|
||||
const config = await confProm;
|
||||
|
||||
selectedDemo = demo;
|
||||
selectedDemoConfigIndex = index;
|
||||
selectedDemoConfig = confProm;
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
await lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
// The view of the previous demo might not exist in the new one
|
||||
navigate(`/${hass.panelUrl}?demo=${demo}`, { replace: true });
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { DemoArea } from "../../stubs/area_registry";
|
||||
import type { DemoFloor } from "../../stubs/floor_registry";
|
||||
|
||||
export const demoFloorsHome: DemoFloor[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "upstairs",
|
||||
name: "Upstairs",
|
||||
level: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoAreasHome: DemoArea[] = [
|
||||
{
|
||||
area_id: "living_room",
|
||||
name: "Living room",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:sofa",
|
||||
temperature_entity_id: "sensor.living_room_temperature",
|
||||
humidity_entity_id: "sensor.living_room_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:fridge",
|
||||
temperature_entity_id: "sensor.kitchen_temperature",
|
||||
humidity_entity_id: "sensor.kitchen_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "entrance",
|
||||
name: "Entrance",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:door",
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
name: "Bedroom",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:bed",
|
||||
temperature_entity_id: "sensor.bedroom_temperature",
|
||||
humidity_entity_id: "sensor.bedroom_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "office",
|
||||
name: "Office",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:desk",
|
||||
temperature_entity_id: "sensor.office_temperature",
|
||||
humidity_entity_id: "sensor.office_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:tree",
|
||||
temperature_entity_id: "sensor.garden_temperature",
|
||||
humidity_entity_id: "sensor.garden_humidity",
|
||||
},
|
||||
];
|
||||
@@ -1,465 +0,0 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesHome: DemoConfig["entities"] = () => [
|
||||
// The devices tile on the overview uses zone.home, which always exists in
|
||||
// a real installation.
|
||||
{
|
||||
entity_id: "zone.home",
|
||||
state: "1",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_floor_lamp",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
min_color_temp_kelvin: 2000,
|
||||
max_color_temp_kelvin: 6535,
|
||||
supported_color_modes: ["color_temp", "xy"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 178,
|
||||
color_temp_kelvin: 2583,
|
||||
icon: "mdi:floor-lamp",
|
||||
friendly_name: "Floor lamp",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_spotlights",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 126,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "climate.living_room",
|
||||
state: "heat",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
hvac_modes: ["auto", "heat", "off"],
|
||||
min_temp: 7,
|
||||
max_temp: 35,
|
||||
current_temperature: 21.5,
|
||||
temperature: 21,
|
||||
friendly_name: "Thermostat",
|
||||
supported_features: 385,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.living_room_blinds",
|
||||
state: "open",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "blind",
|
||||
friendly_name: "Blinds",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.living_room_window",
|
||||
state: "off",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.living_room_speaker",
|
||||
state: "playing",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.28,
|
||||
is_volume_muted: false,
|
||||
media_content_type: "music",
|
||||
media_duration: 300,
|
||||
media_position: 0,
|
||||
media_position_updated_at: new Date(
|
||||
new Date().getTime() - 23000
|
||||
).toISOString(),
|
||||
media_title: "I Wasn't Born To Follow",
|
||||
media_artist: "The Byrds",
|
||||
media_album_name: "The Notorious Byrd Brothers",
|
||||
shuffle: false,
|
||||
friendly_name: "Living room speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_temperature",
|
||||
state: "21.5",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_humidity",
|
||||
state: "45",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_spotlights",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 255,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_worktop",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:light-flood-down",
|
||||
friendly_name: "Worktop",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.coffee_machine",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
icon: "mdi:coffee-maker",
|
||||
friendly_name: "Coffee machine",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.kitchen_motion",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.kitchen_speaker",
|
||||
state: "idle",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.18,
|
||||
is_volume_muted: false,
|
||||
friendly_name: "Kitchen speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_temperature",
|
||||
state: "22.1",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_humidity",
|
||||
state: "48",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lock.front_door",
|
||||
state: "locked",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
friendly_name: "Front door",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
device_class: "door",
|
||||
friendly_name: "Front door",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "alarm_control_panel.home_alarm",
|
||||
state: "disarmed",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
changed_by: null,
|
||||
code_arm_required: false,
|
||||
friendly_name: "Home alarm",
|
||||
supported_features: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.entrance_ceiling",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.front_door_lock_battery",
|
||||
state: "88",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Front door lock battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedroom_ceiling",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedside_lamp",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:lamp",
|
||||
friendly_name: "Bedside lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.bedroom_shutter",
|
||||
state: "open",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "shutter",
|
||||
friendly_name: "Shutter",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.bedroom_speaker",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
friendly_name: "Bedroom speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.bedroom_motion",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_motion_battery",
|
||||
state: "15",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Motion sensor battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_temperature",
|
||||
state: "19.6",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_humidity",
|
||||
state: "52",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.office_desk_lamp",
|
||||
state: "on",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 200,
|
||||
icon: "mdi:desk-lamp",
|
||||
friendly_name: "Desk lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.office_ceiling_fan",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
percentage: 0,
|
||||
percentage_step: 25,
|
||||
friendly_name: "Ceiling fan",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.office_window",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_temperature",
|
||||
state: "21.8",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_humidity",
|
||||
state: "50",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garden_sprinkler",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
icon: "mdi:sprinkler-variant",
|
||||
friendly_name: "Sprinkler",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.garden_path",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:outdoor-lamp",
|
||||
friendly_name: "Path lights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.garden_gate",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
device_class: "opening",
|
||||
friendly_name: "Gate",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_temperature",
|
||||
state: "14.2",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_humidity",
|
||||
state: "68",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "weather.home",
|
||||
state: "partlycloudy",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
temperature: 14.2,
|
||||
temperature_unit: "°C",
|
||||
humidity: 68,
|
||||
pressure: 1012,
|
||||
pressure_unit: "hPa",
|
||||
wind_speed: 11.2,
|
||||
wind_speed_unit: "km/h",
|
||||
friendly_name: "Home",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAreasHome, demoFloorsHome } from "./areas";
|
||||
import { demoEntitiesHome } from "./entities";
|
||||
import { demoLovelaceHome } from "./lovelace";
|
||||
|
||||
export const demoHome: DemoConfig = {
|
||||
authorName: "Home Assistant",
|
||||
authorUrl: "https://www.home-assistant.io",
|
||||
name: "Home page",
|
||||
description:
|
||||
"The page you land on when you open Home Assistant, automatically built from the areas in your home and the devices in them.",
|
||||
lovelace: demoLovelaceHome,
|
||||
entities: demoEntitiesHome,
|
||||
floors: demoFloorsHome,
|
||||
areas: demoAreasHome,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import "./strategies";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoLovelaceHome: DemoConfig["lovelace"] = () => ({
|
||||
strategy: {
|
||||
type: "custom:demo-home",
|
||||
favorite_entities: ["lock.front_door", "switch.garden_sprinkler"],
|
||||
},
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../../src/data/lovelace/config/view";
|
||||
import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
HomeDashboardStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home";
|
||||
}
|
||||
|
||||
interface DemoHomeOverviewViewStrategyConfig extends Omit<
|
||||
HomeOverviewViewStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home-overview";
|
||||
}
|
||||
|
||||
class DemoHomeDashboardStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeDashboardStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeDashboardStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceConfig> {
|
||||
const generated = await HomeDashboardStrategy.generate(
|
||||
{ ...config, type: "home" },
|
||||
hass
|
||||
);
|
||||
// Swap the overview view for the demo version, which adds the demo card.
|
||||
return {
|
||||
...generated,
|
||||
views: generated.views.map((view) =>
|
||||
isStrategyView(view) && view.strategy.type === "home-overview"
|
||||
? {
|
||||
...view,
|
||||
strategy: { ...view.strategy, type: "custom:demo-home-overview" },
|
||||
}
|
||||
: view
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeOverviewViewStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeOverviewViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Expand the favorites section so the demo card can be added to it
|
||||
const sections = await Promise.all(
|
||||
(view.sections || []).map(async (section) => {
|
||||
if (
|
||||
!isStrategySection(section) ||
|
||||
section.strategy.type !== "common-controls"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
// The demo card takes up the space of two tiles
|
||||
const limit = (section.strategy.limit as number | undefined) ?? 8;
|
||||
const favorites = await generateLovelaceSectionStrategy(
|
||||
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
|
||||
hass
|
||||
);
|
||||
const [heading, ...cards] = favorites.cards || [];
|
||||
return {
|
||||
...favorites,
|
||||
// Place the demo card first so the tiles fill the rows next to it
|
||||
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...view,
|
||||
sections: sections,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"ll-strategy-dashboard-demo-home",
|
||||
DemoHomeDashboardStrategy
|
||||
);
|
||||
customElements.define(
|
||||
"ll-strategy-view-demo-home-overview",
|
||||
DemoHomeOverviewViewStrategy
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ll-strategy-dashboard-demo-home": DemoHomeDashboardStrategy;
|
||||
"ll-strategy-view-demo-home-overview": DemoHomeOverviewViewStrategy;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import type { DemoArea } from "../stubs/area_registry";
|
||||
import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
@@ -15,9 +13,7 @@ export interface DemoConfig {
|
||||
authorUrl: string;
|
||||
description?:
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
LovelaceCard,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demos,
|
||||
selectedDemo,
|
||||
demoConfigs,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-card")
|
||||
@@ -112,12 +112,16 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
|
||||
this._updateConfig(
|
||||
selectedDemoConfigIndex < demoConfigs.length - 1
|
||||
? selectedDemoConfigIndex + 1
|
||||
: 0
|
||||
);
|
||||
}
|
||||
|
||||
private async _updateConfig(demo: string) {
|
||||
private async _updateConfig(index: number) {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, { demo });
|
||||
fireEvent(this, "set-demo-config" as any, { index });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-control-button";
|
||||
import "../../../src/components/ha-control-button-group";
|
||||
import "../../../src/components/tile/ha-tile-container";
|
||||
import "../../../src/components/tile/ha-tile-icon";
|
||||
import "../../../src/components/tile/ha-tile-info";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { tileCardStyle } from "../../../src/panels/lovelace/cards/tile/tile-card-style";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceGridOptions,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-next-card")
|
||||
export class HADemoNextCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false }) public hass!: MockHomeAssistant;
|
||||
|
||||
@state() private _switching = false;
|
||||
|
||||
public getCardSize() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
min_columns: 6,
|
||||
min_rows: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public setConfig(_config: LovelaceCardConfig) {}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card>
|
||||
<ha-tile-container>
|
||||
<ha-tile-icon slot="icon" icon="mdi:home-assistant"></ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">
|
||||
${until(
|
||||
selectedDemoConfig.then((conf) => conf.name),
|
||||
nothing
|
||||
)}
|
||||
</span>
|
||||
<span slot="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.page-demo.cards.demo.interactive_demo"
|
||||
)}
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
<ha-control-button-group slot="features">
|
||||
<ha-control-button
|
||||
.disabled=${this._switching}
|
||||
@click=${this._nextConfig}
|
||||
>
|
||||
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
|
||||
</ha-control-button>
|
||||
</ha-control-button-group>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, {
|
||||
demo: demos[(demos.indexOf(selectedDemo) + 1) % demos.length],
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
css`
|
||||
:host {
|
||||
--tile-color: var(--primary-color);
|
||||
}
|
||||
ha-control-button-group {
|
||||
--control-button-group-spacing: 0;
|
||||
--control-button-group-thickness: var(--feature-height, 42px);
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-demo-next-card": HADemoNextCard;
|
||||
}
|
||||
}
|
||||
+5
-16
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -14,7 +14,7 @@ import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
@@ -29,7 +29,6 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
@@ -75,18 +74,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried. usage_prediction
|
||||
// is needed for common-controls sections in strategy dashboards.
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
],
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,7 +122,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -178,11 +169,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
// Once config is loaded AND localize, set entities and apply theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,14 @@
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoArea {
|
||||
area_id: string;
|
||||
name: string;
|
||||
floor_id?: string;
|
||||
icon?: string;
|
||||
temperature_entity_id?: string;
|
||||
humidity_entity_id?: string;
|
||||
}
|
||||
|
||||
let areas: AreaRegistryEntry[] = [];
|
||||
|
||||
export const mockAreaRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DemoArea[] = []
|
||||
data: AreaRegistryEntry[] = []
|
||||
) => {
|
||||
hass.mockWS("config/area_registry/list", () => areas);
|
||||
setDemoAreas(hass, data);
|
||||
};
|
||||
|
||||
/** Set the areas of the currently loaded demo config. */
|
||||
export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
areas = demoAreas.map((area) => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
hass.mockWS("config/area_registry/list", () => data);
|
||||
const areas = {};
|
||||
data.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas: areasById });
|
||||
hass.updateHass({ areas });
|
||||
};
|
||||
|
||||
@@ -19,31 +19,17 @@ const pipelines: AssistPipeline[] = [
|
||||
{
|
||||
id: "01local",
|
||||
name: "Local",
|
||||
language: "nl",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "nl",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.faster_whisper",
|
||||
stt_language: "nl",
|
||||
stt_language: "en",
|
||||
tts_engine: "tts.piper",
|
||||
tts_language: "nl",
|
||||
tts_language: "en",
|
||||
tts_voice: null,
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
{
|
||||
id: "01chatgpt",
|
||||
name: "ChatGPT",
|
||||
language: "es",
|
||||
conversation_engine: "conversation.chatgpt",
|
||||
conversation_language: "es",
|
||||
stt_engine: "cloud",
|
||||
stt_language: "es-ES",
|
||||
tts_engine: "cloud",
|
||||
tts_language: "es-ES",
|
||||
tts_voice: "ElviraNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -85,7 +85,7 @@ export const energyEntities = () =>
|
||||
},
|
||||
},
|
||||
"sensor.energy_consumption_tarif_1": {
|
||||
entity_id: "sensor.energy_consumption_tarif_1",
|
||||
entity_id: "sensor.energy_consumption_tarif_1 ",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
|
||||
@@ -1,40 +1,7 @@
|
||||
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoFloor {
|
||||
floor_id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
let floors: FloorRegistryEntry[] = [];
|
||||
|
||||
export const mockFloorRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DemoFloor[] = []
|
||||
) => {
|
||||
hass.mockWS("config/floor_registry/list", () => floors);
|
||||
setDemoFloors(hass, data);
|
||||
};
|
||||
|
||||
/** Set the floors of the currently loaded demo config. */
|
||||
export const setDemoFloors = (
|
||||
hass: MockHomeAssistant,
|
||||
demoFloors: DemoFloor[] = []
|
||||
) => {
|
||||
floors = demoFloors.map((floor) => ({
|
||||
floor_id: floor.floor_id,
|
||||
name: floor.name,
|
||||
level: floor.level ?? null,
|
||||
icon: floor.icon ?? null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const floorsById: Record<string, FloorRegistryEntry> = {};
|
||||
floors.forEach((floor) => {
|
||||
floorsById[floor.floor_id] = floor;
|
||||
});
|
||||
hass.updateHass({ floors: floorsById });
|
||||
};
|
||||
data: FloorRegistryEntry[] = []
|
||||
) => hass.mockWS("config/floor_registry/list", () => data);
|
||||
|
||||
@@ -2,13 +2,12 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
setDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mapEntities } from "./entities";
|
||||
|
||||
export const mockLovelace = (
|
||||
@@ -46,11 +45,11 @@ customElements.whenDefined("hui-root").then(() => {
|
||||
HUIRoot.prototype.firstUpdated = function (changedProperties) {
|
||||
oldFirstUpdated.call(this, changedProperties);
|
||||
this.addEventListener("set-demo-config", async (ev) => {
|
||||
const demo = (ev as CustomEvent).detail.demo;
|
||||
const index = (ev as CustomEvent).detail.index;
|
||||
try {
|
||||
await setDemoConfig(this.hass, this.lovelace!, demo);
|
||||
await setDemoConfig(this.hass, this.lovelace!, index);
|
||||
} catch (_err: any) {
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
|
||||
alert("Failed to switch config :-(");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
|
||||
// Entities that don't exist in the currently loaded demo config are
|
||||
// filtered out by the common-controls section strategy.
|
||||
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
|
||||
entities: [
|
||||
"light.living_room_floor_lamp",
|
||||
"climate.living_room",
|
||||
"cover.living_room_blinds",
|
||||
"media_player.living_room_speaker",
|
||||
"light.kitchen_spotlights",
|
||||
"switch.coffee_machine",
|
||||
"light.bedside_lamp",
|
||||
"alarm_control_panel.home_alarm",
|
||||
],
|
||||
}));
|
||||
};
|
||||
@@ -2,10 +2,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
@@ -13,6 +10,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
|
||||
import "../../../../src/components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
@@ -138,20 +136,45 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: DemoArea[] = [
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: null,
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: null,
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: null,
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import {
|
||||
mockFloorRegistry,
|
||||
type DemoFloor,
|
||||
} from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import {
|
||||
showDialog,
|
||||
@@ -151,43 +147,75 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: DemoArea[] = [
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: "ground",
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: "first",
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: "ground",
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const FLOORS: DemoFloor[] = [
|
||||
const FLOORS: FloorRegistryEntry[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
icon: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "first",
|
||||
name: "First floor",
|
||||
level: 1,
|
||||
icon: "mdi:numeric-1",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "second",
|
||||
name: "Second floor",
|
||||
level: 2,
|
||||
icon: "mdi:numeric-2",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+8
-7
@@ -91,7 +91,7 @@
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
@@ -108,7 +108,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.2",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -150,7 +150,7 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
@@ -183,7 +183,7 @@
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"fs-extra": "11.3.6",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
@@ -196,7 +196,7 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.2.0",
|
||||
"lint-staged": "17.1.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -207,7 +207,7 @@
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.21",
|
||||
"tar": "7.5.20",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
@@ -226,7 +226,8 @@
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
|
||||
+1
-2
@@ -106,8 +106,7 @@
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"minimumGroupSize": 5
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EntityIdPart } from "../../data/entity_id_format";
|
||||
import { slugify } from "../string/slugify";
|
||||
|
||||
export const computeEntityIdFormatExample = (
|
||||
format: EntityIdPart[],
|
||||
examples: Record<EntityIdPart, string>
|
||||
): string => {
|
||||
const parts = format
|
||||
.map((item) => examples[item])
|
||||
.filter(Boolean)
|
||||
.map((part) => slugify(part, "_"))
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.join("_") || "unknown";
|
||||
};
|
||||
+18
-11
@@ -64,15 +64,22 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
if (path.includes("#")) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
@@ -80,10 +87,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
mainWindow.location.hash = path;
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
|
||||
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host([appearance="brand"]) ha-svg-icon {
|
||||
.progress ha-svg-icon {
|
||||
color: var(--white-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -36,10 +36,6 @@ export function downSampleLineData<
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
// a degenerate frame size would put every point in a single frame
|
||||
return data;
|
||||
}
|
||||
|
||||
if (useMean) {
|
||||
// Group points into frames, accumulating sums in insertion order.
|
||||
|
||||
@@ -51,7 +51,6 @@ 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;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -1049,9 +1048,7 @@ export class HaChartBase extends LitElement {
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
this.clientWidth * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
|
||||
);
|
||||
--ha-input-chip-selected-container-opacity: 1;
|
||||
--md-input-chip-label-text-font: Roboto, sans-serif;
|
||||
--md-input-chip-label-text-weight: 400;
|
||||
}
|
||||
/** Set the size of mdc icons **/
|
||||
::slotted([slot="icon"]) {
|
||||
|
||||
@@ -141,7 +141,7 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
|
||||
private _value(choice?: string): any {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
if (!this.value) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object"
|
||||
|
||||
@@ -542,14 +542,20 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
${
|
||||
serviceData && "target" in serviceData
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector>`
|
||||
? html`<ha-settings-row
|
||||
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
|
||||
>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize("ui.components.service-control.target")}</span
|
||||
>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
: entityId
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this.disabled}
|
||||
@@ -1053,14 +1059,6 @@ export class HaServiceControl extends LitElement {
|
||||
display: block;
|
||||
margin: var(--service-control-padding, 0 var(--ha-space-4));
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -1342,7 +1342,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
border: 2px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface ToastClosedEventDetail {
|
||||
export class HaToast extends LitElement {
|
||||
@property({ attribute: "label-text" }) public labelText = "";
|
||||
|
||||
@property({ attribute: "announce-text" }) public announceText?: string;
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
|
||||
@@ -192,6 +190,8 @@ export class HaToast extends LitElement {
|
||||
style=${styleMap({
|
||||
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
@@ -200,14 +200,6 @@ export class HaToast extends LitElement {
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="assistive-message"
|
||||
role="status"
|
||||
aria-live=${this._active ? "polite" : "off"}
|
||||
aria-atomic="true"
|
||||
>
|
||||
${this.announceText ?? this.labelText}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -262,18 +254,6 @@ export class HaToast extends LitElement {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assistive-message {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { grid } from "@lit-labs/virtualizer/layouts/grid";
|
||||
|
||||
import {
|
||||
mdiArrowUpRight,
|
||||
mdiFilterVariant,
|
||||
mdiKeyboard,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import { mdiArrowUpRight, mdiKeyboard, mdiPlay, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
@@ -20,7 +14,6 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
@@ -39,7 +32,6 @@ import {
|
||||
browseLocalMediaPlayer,
|
||||
isManualMediaSourceContentId,
|
||||
MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
searchMedia,
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
@@ -51,14 +43,9 @@ import "../entity/ha-entity-picker";
|
||||
import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-card";
|
||||
import "../ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../input/ha-input-search";
|
||||
import type { HaInputSearch } from "../input/ha-input-search";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
import "../ha-spinner";
|
||||
import "../ha-svg-icon";
|
||||
@@ -87,8 +74,6 @@ export interface MediaPlayerItemId {
|
||||
media_content_type?: string | undefined;
|
||||
}
|
||||
|
||||
type MediaClass = MediaPlayerItem["media_class"];
|
||||
|
||||
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
can_expand: true,
|
||||
can_play: false,
|
||||
@@ -137,16 +122,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
@state() private _currentItem?: MediaPlayerItem;
|
||||
|
||||
@state() private _searchQuery = "";
|
||||
|
||||
@state() private _searchResults?: MediaPlayerItem[];
|
||||
|
||||
@state() private _searching = false;
|
||||
|
||||
@state() private _mediaClassFilter: MediaClass[] = [];
|
||||
|
||||
private _searchRequestId = 0;
|
||||
|
||||
@query(".header") private _header?: HTMLDivElement;
|
||||
|
||||
@query(".content") private _content?: HTMLDivElement;
|
||||
@@ -222,10 +197,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
const oldParentItem = this._parentItem;
|
||||
this._currentItem = undefined;
|
||||
this._parentItem = undefined;
|
||||
this._abortSearch();
|
||||
this._searchQuery = "";
|
||||
this._searchResults = undefined;
|
||||
this._mediaClassFilter = [];
|
||||
const currentId = navigateIds[navigateIds.length - 1];
|
||||
const parentId =
|
||||
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
|
||||
@@ -357,14 +328,11 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("_scrolled") || changedProps.has("_currentItem")) {
|
||||
// Re-measure across frames rather than once: the search input sizes
|
||||
// asynchronously, so a single measurement can be too small and the
|
||||
// content would clip under the header.
|
||||
if (changedProps.has("_scrolled")) {
|
||||
this._animateHeaderHeight();
|
||||
}
|
||||
} else if (changedProps.has("_currentItem")) {
|
||||
this._setHeaderHeight();
|
||||
|
||||
if (changedProps.has("_currentItem")) {
|
||||
// This fixes a race condition for resizing of the cards using the grid layout
|
||||
if (this._observed) {
|
||||
return;
|
||||
@@ -397,15 +365,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
const currentItem = this._currentItem;
|
||||
|
||||
const isSearching = this._searchResults !== undefined;
|
||||
|
||||
const subtitle = this.hass.localize(
|
||||
`ui.components.media-browser.class.${currentItem.media_class}`
|
||||
);
|
||||
let children = isSearching
|
||||
? this._searchResults!
|
||||
: currentItem.children || [];
|
||||
const notShown = isSearching ? 0 : currentItem.not_shown || 0;
|
||||
let children = currentItem.children || [];
|
||||
const canPlayChildren = new Set<string>();
|
||||
|
||||
// Filter children based on accept property if provided
|
||||
@@ -436,26 +399,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Search is available on non-root pages that opt in via can_search, aside
|
||||
// from the manual-entry and TTS pseudo-sources.
|
||||
const showSearch =
|
||||
this.navigateIds.length > 1 &&
|
||||
!isManualMediaSourceContentId(currentItem.media_content_id) &&
|
||||
!isTTSMediaSource(currentItem.media_content_id) &&
|
||||
currentItem.can_search;
|
||||
// The backend reports which media classes are worth filtering by for this
|
||||
// item; without them we still allow searching, just without the filter.
|
||||
const mediaClassFilterOptions =
|
||||
showSearch && currentItem.search_media_classes
|
||||
? [...currentItem.search_media_classes].sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
this._localizeMediaClass(a),
|
||||
this._localizeMediaClass(b),
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
|
||||
const childrenMediaClass = currentItem.children_media_class
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
@@ -463,109 +406,94 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
return html`
|
||||
${
|
||||
currentItem.can_play || showSearch
|
||||
currentItem.can_play
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !currentItem.can_play,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
${
|
||||
showSearch
|
||||
? this._renderSearchRow(
|
||||
currentItem,
|
||||
mediaClassFilterOptions
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
currentItem.can_play
|
||||
? html`<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
? html`
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
))
|
||||
? html`
|
||||
<ha-button
|
||||
class="fab"
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
.title=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="header-info">
|
||||
<div class="breadcrumb">
|
||||
<h1 class="title">${currentItem.title}</h1>
|
||||
<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
? html`
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
subtitle
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
))
|
||||
? html`
|
||||
<h2 class="subtitle">
|
||||
${subtitle}
|
||||
</h2>
|
||||
<ha-button
|
||||
class="fab"
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
.title=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
${
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="header-info">
|
||||
<div class="breadcrumb">
|
||||
<h1 class="title">${currentItem.title}</h1>
|
||||
${
|
||||
subtitle
|
||||
? html`
|
||||
<h2 class="subtitle">${subtitle}</h2>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
${
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
@@ -576,10 +504,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@touchmove=${this._scroll}
|
||||
>
|
||||
${
|
||||
this._searching
|
||||
this._error
|
||||
? html`
|
||||
<div class="container">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-alert alert-type="error">
|
||||
${this._renderError(this._error)}
|
||||
</ha-alert>
|
||||
</div>
|
||||
`
|
||||
: isManualMediaSourceContentId(currentItem.media_content_id)
|
||||
@@ -602,33 +532,29 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@tts-picked=${this._ttsPicked}
|
||||
></ha-browse-media-tts>
|
||||
`
|
||||
: !children.length && !notShown
|
||||
: !children.length && !currentItem.not_shown
|
||||
? html`
|
||||
<div class="container no-items">
|
||||
${
|
||||
isSearching
|
||||
? this.hass.localize(
|
||||
"ui.components.media-browser.search.no_results"
|
||||
currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
)
|
||||
: currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -658,17 +584,17 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
portrait:
|
||||
childrenMediaClass.thumbnail_ratio ===
|
||||
"portrait",
|
||||
not_shown: !!notShown,
|
||||
not_shown: !!currentItem.not_shown,
|
||||
})}"
|
||||
></lit-virtualizer>
|
||||
${
|
||||
notShown
|
||||
currentItem.not_shown
|
||||
? html`
|
||||
<div class="grid not-shown">
|
||||
<div class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: notShown }
|
||||
{ count: currentItem.not_shown }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,7 +614,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
.renderItem=${this._renderListItem}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
notShown
|
||||
currentItem.not_shown
|
||||
? html`
|
||||
<ha-list-item
|
||||
noninteractive
|
||||
@@ -702,7 +628,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
<span class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: notShown }
|
||||
{ count: currentItem.not_shown }
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item>
|
||||
@@ -718,188 +644,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSearchRow(
|
||||
currentItem: MediaPlayerItem,
|
||||
mediaClassFilterOptions: MediaClass[]
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="search-row">
|
||||
${
|
||||
currentItem.can_search
|
||||
? html`
|
||||
<ha-input-search
|
||||
class="search-input"
|
||||
appearance="outlined"
|
||||
.value=${this._searchQuery}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.components.media-browser.search.search_placeholder",
|
||||
{ name: currentItem.title }
|
||||
)}
|
||||
@input=${this._handleSearchInput}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
></ha-input-search>
|
||||
${
|
||||
mediaClassFilterOptions.length
|
||||
? this._renderMediaClassFilter(mediaClassFilterOptions)
|
||||
: nothing
|
||||
}
|
||||
<ha-button
|
||||
class="search-button"
|
||||
appearance="filled"
|
||||
.disabled=${!this._searchQuery.trim()}
|
||||
@click=${this._search}
|
||||
>
|
||||
${this.hass.localize("ui.common.search")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMediaClassFilter(
|
||||
mediaClassFilterOptions: MediaClass[]
|
||||
): TemplateResult {
|
||||
const selectedCount = this._mediaClassFilter.length;
|
||||
return html`
|
||||
<div class="media-class-filter">
|
||||
<ha-dropdown
|
||||
placement="bottom-end"
|
||||
@wa-select=${this._toggleMediaClassFilter}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
class="filter-button ${classMap({ active: selectedCount > 0 })}"
|
||||
.path=${mdiFilterVariant}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.media-browser.filter_media_type"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
${mediaClassFilterOptions.map((mediaClass) => {
|
||||
const selected = this._mediaClassFilter.includes(mediaClass);
|
||||
return html`
|
||||
<ha-dropdown-item
|
||||
.value=${mediaClass}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
type="checkbox"
|
||||
.checked=${selected}
|
||||
>
|
||||
${this._localizeMediaClass(mediaClass)}
|
||||
</ha-dropdown-item>
|
||||
`;
|
||||
})}
|
||||
</ha-dropdown>
|
||||
${
|
||||
selectedCount
|
||||
? html`<div class="filter-badge">${selectedCount}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _localizeMediaClass(mediaClass: MediaClass): string {
|
||||
return (
|
||||
this.hass.localize(`ui.components.media-browser.class.${mediaClass}`) ||
|
||||
mediaClass
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleMediaClassFilter(ev: HaDropdownSelectEvent<MediaClass>): void {
|
||||
ev.preventDefault(); // keep the dropdown open for multi-select
|
||||
const value = ev.detail.item.value;
|
||||
const action = (ev.detail.item as { action?: "add" | "remove" }).action;
|
||||
this._mediaClassFilter =
|
||||
action === "add"
|
||||
? [...this._mediaClassFilter, value]
|
||||
: this._mediaClassFilter.filter((mediaClass) => mediaClass !== value);
|
||||
// Only refine results already on screen; before a search has run, the
|
||||
// filter is just staged for the next Enter/search-button submission.
|
||||
if (this._searchResults !== undefined) {
|
||||
this._search();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSearchInput(ev: InputEvent): void {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this._searchQuery = value;
|
||||
// Searching is explicit (Enter or the search button). Emptying the field —
|
||||
// e.g. via the clear button — returns to the browse view.
|
||||
if (!value) {
|
||||
this._clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSearchKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._search();
|
||||
}
|
||||
}
|
||||
|
||||
private _abortSearch(): void {
|
||||
// Invalidate any in-flight search so a late response is ignored, and clear
|
||||
// the loading state.
|
||||
this._searchRequestId++;
|
||||
this._searching = false;
|
||||
}
|
||||
|
||||
private _clearSearch(): void {
|
||||
this._abortSearch();
|
||||
this._searchQuery = "";
|
||||
this._searchResults = undefined;
|
||||
}
|
||||
|
||||
private async _search(): Promise<void> {
|
||||
const searchQuery = this._searchQuery.trim();
|
||||
if (!searchQuery) {
|
||||
// Nothing to search; drop any stale results but keep the input.
|
||||
this._abortSearch();
|
||||
this._searchResults = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateId = this.navigateIds[this.navigateIds.length - 1];
|
||||
const requestId = ++this._searchRequestId;
|
||||
|
||||
this._searching = true;
|
||||
// Clear previous results so stale data isn't shown while searching or on error
|
||||
this._searchResults = undefined;
|
||||
const mediaFilterClasses = this._mediaClassFilter.length
|
||||
? this._mediaClassFilter
|
||||
: undefined;
|
||||
try {
|
||||
const { result } = await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
// Ignore the response if a newer search started or we navigated away
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
}
|
||||
this._searchResults = result;
|
||||
} catch (err) {
|
||||
// Ignore errors from superseded searches
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
}
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.media-browser.media_browsing_error"
|
||||
),
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
// Only the most recent search controls the loading state
|
||||
if (requestId === this._searchRequestId) {
|
||||
this._searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
|
||||
return html`
|
||||
<div class="child" .item=${child} @click=${this._childClicked}>
|
||||
@@ -1265,8 +1009,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background-color: var(--card-background-color);
|
||||
position: absolute;
|
||||
@@ -1276,56 +1019,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
z-index: 3;
|
||||
padding: 16px;
|
||||
}
|
||||
.header.search-only {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
--ha-input-padding-top: 0;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.search-button {
|
||||
flex: none;
|
||||
}
|
||||
:host([narrow]) .search-row {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.media-class-filter {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
.filter-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.filter-button.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
.header_button {
|
||||
position: relative;
|
||||
right: -8px;
|
||||
|
||||
@@ -8,13 +8,6 @@ import "../ha-expansion-panel";
|
||||
import "../list/ha-list-base";
|
||||
import "./ha-target-picker-item-row";
|
||||
|
||||
const TYPE_PLURAL = {
|
||||
entity: "entities",
|
||||
device: "devices",
|
||||
area: "areas",
|
||||
label: "labels",
|
||||
} as const satisfies Record<TargetTypeFloorless, string>;
|
||||
|
||||
@customElement("ha-target-picker-item-group")
|
||||
export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -67,11 +60,11 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
>
|
||||
<div slot="header" class="heading">
|
||||
${this.hass.localize(
|
||||
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
|
||||
`ui.components.target-picker.selected.${this.type}`,
|
||||
{
|
||||
count,
|
||||
}
|
||||
)}
|
||||
${
|
||||
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
|
||||
}
|
||||
</div>
|
||||
<ha-list-base>
|
||||
${Object.entries(this.items).map(([type, items]) =>
|
||||
@@ -113,10 +106,6 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
justify-content: space-between;
|
||||
min-height: unset;
|
||||
}
|
||||
.count {
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,13 +36,3 @@ export interface DirtyStateContext<
|
||||
* boundary.
|
||||
*/
|
||||
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
isDirtyState?: boolean;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"dirty-state-changed": { isDirty: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,6 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
stat_rate?: string; // always available if power_config is set
|
||||
power_config?: PowerConfig;
|
||||
stat_soc?: string;
|
||||
capacity?: number; // usable capacity in kWh, used to weight the combined SOC
|
||||
name?: string;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HomeAssistantApi } from "../../types";
|
||||
import type { EntityIdFormat } from "../entity_id_format";
|
||||
|
||||
export interface EntityRegistrySettings {
|
||||
entity_id_parts: EntityIdFormat | null;
|
||||
}
|
||||
|
||||
export const fetchEntityRegistrySettings = (
|
||||
api: HomeAssistantApi
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/get",
|
||||
});
|
||||
|
||||
export const updateEntityRegistrySettings = (
|
||||
api: HomeAssistantApi,
|
||||
updates: Partial<EntityRegistrySettings>
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/update",
|
||||
...updates,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EntityIdPart = "area" | "device" | "entity" | "floor";
|
||||
|
||||
export type EntityIdFormat = EntityIdPart[];
|
||||
|
||||
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
];
|
||||
|
||||
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
|
||||
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
|
||||
@@ -186,9 +186,6 @@ export interface MediaPlayerItem {
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
can_search: boolean;
|
||||
search_media_classes?:
|
||||
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
|
||||
| null;
|
||||
thumbnail?: string;
|
||||
iconPath?: string;
|
||||
children?: MediaPlayerItem[];
|
||||
|
||||
@@ -24,23 +24,6 @@ export const browseLocalMediaPlayer = (
|
||||
media_content_id: mediaContentId,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMedia = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId: string | undefined,
|
||||
searchQuery: string,
|
||||
mediaFilterClasses?: string[]
|
||||
): Promise<SearchMediaResult> =>
|
||||
hass.callWS<SearchMediaResult>({
|
||||
type: "media_source/search_media",
|
||||
media_content_id: mediaContentId,
|
||||
search_query: searchQuery,
|
||||
media_filter_classes: mediaFilterClasses,
|
||||
});
|
||||
|
||||
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
|
||||
|
||||
export const isManualMediaSourceContentId = (mediaContentId: string) =>
|
||||
|
||||
@@ -108,7 +108,7 @@ class StepFlowForm extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
step.data_schema.length || this._errors
|
||||
step.data_schema.length
|
||||
? html`<ha-form
|
||||
${ref(this._formRef)}
|
||||
?autofocus=${this.autoFocus}
|
||||
|
||||
@@ -54,7 +54,6 @@ export class HaImagecropperDialog
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (!changedProperties.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,38 +16,6 @@ export const demoPanels: Panels = {
|
||||
config: null,
|
||||
url_path: "home",
|
||||
},
|
||||
light: {
|
||||
component_name: "light",
|
||||
icon: "mdi:lamps",
|
||||
title: "light",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "light",
|
||||
},
|
||||
climate: {
|
||||
component_name: "climate",
|
||||
icon: "mdi:home-thermometer",
|
||||
title: "climate",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "climate",
|
||||
},
|
||||
security: {
|
||||
component_name: "security",
|
||||
icon: "mdi:security",
|
||||
title: "security",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "security",
|
||||
},
|
||||
maintenance: {
|
||||
component_name: "maintenance",
|
||||
icon: "mdi:wrench",
|
||||
title: "maintenance",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "maintenance",
|
||||
},
|
||||
"dev-state": {
|
||||
component_name: "dev-state",
|
||||
icon: null,
|
||||
|
||||
@@ -27,8 +27,6 @@ export class MockBaseEntity {
|
||||
|
||||
public state: string;
|
||||
|
||||
public areaId?: string;
|
||||
|
||||
public baseAttributes: EntityAttributes;
|
||||
|
||||
public attributes: EntityAttributes;
|
||||
@@ -49,7 +47,6 @@ export class MockBaseEntity {
|
||||
this.domain = domain;
|
||||
this.objectId = objectId;
|
||||
this.state = input.state;
|
||||
this.areaId = input.area_id;
|
||||
this.lastChanged = randomTime();
|
||||
this.lastUpdated = randomTime();
|
||||
|
||||
@@ -130,8 +127,6 @@ export class MockBaseEntity {
|
||||
"entity_picture",
|
||||
"assumed_state",
|
||||
"device_class",
|
||||
"state_class",
|
||||
"unit_of_measurement",
|
||||
"supported_features",
|
||||
]) {
|
||||
if (key in attrs) {
|
||||
|
||||
@@ -10,10 +10,7 @@ export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
|
||||
export type EntityInput = Pick<
|
||||
HassEntity,
|
||||
"entity_id" | "state" | "attributes"
|
||||
> & {
|
||||
/** Area the entity is assigned to in the mocked entity registry */
|
||||
area_id?: string;
|
||||
};
|
||||
>;
|
||||
|
||||
/**
|
||||
* The hass mock object interface, kept intentionally loose
|
||||
|
||||
@@ -299,7 +299,6 @@ export const provideHass = (
|
||||
icon: undefined,
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
area_id: ent.areaId,
|
||||
} satisfies EntityRegistryDisplayEntry;
|
||||
});
|
||||
if (replace) {
|
||||
|
||||
@@ -135,14 +135,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
this.update = super.update;
|
||||
// Apps with a native splash screen keep covering the frontend until
|
||||
// frontend/loaded, so the launch screen stays up (invisibly) until the
|
||||
// first panel has rendered and partial-panel-resolver removes it and
|
||||
// fires frontend/loaded.
|
||||
if (
|
||||
!this.hass.auth.external?.config.hasSplashscreen &&
|
||||
removeLaunchScreen()
|
||||
) {
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
// first panel has rendered and partial-panel-resolver removes it.
|
||||
if (!this.hass.auth.external?.config.hasSplashscreen) {
|
||||
removeLaunchScreen();
|
||||
}
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
super.update(changedProps);
|
||||
}
|
||||
@@ -176,7 +173,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
window.addEventListener("location-changed", () => updateRoute());
|
||||
|
||||
// Handle history changes
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
if (useHash) {
|
||||
window.addEventListener("hashchange", () => updateRoute());
|
||||
} else {
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
}
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
|
||||
@@ -220,13 +220,8 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
) {
|
||||
await this.rebuild();
|
||||
await this.pageRendered;
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again.
|
||||
if (
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
|
||||
) {
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ export interface ShowToastParams {
|
||||
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
|
||||
id?: string;
|
||||
message:
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
announceMessage?:
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
action?: ToastActionParams;
|
||||
secondaryAction?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
@@ -28,10 +23,8 @@ export interface ShowToastParams {
|
||||
|
||||
export interface ToastActionParams {
|
||||
action: () => void;
|
||||
primary?: boolean;
|
||||
text:
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
}
|
||||
|
||||
@customElement("notification-manager")
|
||||
@@ -100,22 +93,31 @@ class NotificationManager extends LitElement {
|
||||
)
|
||||
: this._parameters.message
|
||||
}
|
||||
.announceText=${
|
||||
this._parameters.announceMessage
|
||||
? typeof this._parameters.announceMessage !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters.announceMessage.translationKey,
|
||||
this._parameters.announceMessage.args
|
||||
)
|
||||
: this._parameters.announceMessage
|
||||
: undefined
|
||||
}
|
||||
.timeoutMs=${this._parameters.duration!}
|
||||
.bottomOffset=${this._parameters.bottomOffset ?? 0}
|
||||
@toast-closed=${this._toastClosed}
|
||||
>
|
||||
${this._renderAction(this._parameters.secondaryAction, true)}
|
||||
${this._renderAction(this._parameters.action, false)}
|
||||
${
|
||||
this._parameters?.action
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._buttonClicked}
|
||||
>
|
||||
${
|
||||
typeof this._parameters?.action.text !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters?.action.text.translationKey,
|
||||
this._parameters?.action.text.args
|
||||
)
|
||||
: this._parameters?.action.text
|
||||
}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._parameters?.dismissable
|
||||
? html`
|
||||
@@ -132,37 +134,11 @@ class NotificationManager extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAction(
|
||||
action: ToastActionParams | undefined,
|
||||
secondary: boolean
|
||||
) {
|
||||
if (!action) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-button
|
||||
appearance=${action.primary ? "filled" : "plain"}
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
|
||||
>
|
||||
${
|
||||
typeof action.text !== "string"
|
||||
? this.hass.localize(action.text.translationKey, action.text.args)
|
||||
: action.text
|
||||
}
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _buttonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.action?.action();
|
||||
}
|
||||
|
||||
private _secondaryButtonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.secondaryAction?.action();
|
||||
if (this._parameters?.action) {
|
||||
this._parameters?.action.action();
|
||||
}
|
||||
}
|
||||
|
||||
private _dismissClicked() {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { provide } from "@lit/context";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import type { LitElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import { shallowEqual } from "../common/util/shallow-equal";
|
||||
import {
|
||||
@@ -18,18 +17,6 @@ export type CompareStrategy<State> =
|
||||
| { type: "shallow" }
|
||||
| { type: "custom"; compare: (a: State, b: State) => boolean };
|
||||
|
||||
const connectedDirtyStateProviders = new Map<object, boolean>();
|
||||
|
||||
const publishGlobalDirtyState = (): void => {
|
||||
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
|
||||
Boolean
|
||||
);
|
||||
if (isDirty !== window.isDirtyState) {
|
||||
window.isDirtyState = isDirty;
|
||||
fireEvent(window, "dirty-state-changed", { isDirty });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mixin that provides dirty-state tracking via Lit context.
|
||||
*
|
||||
@@ -39,9 +26,6 @@ const publishGlobalDirtyState = (): void => {
|
||||
* so independent contributors (e.g. a helper form alongside the entity
|
||||
* registry editor) can coexist without overwriting each other.
|
||||
*
|
||||
* Connected providers contribute to the global dirty state. It remains dirty
|
||||
* while any provider has unsaved changes.
|
||||
*
|
||||
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
|
||||
* initial and current value through the optional `effectiveNormalize` function
|
||||
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
|
||||
@@ -134,24 +118,6 @@ export const DirtyStateProviderMixin =
|
||||
this._dirtyStateContext = this._buildContextValue();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
connectedDirtyStateProviders.delete(this);
|
||||
publishGlobalDirtyState();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
|
||||
const slice = this._dirtySlices.get(key);
|
||||
if (!slice) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { consume } from "@lit/context";
|
||||
import "@material/mwc-list/mwc-list";
|
||||
import { mdiPlus, mdiTextureBox, mdiUnfoldMoreHorizontal } from "@mdi/js";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import {
|
||||
@@ -98,16 +98,8 @@ export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_SEARCH_ITEMS_PER_SECTION = 5;
|
||||
|
||||
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
|
||||
|
||||
interface SearchMoreComboBoxItem extends PickerComboBoxItem {
|
||||
type: "more";
|
||||
section: "entity" | "device" | "item";
|
||||
label: string;
|
||||
}
|
||||
|
||||
@customElement("ha-automation-add-search")
|
||||
export class HaAutomationAddSearch extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -296,8 +288,7 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
| AutomationItemComboBoxItem
|
||||
| SearchMoreComboBoxItem,
|
||||
| AutomationItemComboBoxItem,
|
||||
index: number
|
||||
) => {
|
||||
if (!item) {
|
||||
@@ -308,24 +299,6 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
return html`<ha-section-title>${item}</ha-section-title>`;
|
||||
}
|
||||
|
||||
if ("type" in item && item.type === "more") {
|
||||
return html`<ha-combo-box-item
|
||||
id=${`search-list-item-${index}`}
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
.section-id=${item.section}
|
||||
.value=${`more-${item.section}`}
|
||||
@click=${this._toggleSection}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiUnfoldMoreHorizontal}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"></span>
|
||||
<span slot="supporting-text">${item.label}</span>
|
||||
</ha-combo-box-item>`;
|
||||
}
|
||||
|
||||
const type = ["trigger", "condition", "action", "block"].includes(
|
||||
(item as AutomationItemComboBoxItem).type
|
||||
)
|
||||
@@ -539,17 +512,10 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
selectedSection?: SearchSection,
|
||||
relatedIdSets?: RelatedIdSets
|
||||
) => {
|
||||
type ResultItem =
|
||||
| string
|
||||
| FloorComboBoxItem
|
||||
| EntityComboBoxItem
|
||||
| PickerComboBoxItem
|
||||
| SearchMoreComboBoxItem;
|
||||
const resultSections: {
|
||||
title: string;
|
||||
type: string;
|
||||
items: ResultItem[];
|
||||
}[] = [];
|
||||
const resultItems: (
|
||||
string | FloorComboBoxItem | EntityComboBoxItem | PickerComboBoxItem
|
||||
)[] = [];
|
||||
|
||||
if (!selectedSection || selectedSection === "item") {
|
||||
let items = this._convertItemsToComboBoxItems(automationItems, type);
|
||||
if (searchTerm) {
|
||||
@@ -560,13 +526,15 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
ITEM_SEARCH_KEYS
|
||||
) as AutomationItemComboBoxItem[];
|
||||
}
|
||||
if (items.length) {
|
||||
resultSections.push({
|
||||
title: localize(`ui.panel.config.automation.editor.${type}s.name`),
|
||||
type: "item",
|
||||
items: items,
|
||||
});
|
||||
|
||||
if (!selectedSection && items.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize(`ui.panel.config.automation.editor.${type}s.name`)
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...items);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -596,13 +564,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
) as AutomationItemComboBoxItem[];
|
||||
}
|
||||
|
||||
if (blocks.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.panel.config.automation.editor.blocks"),
|
||||
type: "block",
|
||||
items: blocks,
|
||||
});
|
||||
if (!selectedSection && blocks.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.panel.config.automation.editor.blocks")
|
||||
);
|
||||
}
|
||||
resultItems.push(...blocks);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
@@ -633,13 +601,14 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (entityItems.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.entities"),
|
||||
type: "entity",
|
||||
items: entityItems,
|
||||
});
|
||||
if (!selectedSection && entityItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.entities")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...entityItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
@@ -671,13 +640,14 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
deviceItems = sortRelatedFirst(deviceItems);
|
||||
}
|
||||
|
||||
if (deviceItems.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.devices"),
|
||||
type: "device",
|
||||
items: deviceItems,
|
||||
});
|
||||
if (!selectedSection && deviceItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "area") {
|
||||
@@ -727,9 +697,15 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (areasAndFloors.length) {
|
||||
const areaItems = areasAndFloors.map((item, index) => {
|
||||
if (!selectedSection && areasAndFloors.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.areas"));
|
||||
}
|
||||
|
||||
resultItems.push(
|
||||
...areasAndFloors.map((item, index) => {
|
||||
const nextItem = areasAndFloors[index + 1];
|
||||
|
||||
if (
|
||||
!nextItem ||
|
||||
(item.type === "area" && nextItem.type === "floor")
|
||||
@@ -739,15 +715,10 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
last: true,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.areas"),
|
||||
type: "area",
|
||||
items: areaItems,
|
||||
});
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "label") {
|
||||
@@ -775,47 +746,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (labels.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.labels"),
|
||||
type: "label",
|
||||
items: labels,
|
||||
});
|
||||
if (!selectedSection && labels.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.labels"));
|
||||
}
|
||||
}
|
||||
|
||||
const resultItems: ResultItem[] = [];
|
||||
resultSections.forEach((section, index) => {
|
||||
if (selectedSection) {
|
||||
resultItems.push(...section.items);
|
||||
return;
|
||||
}
|
||||
resultItems.push(section.title);
|
||||
if (
|
||||
index !== resultSections.length - 1 &&
|
||||
(section.type === "item" ||
|
||||
section.type === "entity" ||
|
||||
section.type === "device") &&
|
||||
section.items.length > MAX_SEARCH_ITEMS_PER_SECTION + 1
|
||||
) {
|
||||
resultItems.push(
|
||||
...section.items.slice(0, MAX_SEARCH_ITEMS_PER_SECTION)
|
||||
);
|
||||
const typeKey = section.type === "item" ? type : section.type;
|
||||
resultItems.push({
|
||||
primary: "",
|
||||
id: `search-more-${section.type}`,
|
||||
type: "more",
|
||||
section: section.type,
|
||||
label: localize(
|
||||
`ui.panel.config.automation.editor.show_more_search.${typeKey}`,
|
||||
{ count: section.items.length - MAX_SEARCH_ITEMS_PER_SECTION }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
resultItems.push(...section.items);
|
||||
}
|
||||
});
|
||||
resultItems.push(...labels);
|
||||
}
|
||||
|
||||
return resultItems;
|
||||
}
|
||||
@@ -853,22 +790,18 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleSection = (ev: Event) => {
|
||||
private _toggleSection(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
// this._resetSelectedItem();
|
||||
this._searchSectionTitle = undefined;
|
||||
const section = (ev.currentTarget as HTMLElement)["section-id"] as string;
|
||||
const section = (ev.target as HTMLElement)["section-id"] as string;
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
this._toggleSectionType(section);
|
||||
};
|
||||
|
||||
private _toggleSectionType(type: string) {
|
||||
if (this._selectedSearchSection === type) {
|
||||
if (this._selectedSearchSection === section) {
|
||||
this._selectedSearchSection = undefined;
|
||||
} else {
|
||||
this._selectedSearchSection = type as SearchSection;
|
||||
this._selectedSearchSection = section as SearchSection;
|
||||
}
|
||||
|
||||
// Reset scroll position when filter changes
|
||||
@@ -1086,13 +1019,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
const item = this._virtualizerElement?.items[
|
||||
this._selectedSearchItemIndex
|
||||
] as PickerComboBoxItem | SearchMoreComboBoxItem;
|
||||
] as PickerComboBoxItem;
|
||||
if (item) {
|
||||
if ("type" in item && item.type === "more") {
|
||||
this._toggleSectionType((item as SearchMoreComboBoxItem).section);
|
||||
} else {
|
||||
this._selectSearchItem(item as PickerComboBoxItem);
|
||||
}
|
||||
this._selectSearchItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -218,14 +218,20 @@ export class HaPlatformCondition extends LitElement {
|
||||
</div>
|
||||
${
|
||||
conditionDesc && "target" in conditionDesc
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.condition?.target}
|
||||
></ha-selector>`
|
||||
? html`<ha-settings-row narrow>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.condition?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
@@ -664,14 +670,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -213,14 +213,19 @@ export class HaPlatformTrigger extends LitElement {
|
||||
</div>
|
||||
${
|
||||
triggerDesc && "target" in triggerDesc
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.trigger?.target}
|
||||
></ha-selector>`
|
||||
? html`<ha-settings-row narrow>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
><ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.trigger?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
@@ -543,14 +548,6 @@ export class HaPlatformTrigger extends LitElement {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -203,7 +203,6 @@ class DialogBackupOnboarding
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_step") && this._step === "key") {
|
||||
this._save();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
mdiPuzzle,
|
||||
mdiRadioTower,
|
||||
mdiRemote,
|
||||
mdiRenameOutline,
|
||||
mdiRobot,
|
||||
mdiScrewdriver,
|
||||
mdiScriptText,
|
||||
@@ -494,6 +495,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/entity-id-format",
|
||||
translationKey: "entity_id_format",
|
||||
iconPath: mdiRenameOutline,
|
||||
iconColor: "#24a5cb",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/analytics",
|
||||
translationKey: "analytics",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiRestore } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { apiContext, configContext } from "../../../data/context";
|
||||
import {
|
||||
fetchEntityRegistrySettings,
|
||||
updateEntityRegistrySettings,
|
||||
} from "../../../data/entity/entity_registry_settings";
|
||||
import {
|
||||
DEFAULT_ENTITY_ID_FORMAT,
|
||||
isDefaultEntityIdFormat,
|
||||
type EntityIdFormat,
|
||||
type EntityIdPart,
|
||||
} from "../../../data/entity_id_format";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "./ha-entity-id-format-editor";
|
||||
|
||||
const EXAMPLE_DOMAIN = "sensor";
|
||||
|
||||
@customElement("ha-config-entity-id-format")
|
||||
export class HaConfigEntityIdFormat extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _format?: EntityIdFormat;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
try {
|
||||
const settings = await fetchEntityRegistrySettings(this._api);
|
||||
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _examples = memoizeOne(
|
||||
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
|
||||
area: localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.examples.area"
|
||||
),
|
||||
device: localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.examples.device"
|
||||
),
|
||||
entity: localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.examples.entity"
|
||||
),
|
||||
floor: localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.examples.floor"
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<p class="description">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.description"
|
||||
)}
|
||||
<a
|
||||
href=${documentationUrl(
|
||||
this._config,
|
||||
"/docs/configuration/customizing-devices/"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.learn_more"
|
||||
)}</a
|
||||
>
|
||||
</p>
|
||||
${
|
||||
this._format
|
||||
? html`
|
||||
<ha-entity-id-format-editor
|
||||
.value=${this._format}
|
||||
.label=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.label"
|
||||
)}
|
||||
@value-changed=${this._formatChanged}
|
||||
></ha-entity-id-format-editor>
|
||||
${this._renderPreview()}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._reset}
|
||||
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.entity_id_format.card.reset")}
|
||||
</ha-button>
|
||||
<ha-progress-button
|
||||
appearance="filled"
|
||||
@click=${this._save}
|
||||
.disabled=${!this._format}
|
||||
>
|
||||
${this._localize("ui.common.save")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPreview() {
|
||||
const example = computeEntityIdFormatExample(
|
||||
this._format!,
|
||||
this._examples(this._localize)
|
||||
);
|
||||
return html`
|
||||
<div class="preview">
|
||||
<span class="preview-label">
|
||||
${this._localize("ui.panel.config.entity_id_format.card.preview")}
|
||||
</span>
|
||||
<code>${EXAMPLE_DOMAIN}.${example}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _formatChanged(ev: CustomEvent) {
|
||||
this._format = ev.detail.value;
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
|
||||
}
|
||||
|
||||
private async _save(ev: Event) {
|
||||
const button = ev.currentTarget as HaProgressButton;
|
||||
if (button.progress) {
|
||||
return;
|
||||
}
|
||||
button.progress = true;
|
||||
this._error = undefined;
|
||||
try {
|
||||
const format = this._format!;
|
||||
const settings = await updateEntityRegistrySettings(this._api, {
|
||||
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
|
||||
});
|
||||
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
|
||||
button.actionSuccess();
|
||||
} catch (err: any) {
|
||||
button.actionError();
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
button.progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
margin-top: var(--ha-space-5);
|
||||
}
|
||||
.preview-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview code {
|
||||
display: block;
|
||||
padding: var(--ha-space-3);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background-color: var(--secondary-background-color);
|
||||
font-family: var(--ha-font-family-code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-entity-id-format": HaConfigEntityIdFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-entity-id-format";
|
||||
|
||||
@customElement("ha-config-section-entity-id-format")
|
||||
export class HaConfigSectionEntityIdFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.entity_id_format.caption"
|
||||
)}
|
||||
>
|
||||
<div class="content">
|
||||
<ha-config-entity-id-format></ha-config-entity-id-format>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
padding: var(--ha-space-7) var(--ha-space-5) 0;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
}
|
||||
ha-config-entity-id-format {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/chips/ha-chip-set";
|
||||
import "../../../components/chips/ha-input-chip";
|
||||
import "../../../components/ha-combo-box-item";
|
||||
import "../../../components/ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type {
|
||||
EntityIdFormat,
|
||||
EntityIdPart,
|
||||
} from "../../../data/entity_id_format";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
|
||||
|
||||
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const encodeType = (type: EntityIdPart) => `___${type}___`;
|
||||
|
||||
const decodeType = (value: string): EntityIdPart | undefined => {
|
||||
const type =
|
||||
value.startsWith("___") && value.endsWith("___")
|
||||
? value.slice(3, -3)
|
||||
: value;
|
||||
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
|
||||
? (type as EntityIdPart)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-entity-id-format-editor")
|
||||
export class HaEntityIdFormatEditor extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public value: EntityIdFormat = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.disabled=${this.disabled}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchLabel=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="field">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this.value,
|
||||
(item) => item,
|
||||
(item: EntityIdPart, idx) => {
|
||||
const label = this._formatType(item);
|
||||
if (REQUIRED_TYPES.includes(item)) {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
filled
|
||||
class="required"
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const newValue = this.value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this.value];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = decodeType(value);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this.value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = type;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(type);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityIdFormat) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _formatType = (type: EntityIdPart) =>
|
||||
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(localize: LocalizeFunc): PickerComboBoxItem[] =>
|
||||
STRUCTURAL_TYPES.map((type) => {
|
||||
const primary = localize(
|
||||
`ui.components.entity.entity-name-picker.types.${type}`
|
||||
);
|
||||
const id = encodeType(type);
|
||||
return {
|
||||
id,
|
||||
primary,
|
||||
search_labels: { primary, id },
|
||||
sorting_label: primary,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
const item = this.value[this._editIndex];
|
||||
return item ? encodeType(item) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getFilteredItems = (): PickerComboBoxItem[] => {
|
||||
const items = this._getItems(this._localize);
|
||||
const currentValue =
|
||||
this._editIndex != null
|
||||
? encodeType(this.value[this._editIndex])
|
||||
: undefined;
|
||||
|
||||
const usedValues = new Set(this.value.map((item) => encodeType(item)));
|
||||
|
||||
return items.filter(
|
||||
(item) => !usedValues.has(item.id) || item.id === currentValue
|
||||
);
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.field:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
:host([disabled]) .field:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.field:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-assist-chip.required {
|
||||
--ha-assist-chip-filled-container-color: rgba(
|
||||
var(--rgb-primary-text-color),
|
||||
0.15
|
||||
);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
|
||||
}
|
||||
}
|
||||
@@ -248,24 +248,6 @@ export class DialogEnergyBatterySettings
|
||||
@value-changed=${this._statisticSocChanged}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-input
|
||||
.value=${
|
||||
this._source.capacity != null ? String(this._source.capacity) : ""
|
||||
}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.capacity"
|
||||
)}
|
||||
.hint=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.capacity_helper"
|
||||
)}
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
@input=${this._capacityChanged}
|
||||
>
|
||||
<span slot="end">kWh</span>
|
||||
</ha-input>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -364,18 +346,6 @@ export class DialogEnergyBatterySettings
|
||||
});
|
||||
}
|
||||
|
||||
private _capacityChanged(ev: InputEvent) {
|
||||
const rawValue = (ev.target as HaInput).value;
|
||||
const value = rawValue ? parseFloat(rawValue) : NaN;
|
||||
this._source = {
|
||||
...this._source!,
|
||||
capacity: Number.isFinite(value) && value > 0 ? value : undefined,
|
||||
};
|
||||
if (this._source.capacity === undefined) {
|
||||
delete this._source.capacity;
|
||||
}
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
const source: BatterySourceTypeEnergyPreference = {
|
||||
@@ -396,10 +366,6 @@ export class DialogEnergyBatterySettings
|
||||
source.stat_soc = this._source!.stat_soc;
|
||||
}
|
||||
|
||||
if (this._source!.capacity != null) {
|
||||
source.capacity = this._source!.capacity;
|
||||
}
|
||||
|
||||
await this._params!.saveCallback(source);
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
@@ -418,11 +384,7 @@ export class DialogEnergyBatterySettings
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-input {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
ha-input:last-of-type {
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -170,6 +170,10 @@ class HaPanelConfig extends HassRouterPage {
|
||||
tag: "ha-config-section-ai-tasks",
|
||||
load: () => import("./core/ha-config-section-ai-tasks"),
|
||||
},
|
||||
"entity-id-format": {
|
||||
tag: "ha-config-section-entity-id-format",
|
||||
load: () => import("./core/ha-config-section-entity-id-format"),
|
||||
},
|
||||
zha: {
|
||||
tag: "zha-config-dashboard-router",
|
||||
load: () =>
|
||||
|
||||
@@ -17,7 +17,6 @@ export type SankeyDeviceNode = Node & { parent?: string };
|
||||
|
||||
interface SmallConsumer {
|
||||
id: string;
|
||||
includedInStat: string | undefined;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
@@ -54,6 +53,9 @@ export interface BuildSankeyDeviceNodesOptions {
|
||||
getValue: (id: string) => number;
|
||||
getLabel: (id: string, name: string | undefined) => string;
|
||||
getEntityId: (id: string) => string | undefined;
|
||||
findEffectiveParent: (
|
||||
includedInStat: string | undefined
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +85,7 @@ export const buildSankeyDeviceNodes = (
|
||||
getValue,
|
||||
getLabel,
|
||||
getEntityId,
|
||||
findEffectiveParent,
|
||||
} = options;
|
||||
|
||||
const unavailableColor = computedStyle
|
||||
@@ -93,65 +96,15 @@ export const buildSankeyDeviceNodes = (
|
||||
const deviceNodes: SankeyDeviceNode[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
const smallConsumerStats = new Set<string>();
|
||||
let untrackedConsumption = initialUntracked;
|
||||
|
||||
// Resolve every device's node id and value once. `included_in_stat` always
|
||||
// names a stat_consumption, so the hierarchy is keyed by that, while the
|
||||
// rendered set holds node ids (stat_consumption or stat_rate, per `getId`).
|
||||
const deviceByStat = new Map<string, DeviceConsumptionEnergyPreference>();
|
||||
const deviceValues = new Map<string, number>();
|
||||
const renderedIds = new Set<string>();
|
||||
devices.forEach((device) => {
|
||||
deviceByStat.set(device.stat_consumption, device);
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = getValue(id);
|
||||
deviceValues.set(id, value);
|
||||
if (value >= minThreshold) {
|
||||
renderedIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
/** Node id of a device rendered as its own node, else undefined. */
|
||||
const renderedId = (statConsumption: string): string | undefined => {
|
||||
const device = deviceByStat.get(statConsumption);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
const id = getId(device);
|
||||
return id && renderedIds.has(id) ? id : undefined;
|
||||
};
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered.
|
||||
// Bounded because a hand-edited config can make included_in_stat cyclic.
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let current = includedInStat;
|
||||
for (let hops = 0; current && hops < devices.length; hops++) {
|
||||
const rendered = renderedId(current);
|
||||
if (rendered) {
|
||||
return rendered;
|
||||
}
|
||||
const device = deviceByStat.get(current);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
current = device.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = deviceValues.get(id)!;
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
@@ -162,13 +115,11 @@ export const buildSankeyDeviceNodes = (
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
id,
|
||||
includedInStat: device.included_in_stat,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
smallConsumerStats.add(device.stat_consumption);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,26 +142,7 @@ export const buildSankeyDeviceNodes = (
|
||||
});
|
||||
|
||||
// Process small consumers - show a lone one directly, group clusters as "Other"
|
||||
smallConsumersByParent.forEach((allConsumers, parentKey) => {
|
||||
// A small consumer whose included_in_stat chain reaches another small
|
||||
// consumer is already counted inside that ancestor's value - drop it so
|
||||
// totals don't double-count nested devices. A rendered ancestor ends the
|
||||
// walk: the consumer links to it and never touches untracked, so it can't
|
||||
// be double-counted through anything further up.
|
||||
const consumers = allConsumers.filter((consumer) => {
|
||||
let ancestor = consumer.includedInStat;
|
||||
for (let hops = 0; ancestor && hops < devices.length; hops++) {
|
||||
if (renderedId(ancestor)) {
|
||||
return true;
|
||||
}
|
||||
if (smallConsumerStats.has(ancestor)) {
|
||||
return false;
|
||||
}
|
||||
ancestor = deviceByStat.get(ancestor)?.included_in_stat;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
|
||||
@@ -210,37 +210,16 @@ class HuiEnergyDistrubutionCard
|
||||
// card's data when the selected period extends to now. For historical
|
||||
// periods (yesterday, last week, ...) fall back to the generic icon.
|
||||
if (periodIncludesNow(this._data)) {
|
||||
const socBatteries = types
|
||||
.battery!.map((source) => ({
|
||||
soc: source.stat_soc
|
||||
const socValues = types
|
||||
.battery!.map((source) =>
|
||||
source.stat_soc
|
||||
? Number(this.hass.states[source.stat_soc]?.state)
|
||||
: NaN,
|
||||
capacity: source.capacity,
|
||||
}))
|
||||
.filter((battery) => Number.isFinite(battery.soc));
|
||||
if (socBatteries.length) {
|
||||
// Weight each battery's SOC by its capacity so the combined value
|
||||
// reflects the total stored energy. Batteries without a configured
|
||||
// capacity assume the mean of the configured ones; when none are
|
||||
// configured this falls back to an equally weighted (simple) average.
|
||||
const configuredCapacities = socBatteries
|
||||
.map((battery) => battery.capacity)
|
||||
.filter((capacity) => capacity != null && capacity > 0) as number[];
|
||||
const meanCapacity = configuredCapacities.length
|
||||
? configuredCapacities.reduce((sum, value) => sum + value, 0) /
|
||||
configuredCapacities.length
|
||||
: 1;
|
||||
let weightSum = 0;
|
||||
let weightedSocSum = 0;
|
||||
socBatteries.forEach((battery) => {
|
||||
const capacity =
|
||||
battery.capacity != null && battery.capacity > 0
|
||||
? battery.capacity
|
||||
: meanCapacity;
|
||||
weightSum += capacity;
|
||||
weightedSocSum += battery.soc * capacity;
|
||||
});
|
||||
averageBatterySoc = weightedSocSum / weightSum;
|
||||
: NaN
|
||||
)
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (socValues.length) {
|
||||
averageBatterySoc =
|
||||
socValues.reduce((sum, value) => sum + value, 0) / socValues.length;
|
||||
batteryIconPath = batteryLevelIconPath(averageBatterySoc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +272,35 @@ class HuiEnergySankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
});
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
@@ -298,6 +327,7 @@ class HuiEnergySankeyCard
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -278,6 +278,54 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
if (value >= minPowerThreshold) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for power hierarchy
|
||||
// Walks up the chain to find an ancestor with stat_rate that will be rendered
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) {
|
||||
return undefined;
|
||||
}
|
||||
// If this parent has a stat_rate and will be rendered, use it
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
// Otherwise, continue up the chain
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -296,6 +344,7 @@ class HuiPowerSankeyCard
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -241,6 +241,48 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
if (value >= minFlowThreshold) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for hierarchy
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) return undefined;
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -259,6 +301,7 @@ class HuiWaterFlowSankeyCard
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -215,6 +215,35 @@ class HuiWaterSankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
});
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
@@ -241,6 +270,7 @@ class HuiWaterSankeyCard
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ export class HuiDialogEditBadge
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!changedProps.has("_badgeConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,6 @@ export class HuiDialogEditCard
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export class HuiDialogEditSection
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const sectionConfig = {
|
||||
...this._config,
|
||||
|
||||
@@ -94,7 +94,6 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const viewConfig = {
|
||||
...this._config,
|
||||
|
||||
@@ -43,7 +43,6 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
|
||||
@state() private _open = false;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const config = {
|
||||
...this._config,
|
||||
|
||||
@@ -44,7 +44,6 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
|
||||
@state() private _open = false;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const config = {
|
||||
...this._config,
|
||||
|
||||
@@ -107,7 +107,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
.min=${Number(stateObj.attributes.min)}
|
||||
.max=${Number(stateObj.attributes.max)}
|
||||
.value=${Number(stateObj.state).toString()}
|
||||
.value=${stateObj.state}
|
||||
type="number"
|
||||
@change=${this._selectedValueChanged}
|
||||
>
|
||||
|
||||
@@ -104,7 +104,6 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
|
||||
if (
|
||||
!this._saving &&
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import {
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
@@ -108,24 +107,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
generateEntityFilter(hass, filter)
|
||||
);
|
||||
|
||||
const primaryFilter = generateEntityFilter(hass, {
|
||||
entity_category: "none",
|
||||
});
|
||||
|
||||
// Only show the devices tile if the other devices view has content: it
|
||||
// only renders area-less primary entities that belong to a device.
|
||||
const hasOtherDevices = allEntities.some(
|
||||
(entityId) =>
|
||||
otherDevicesFilters.some((filter) => filter(entityId)) &&
|
||||
primaryFilter(entityId) &&
|
||||
!!getEntityContext(
|
||||
hass.states[entityId],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
).device
|
||||
);
|
||||
const entitiesWithoutAreas = findEntities(allEntities, otherDevicesFilters);
|
||||
|
||||
const floorsSections: LovelaceSectionConfig[] = [];
|
||||
for (const floorStructure of home.floors) {
|
||||
@@ -158,13 +140,13 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (home.areas.length > 0 || hasOtherDevices) {
|
||||
if (home.areas.length > 0 || entitiesWithoutAreas.length > 0) {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
for (const areaId of home.areas) {
|
||||
cards.push(computeAreaCard(areaId, hass));
|
||||
}
|
||||
|
||||
if (hasOtherDevices) {
|
||||
if (entitiesWithoutAreas.length > 0) {
|
||||
cards.push({
|
||||
type: "tile",
|
||||
entity: "zone.home", // zone entity to represent unassigned area as it always exists
|
||||
|
||||
@@ -101,7 +101,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
const columns = Math.floor(
|
||||
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
|
||||
);
|
||||
return Math.max(columns, 1);
|
||||
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
return clamp(columns, 1, maxColumns);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,14 +155,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _updateMaxColumnCount(): void {
|
||||
// The column count is clamped here instead of in the resize callback, so
|
||||
// that it follows the config of the view the element is currently used for
|
||||
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
const maxColumnCount = clamp(
|
||||
this._columnsController.value ?? 1,
|
||||
1,
|
||||
maxColumns
|
||||
);
|
||||
const maxColumnCount = this._columnsController.value ?? 1;
|
||||
|
||||
if (maxColumnCount !== this._maxColumns) {
|
||||
this._maxColumns = maxColumnCount;
|
||||
|
||||
+32
-18
@@ -797,6 +797,13 @@
|
||||
"target_details": "Target details",
|
||||
"no_targets": "No targets",
|
||||
"no_target_found": "No target found for {term}",
|
||||
"selected": {
|
||||
"entity": "Entities: {count}",
|
||||
"device": "Devices: {count}",
|
||||
"area": "Areas: {count}",
|
||||
"label": "Labels: {count}",
|
||||
"floor": "Floors: {count}"
|
||||
},
|
||||
"type": {
|
||||
"area": "Area",
|
||||
"areas": "Areas",
|
||||
@@ -1186,11 +1193,6 @@
|
||||
"play": "Play",
|
||||
"play-media": "Play media",
|
||||
"pick-media": "Pick media",
|
||||
"search": {
|
||||
"search_placeholder": "Search {name}",
|
||||
"no_results": "No results found"
|
||||
},
|
||||
"filter_media_type": "Filter by media type",
|
||||
"no_items": "No items",
|
||||
"not_shown": "{count} {count, plural,\n one {item}\n other {items}\n} incompatible with the currently selected player {count, plural,\n one {is}\n other {are}\n} hidden",
|
||||
"choose_player": "Choose player",
|
||||
@@ -2508,8 +2510,8 @@
|
||||
"triggered": "Triggered {name}",
|
||||
"dismiss": "Dismiss",
|
||||
"no_matching_link_found": "No matching My link found for {path}",
|
||||
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
|
||||
"update_now": "Update now",
|
||||
"new_version_available": "A new version of the frontend is available.",
|
||||
"reload": "Reload",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
"onboarding_survey": {
|
||||
@@ -4242,8 +4244,6 @@
|
||||
"display_name": "[%key:ui::panel::config::energy::device_consumption::dialog::display_name%]",
|
||||
"state_of_charge": "Battery state of charge sensor",
|
||||
"state_of_charge_helper": "Sensor reporting battery state of charge as %.",
|
||||
"capacity": "Battery capacity",
|
||||
"capacity_helper": "Usable battery capacity in kWh. Used to weight the combined state of charge when you have multiple batteries of different sizes.",
|
||||
"power": "Battery power",
|
||||
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery.",
|
||||
"sensor_type": "Type of power measurement",
|
||||
@@ -5177,13 +5177,6 @@
|
||||
"area": "No areas or floors found for {term}",
|
||||
"label": "No labels found for {term}"
|
||||
},
|
||||
"show_more_search": {
|
||||
"trigger": "{count} more triggers...",
|
||||
"condition": "{count} more conditions...",
|
||||
"action": "{count} more actions...",
|
||||
"entity": "{count} more entities...",
|
||||
"device": "{count} more devices..."
|
||||
},
|
||||
"load_target_items_failed": "Failed to load target items for",
|
||||
"other_areas": "Other areas",
|
||||
"services": "Services",
|
||||
@@ -5676,7 +5669,7 @@
|
||||
},
|
||||
"trigger": {
|
||||
"label": "Triggered by",
|
||||
"no_triggers": "There are no triggers with IDs set in this automation. Edit a trigger and give it a Trigger ID name.",
|
||||
"no_triggers": "There are no triggers with ID's set in this automation. Edit a trigger and give it a Trigger ID name.",
|
||||
"id": "Trigger",
|
||||
"description": {
|
||||
"picker": "Tests if the automation has been triggered by a specific trigger.",
|
||||
@@ -8606,6 +8599,28 @@
|
||||
"caption": "AI tasks",
|
||||
"description": "Configure AI suggestions and task preferences"
|
||||
},
|
||||
"entity_id_format": {
|
||||
"caption": "Entity ID format",
|
||||
"description": "Manage the format of newly created entity IDs",
|
||||
"card": {
|
||||
"header": "Entity ID format for new entities",
|
||||
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
|
||||
"learn_more": "Learn more",
|
||||
"preview": "Preview",
|
||||
"reset": "Reset to default",
|
||||
"editor": {
|
||||
"label": "Format",
|
||||
"add": "Add",
|
||||
"search": "Search part",
|
||||
"examples": {
|
||||
"area": "Living room",
|
||||
"device": "Thermostat",
|
||||
"entity": "Temperature",
|
||||
"floor": "Ground floor"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"caption": "Labs",
|
||||
"custom_integration": "Custom integration",
|
||||
@@ -11259,7 +11274,6 @@
|
||||
"cards": {
|
||||
"demo": {
|
||||
"demo_by": "by {name}",
|
||||
"interactive_demo": "Interactive demo",
|
||||
"next_demo": "Next demo",
|
||||
"introduction": "Welcome home! You've reached the Home Assistant demo where we showcase the best UIs created by our community.",
|
||||
"learn_more": "Learn more about Home Assistant"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { showToast } from "./toast";
|
||||
|
||||
const UPDATE_DELAY = 60_000;
|
||||
const UPDATE_TOAST_ID = "frontend-update-available";
|
||||
|
||||
export const supportsServiceWorker = () =>
|
||||
"serviceWorker" in navigator &&
|
||||
(location.protocol === "https:" || location.hostname === "localhost");
|
||||
@@ -26,92 +23,6 @@ export const registerServiceWorker = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let pendingWorker: ServiceWorker | undefined;
|
||||
let updateDeadline = 0;
|
||||
let updateInterval = 0;
|
||||
|
||||
const hideUpdateToast = () => {
|
||||
showToast(rootEl, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const clearUpdateCountdown = () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
|
||||
const activateUpdate = () => {
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
clearUpdateCountdown();
|
||||
hideUpdateToast();
|
||||
pendingWorker.postMessage({ type: "skipWaiting" });
|
||||
pendingWorker = undefined;
|
||||
};
|
||||
|
||||
const showUpdateToast = () => {
|
||||
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
|
||||
if (seconds <= 0) {
|
||||
activateUpdate();
|
||||
return;
|
||||
}
|
||||
const announceSeconds =
|
||||
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
|
||||
showToast(rootEl, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: announceSeconds },
|
||||
},
|
||||
action: {
|
||||
action: activateUpdate,
|
||||
primary: true,
|
||||
text: { translationKey: "ui.notification_toast.update_now" },
|
||||
},
|
||||
secondaryAction: {
|
||||
action: () => {
|
||||
pendingWorker = undefined;
|
||||
clearUpdateCountdown();
|
||||
},
|
||||
text: { translationKey: "ui.common.cancel" },
|
||||
},
|
||||
duration: -1,
|
||||
});
|
||||
};
|
||||
|
||||
const startUpdateCountdown = () => {
|
||||
updateDeadline = Date.now() + UPDATE_DELAY;
|
||||
showUpdateToast();
|
||||
updateInterval = window.setInterval(showUpdateToast, 1000);
|
||||
};
|
||||
|
||||
window.addEventListener("dirty-state-changed", () => {
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
if (window.isDirtyState) {
|
||||
clearUpdateCountdown();
|
||||
hideUpdateToast();
|
||||
return;
|
||||
}
|
||||
startUpdateCountdown();
|
||||
});
|
||||
|
||||
const updateReady = (worker: ServiceWorker) => {
|
||||
clearUpdateCountdown();
|
||||
pendingWorker = worker;
|
||||
if (!window.isDirtyState) {
|
||||
startUpdateCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
reg.addEventListener("updatefound", () => {
|
||||
const installingWorker = reg.installing;
|
||||
|
||||
@@ -126,11 +37,22 @@ export const registerServiceWorker = async (
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateReady(installingWorker);
|
||||
|
||||
// Notify users a new frontend is available.
|
||||
showToast(rootEl, {
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
},
|
||||
action: {
|
||||
// We tell the service worker to call skipWaiting, which activates
|
||||
// the new service worker. Above we listen for `controllerchange`
|
||||
// so we reload the page once a new service worker activates.
|
||||
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
|
||||
text: { translationKey: "ui.notification_toast.reload" },
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
updateReady(reg.waiting);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,22 +32,6 @@ describe("downSampleLineData", () => {
|
||||
expect(downSampleLineData(points, 100)).toBe(points);
|
||||
});
|
||||
|
||||
it("returns input unchanged when maxDetails is zero", () => {
|
||||
const points = generatePoints(11, 720);
|
||||
expect(downSampleLineData(points, 0)).toBe(points);
|
||||
expect(
|
||||
downSampleLineData(points, 0, points[0][0], points[points.length - 1][0])
|
||||
).toBe(points);
|
||||
});
|
||||
|
||||
it("returns input unchanged when all points share the same x", () => {
|
||||
const points: [number, number][] = Array.from({ length: 20 }, (_, i) => [
|
||||
FIXED_EPOCH_MS,
|
||||
i,
|
||||
]);
|
||||
expect(downSampleLineData(points, 5)).toBe(points);
|
||||
});
|
||||
|
||||
it("skips points with non-finite coordinates", () => {
|
||||
const points = generatePoints(2, 200);
|
||||
points[10] = [points[10][0], NaN];
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaToast } from "../../src/components/ha-toast";
|
||||
import "../../src/components/ha-toast";
|
||||
|
||||
let toast: HaToast | undefined;
|
||||
|
||||
const mountToast = async (properties: Partial<HaToast> = {}) => {
|
||||
toast = document.createElement("ha-toast");
|
||||
Object.assign(toast, properties);
|
||||
document.body.append(toast);
|
||||
await toast.updateComplete;
|
||||
return toast;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
toast?.remove();
|
||||
toast = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const showToast = async (element: HaToast) => {
|
||||
vi.useFakeTimers();
|
||||
const shown = element.show();
|
||||
await element.updateComplete;
|
||||
Object.defineProperty(
|
||||
element.shadowRoot!.querySelector(".toast"),
|
||||
"getAnimations",
|
||||
{
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
}
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await shown;
|
||||
};
|
||||
|
||||
describe("ha-toast", () => {
|
||||
it("renders its message and bottom offset", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Configuration saved",
|
||||
bottomOffset: 24,
|
||||
});
|
||||
|
||||
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
|
||||
"Configuration saved"
|
||||
);
|
||||
expect(
|
||||
(
|
||||
element.shadowRoot?.querySelector(".toast") as HTMLElement
|
||||
).style.getPropertyValue("--ha-toast-bottom-offset")
|
||||
).toBe("24px");
|
||||
});
|
||||
|
||||
it("renders assigned action and dismiss content", async () => {
|
||||
toast = document.createElement("ha-toast");
|
||||
const action = document.createElement("button");
|
||||
action.slot = "action";
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.slot = "dismiss";
|
||||
toast.append(action, dismiss);
|
||||
document.body.append(toast);
|
||||
await toast.updateComplete;
|
||||
toast.requestUpdate();
|
||||
await toast.updateComplete;
|
||||
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="action"]')
|
||||
?.assignedElements()
|
||||
).toEqual([action]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
|
||||
?.assignedElements()
|
||||
).toEqual([dismiss]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector(".actions")
|
||||
?.classList.contains("has-action")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps changing visual text separate from live-region text", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Updating in 59 seconds",
|
||||
announceText: "Updating in 60 seconds",
|
||||
});
|
||||
|
||||
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
|
||||
expect(visibleMessage.closest('[role="status"]')).toBeNull();
|
||||
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
|
||||
expect(liveRegion.getAttribute("role")).toBe("status");
|
||||
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to announcing the visible message", async () => {
|
||||
const element = await mountToast({ labelText: "Configuration saved" });
|
||||
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".assistive-message")!
|
||||
.textContent?.trim()
|
||||
).toBe("Configuration saved");
|
||||
});
|
||||
|
||||
it("activates its live region while shown", async () => {
|
||||
const element = await mountToast();
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("off");
|
||||
|
||||
await showToast(element);
|
||||
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
|
||||
expect(
|
||||
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["action", "dismiss", "programmatic"] as const)(
|
||||
"reports a %s close reason",
|
||||
async (reason) => {
|
||||
const element = await mountToast({ timeoutMs: -1 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await element.hide(reason);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".toast")!
|
||||
.classList.contains("active")
|
||||
).toBe(false);
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".assistive-message")!
|
||||
.getAttribute("aria-live")
|
||||
).toBe("off");
|
||||
}
|
||||
);
|
||||
|
||||
it("closes with a timeout reason", async () => {
|
||||
const element = await mountToast({ timeoutMs: 1000 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
|
||||
});
|
||||
|
||||
it("does not close automatically when the timeout is disabled", async () => {
|
||||
const element = await mountToast({ timeoutMs: -1 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit a close event when already hidden", async () => {
|
||||
const element = await mountToast();
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
|
||||
await element.hide("dismiss");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user