mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-28 00:33:55 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fbc8ae758 | |||
| e18cc7ec3a | |||
| 7620f21033 | |||
| 425a914d34 | |||
| f413e71d54 | |||
| 28045f6cd6 | |||
| 34f3283850 | |||
| d02c4924c4 | |||
| e71bb867be | |||
| 60163efd56 | |||
| 351859c3d0 | |||
| 5f0f1f4867 | |||
| 18de8b13d3 | |||
| 39e70ff315 | |||
| 14fd9114d6 | |||
| 406a0b2bad | |||
| 4c86785d21 | |||
| 2aeb522eed | |||
| bef1c0def6 | |||
| f7e5bdb2a0 | |||
| de59237cfb | |||
| f778572b89 | |||
| d2c753b589 | |||
| 8fbb38a25d | |||
| 2f6c1388e8 | |||
| ced5ffdd18 | |||
| 5a6a16ed27 | |||
| 3d1ed43edc | |||
| d682830a3e | |||
| 863aad24fc | |||
| 3284633ac9 | |||
| 8dd54e0aa8 | |||
| 90a79d458f | |||
| 34dba7ea5a | |||
| e76e7d1064 | |||
| b6675159ae |
@@ -32,7 +32,7 @@ jobs:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # 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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -1,6 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -12,33 +15,53 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
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),
|
||||
];
|
||||
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];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
export let selectedDemo = initialDemo();
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
demoConfigs[selectedDemo]();
|
||||
|
||||
export const setDemoConfig = async (
|
||||
hass: MockHomeAssistant,
|
||||
lovelace: Lovelace,
|
||||
index: number
|
||||
demo: string
|
||||
) => {
|
||||
const confProm = demoConfigs[index]();
|
||||
const confProm = demoConfigs[demo]();
|
||||
const config = await confProm;
|
||||
|
||||
selectedDemoConfigIndex = index;
|
||||
selectedDemo = demo;
|
||||
selectedDemoConfig = confProm;
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
|
||||
// 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 });
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,465 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
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 },
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
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"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
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,8 +1,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceRawConfig } 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);
|
||||
|
||||
@@ -13,7 +15,9 @@ export interface DemoConfig {
|
||||
authorUrl: string;
|
||||
description?:
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
LovelaceCard,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demoConfigs,
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-card")
|
||||
@@ -112,16 +112,12 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._updateConfig(
|
||||
selectedDemoConfigIndex < demoConfigs.length - 1
|
||||
? selectedDemoConfigIndex + 1
|
||||
: 0
|
||||
);
|
||||
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
|
||||
}
|
||||
|
||||
private async _updateConfig(index: number) {
|
||||
private async _updateConfig(demo: string) {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, { index });
|
||||
fireEvent(this, "set-demo-config" as any, { demo });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+16
-5
@@ -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 } from "./stubs/area_registry";
|
||||
import { mockAreaRegistry, setDemoAreas } 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 } from "./stubs/floor_registry";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
@@ -29,6 +29,7 @@ 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
|
||||
@@ -74,11 +75,18 @@ 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.
|
||||
// mocked backup/config/info and webhook/list are queried. usage_prediction
|
||||
// is needed for common-controls sections in strategy dashboards.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,6 +130,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -169,9 +178,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
// Once config is loaded AND localize, set entities and apply theme.
|
||||
// Once config is loaded AND localize, set registries, entities and 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,14 +1,46 @@
|
||||
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: AreaRegistryEntry[] = []
|
||||
data: DemoArea[] = []
|
||||
) => {
|
||||
hass.mockWS("config/area_registry/list", () => data);
|
||||
const areas = {};
|
||||
data.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas });
|
||||
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.updateHass({ areas: areasById });
|
||||
};
|
||||
|
||||
@@ -19,17 +19,31 @@ const pipelines: AssistPipeline[] = [
|
||||
{
|
||||
id: "01local",
|
||||
name: "Local",
|
||||
language: "en",
|
||||
language: "nl",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
conversation_language: "nl",
|
||||
stt_engine: "stt.faster_whisper",
|
||||
stt_language: "en",
|
||||
stt_language: "nl",
|
||||
tts_engine: "tts.piper",
|
||||
tts_language: "en",
|
||||
tts_language: "nl",
|
||||
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,7 +1,40 @@
|
||||
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: FloorRegistryEntry[] = []
|
||||
) => hass.mockWS("config/floor_registry/list", () => data);
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -2,12 +2,13 @@ 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 = (
|
||||
@@ -45,11 +46,11 @@ customElements.whenDefined("hui-root").then(() => {
|
||||
HUIRoot.prototype.firstUpdated = function (changedProperties) {
|
||||
oldFirstUpdated.call(this, changedProperties);
|
||||
this.addEventListener("set-demo-config", async (ev) => {
|
||||
const index = (ev as CustomEvent).detail.index;
|
||||
const demo = (ev as CustomEvent).detail.demo;
|
||||
try {
|
||||
await setDemoConfig(this.hass, this.lovelace!, index);
|
||||
await setDemoConfig(this.hass, this.lovelace!, demo);
|
||||
} catch (_err: any) {
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
|
||||
alert("Failed to switch config :-(");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,7 +2,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} 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";
|
||||
@@ -10,7 +13,6 @@ 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";
|
||||
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
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,21 +1,25 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} 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 } from "../../../../demo/src/stubs/floor_registry";
|
||||
import {
|
||||
mockFloorRegistry,
|
||||
type DemoFloor,
|
||||
} 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,
|
||||
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
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: FloorRegistryEntry[] = [
|
||||
const FLOORS: DemoFloor[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+6
-7
@@ -91,7 +91,7 @@
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"color-name": "2.1.1",
|
||||
"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.1",
|
||||
"js-yaml": "5.2.2",
|
||||
"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",
|
||||
@@ -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.3.6",
|
||||
"fs-extra": "11.4.0",
|
||||
"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.1.0",
|
||||
"lint-staged": "17.2.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.20",
|
||||
"tar": "7.5.21",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
@@ -226,8 +226,7 @@
|
||||
"@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",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
"@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"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
|
||||
const scrollParent = (element: Element): HTMLElement | undefined => {
|
||||
let node = element.parentElement;
|
||||
while (node) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
|
||||
* Point the ref at the element that grows, not at the scroller. Turns native
|
||||
* anchoring off on that scroller, so growth elsewhere in it is no longer
|
||||
* compensated either.
|
||||
*/
|
||||
export class PreserveScrollPositionController implements ReactiveController {
|
||||
private _target: Ref<HTMLElement>;
|
||||
|
||||
private _element?: HTMLElement;
|
||||
|
||||
private _scroller?: HTMLElement;
|
||||
|
||||
private _observer?: ResizeObserver;
|
||||
|
||||
private _height = 0;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & LitElement,
|
||||
target: Ref<HTMLElement>
|
||||
) {
|
||||
this._target = target;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._detach();
|
||||
}
|
||||
|
||||
private _sync() {
|
||||
const element = this._target.value;
|
||||
|
||||
if (element === this._element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._detach();
|
||||
this._element = element;
|
||||
|
||||
if (element) {
|
||||
this._height = element.getBoundingClientRect().height;
|
||||
this._observer = new ResizeObserver((entries) =>
|
||||
this._compensate(entries)
|
||||
);
|
||||
this._observer.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
private _detach() {
|
||||
this._observer?.disconnect();
|
||||
this._observer = undefined;
|
||||
this._element = undefined;
|
||||
if (this._scroller) {
|
||||
this._scroller.style.removeProperty("overflow-anchor");
|
||||
this._scroller = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveScroller(): HTMLElement | undefined {
|
||||
if (!this._scroller && this._element) {
|
||||
this._scroller = scrollParent(this._element);
|
||||
if (this._scroller) {
|
||||
// Chrome and Firefox would otherwise anchor on top of this controller
|
||||
// and correct twice.
|
||||
this._scroller.style.overflowAnchor = "none";
|
||||
}
|
||||
}
|
||||
return this._scroller;
|
||||
}
|
||||
|
||||
private _compensate(entries: ResizeObserverEntry[]) {
|
||||
const element = this._element;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
|
||||
const delta = height - this._height;
|
||||
this._height = height;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scroller = this._resolveScroller();
|
||||
if (
|
||||
scroller &&
|
||||
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
|
||||
) {
|
||||
scroller.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-18
@@ -64,22 +64,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
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 (!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 (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
@@ -87,10 +80,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.location.hash = path;
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
|
||||
@@ -36,6 +36,10 @@ 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,6 +51,7 @@ 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"]>,
|
||||
@@ -1048,7 +1049,9 @@ export class HaChartBase extends LitElement {
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
this.clientWidth * window.devicePixelRatio,
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
|
||||
@@ -141,7 +141,7 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
|
||||
private _value(choice?: string): any {
|
||||
if (!this.value) {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object"
|
||||
|
||||
@@ -24,6 +24,8 @@ 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 =
|
||||
@@ -190,8 +192,6 @@ 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,6 +200,14 @@ 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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -254,6 +262,18 @@ 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,7 +1,13 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { grid } from "@lit-labs/virtualizer/layouts/grid";
|
||||
|
||||
import { mdiArrowUpRight, mdiKeyboard, mdiPlay, mdiPlus } from "@mdi/js";
|
||||
import {
|
||||
mdiArrowUpRight,
|
||||
mdiFilterVariant,
|
||||
mdiKeyboard,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
@@ -14,6 +20,7 @@ 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";
|
||||
@@ -32,6 +39,7 @@ 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";
|
||||
@@ -43,9 +51,14 @@ 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";
|
||||
@@ -74,6 +87,8 @@ 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,
|
||||
@@ -122,6 +137,16 @@ 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;
|
||||
@@ -197,6 +222,10 @@ 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;
|
||||
@@ -328,11 +357,14 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("_scrolled")) {
|
||||
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.
|
||||
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;
|
||||
@@ -365,10 +397,15 @@ 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 = currentItem.children || [];
|
||||
let children = isSearching
|
||||
? this._searchResults!
|
||||
: currentItem.children || [];
|
||||
const notShown = isSearching ? 0 : currentItem.not_shown || 0;
|
||||
const canPlayChildren = new Set<string>();
|
||||
|
||||
// Filter children based on accept property if provided
|
||||
@@ -399,6 +436,26 @@ 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]
|
||||
@@ -406,94 +463,109 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
return html`
|
||||
${
|
||||
currentItem.can_play
|
||||
currentItem.can_play || showSearch
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !currentItem.can_play,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
? html`
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
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>
|
||||
${
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
))
|
||||
subtitle
|
||||
? 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>
|
||||
<h2 class="subtitle">
|
||||
${subtitle}
|
||||
</h2>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: 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>
|
||||
${
|
||||
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
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
@@ -504,12 +576,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@touchmove=${this._scroll}
|
||||
>
|
||||
${
|
||||
this._error
|
||||
this._searching
|
||||
? html`
|
||||
<div class="container">
|
||||
<ha-alert alert-type="error">
|
||||
${this._renderError(this._error)}
|
||||
</ha-alert>
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
: isManualMediaSourceContentId(currentItem.media_content_id)
|
||||
@@ -532,29 +602,33 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@tts-picked=${this._ttsPicked}
|
||||
></ha-browse-media-tts>
|
||||
`
|
||||
: !children.length && !currentItem.not_shown
|
||||
: !children.length && !notShown
|
||||
? html`
|
||||
<div class="container 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"
|
||||
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"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -584,17 +658,17 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
portrait:
|
||||
childrenMediaClass.thumbnail_ratio ===
|
||||
"portrait",
|
||||
not_shown: !!currentItem.not_shown,
|
||||
not_shown: !!notShown,
|
||||
})}"
|
||||
></lit-virtualizer>
|
||||
${
|
||||
currentItem.not_shown
|
||||
notShown
|
||||
? html`
|
||||
<div class="grid not-shown">
|
||||
<div class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: currentItem.not_shown }
|
||||
{ count: notShown }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -614,7 +688,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
.renderItem=${this._renderListItem}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
currentItem.not_shown
|
||||
notShown
|
||||
? html`
|
||||
<ha-list-item
|
||||
noninteractive
|
||||
@@ -628,7 +702,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
<span class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: currentItem.not_shown }
|
||||
{ count: notShown }
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item>
|
||||
@@ -644,6 +718,188 @@ 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}>
|
||||
@@ -1009,7 +1265,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background-color: var(--card-background-color);
|
||||
position: absolute;
|
||||
@@ -1019,6 +1276,56 @@ 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;
|
||||
|
||||
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
|
||||
* boundary.
|
||||
*/
|
||||
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
isDirtyState?: boolean;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"dirty-state-changed": { isDirty: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ 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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
apps_info_dismissed?: boolean;
|
||||
dashboard_favorite_card_types?: string[];
|
||||
}
|
||||
|
||||
export interface SidebarFrontendUserData {
|
||||
|
||||
@@ -186,6 +186,9 @@ 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,6 +24,23 @@ 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
|
||||
step.data_schema.length || this._errors
|
||||
? html`<ha-form
|
||||
${ref(this._formRef)}
|
||||
?autofocus=${this.autoFocus}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (!changedProperties.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,38 @@ 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,6 +27,8 @@ export class MockBaseEntity {
|
||||
|
||||
public state: string;
|
||||
|
||||
public areaId?: string;
|
||||
|
||||
public baseAttributes: EntityAttributes;
|
||||
|
||||
public attributes: EntityAttributes;
|
||||
@@ -47,6 +49,7 @@ export class MockBaseEntity {
|
||||
this.domain = domain;
|
||||
this.objectId = objectId;
|
||||
this.state = input.state;
|
||||
this.areaId = input.area_id;
|
||||
this.lastChanged = randomTime();
|
||||
this.lastUpdated = randomTime();
|
||||
|
||||
@@ -127,6 +130,8 @@ export class MockBaseEntity {
|
||||
"entity_picture",
|
||||
"assumed_state",
|
||||
"device_class",
|
||||
"state_class",
|
||||
"unit_of_measurement",
|
||||
"supported_features",
|
||||
]) {
|
||||
if (key in attrs) {
|
||||
|
||||
@@ -10,7 +10,10 @@ 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,6 +299,7 @@ export const provideHass = (
|
||||
icon: undefined,
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
area_id: ent.areaId,
|
||||
} satisfies EntityRegistryDisplayEntry;
|
||||
});
|
||||
if (replace) {
|
||||
|
||||
@@ -173,11 +173,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
window.addEventListener("location-changed", () => updateRoute());
|
||||
|
||||
// Handle history changes
|
||||
if (useHash) {
|
||||
window.addEventListener("hashchange", () => updateRoute());
|
||||
} else {
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
}
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
|
||||
@@ -13,8 +13,13 @@ 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> };
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
announceMessage?:
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
action?: ToastActionParams;
|
||||
secondaryAction?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
@@ -23,8 +28,10 @@ export interface ShowToastParams {
|
||||
|
||||
export interface ToastActionParams {
|
||||
action: () => void;
|
||||
primary?: boolean;
|
||||
text:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
}
|
||||
|
||||
@customElement("notification-manager")
|
||||
@@ -93,31 +100,22 @@ 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._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._renderAction(this._parameters.secondaryAction, true)}
|
||||
${this._renderAction(this._parameters.action, false)}
|
||||
${
|
||||
this._parameters?.dismissable
|
||||
? html`
|
||||
@@ -134,11 +132,37 @@ 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");
|
||||
if (this._parameters?.action) {
|
||||
this._parameters?.action.action();
|
||||
}
|
||||
this._parameters?.action?.action();
|
||||
}
|
||||
|
||||
private _secondaryButtonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.secondaryAction?.action();
|
||||
}
|
||||
|
||||
private _dismissClicked() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { provide } from "@lit/context";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { LitElement } from "lit";
|
||||
import type { LitElement, PropertyValues } 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 {
|
||||
@@ -17,6 +18,18 @@ 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.
|
||||
*
|
||||
@@ -26,6 +39,9 @@ export type CompareStrategy<State> =
|
||||
* 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
|
||||
@@ -118,6 +134,24 @@ 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) {
|
||||
|
||||
@@ -203,6 +203,7 @@ class DialogBackupOnboarding
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_step") && this._step === "key") {
|
||||
this._save();
|
||||
}
|
||||
|
||||
@@ -248,6 +248,24 @@ 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"
|
||||
@@ -346,6 +364,18 @@ 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 = {
|
||||
@@ -366,6 +396,10 @@ 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();
|
||||
@@ -384,7 +418,11 @@ export class DialogEnergyBatterySettings
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
ha-input {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
ha-input:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SankeyDeviceNode = Node & { parent?: string };
|
||||
|
||||
interface SmallConsumer {
|
||||
id: string;
|
||||
includedInStat: string | undefined;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
@@ -96,8 +97,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;
|
||||
|
||||
// Parent chain in stat_consumption space, used to detect nested small consumers
|
||||
const deviceParents = new Map<string, string | undefined>();
|
||||
devices.forEach((device) => {
|
||||
deviceParents.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
@@ -115,11 +123,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -142,7 +152,21 @@ export const buildSankeyDeviceNodes = (
|
||||
});
|
||||
|
||||
// Process small consumers - show a lone one directly, group clusters as "Other"
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
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.
|
||||
const consumers = allConsumers.filter((consumer) => {
|
||||
let ancestor = consumer.includedInStat;
|
||||
for (let hops = 0; ancestor && hops < devices.length; hops++) {
|
||||
if (smallConsumerStats.has(ancestor)) {
|
||||
return false;
|
||||
}
|
||||
ancestor = deviceParents.get(ancestor);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
|
||||
@@ -210,16 +210,37 @@ 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 socValues = types
|
||||
.battery!.map((source) =>
|
||||
source.stat_soc
|
||||
const socBatteries = types
|
||||
.battery!.map((source) => ({
|
||||
soc: source.stat_soc
|
||||
? Number(this.hass.states[source.stat_soc]?.state)
|
||||
: NaN
|
||||
)
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (socValues.length) {
|
||||
averageBatterySoc =
|
||||
socValues.reduce((sum, value) => sum + value, 0) / socValues.length;
|
||||
: 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;
|
||||
batteryIconPath = batteryLevelIconPath(averageBatterySoc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!changedProps.has("_badgeConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { mdiSort, mdiStar, mdiStarOutline } from "@mdi/js";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { PreserveScrollPositionController } from "../../../../common/controllers/preserve-scroll-position-controller";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-ripple";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
|
||||
import { saveFrontendUserData } from "../../../../data/frontend";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { CustomCardEntry } from "../../../../data/lovelace_custom_cards";
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
} from "../../../../data/lovelace_custom_cards";
|
||||
import { haStyleScrollbar } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import {
|
||||
calcUnusedEntities,
|
||||
computeUsedEntities,
|
||||
@@ -33,12 +42,18 @@ import type { LovelaceCard } from "../../types";
|
||||
import { getCardStubConfig } from "../get-card-stub-config";
|
||||
import { coreCards, energyCards } from "../lovelace-cards";
|
||||
import type { Card, CardPickTarget } from "../types";
|
||||
import { showReorderFavoriteCardsDialog } from "./show-reorder-favorite-cards-dialog";
|
||||
|
||||
interface CardElement {
|
||||
card: Card;
|
||||
element: TemplateResult;
|
||||
}
|
||||
|
||||
const cardKey = (card: Card): string =>
|
||||
card.isCustom ? `${CUSTOM_TYPE_PREFIX}${card.type}` : card.type;
|
||||
|
||||
const SPINNER = html`<div class="spinner"><ha-spinner></ha-spinner></div>`;
|
||||
|
||||
@customElement("hui-card-picker")
|
||||
export class HuiCardPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -56,6 +71,8 @@ export class HuiCardPicker extends LitElement {
|
||||
|
||||
@state() private _cards: CardElement[] = [];
|
||||
|
||||
@state() private _favorites: string[] = [];
|
||||
|
||||
public lovelace?: LovelaceConfig;
|
||||
|
||||
public cardPicked?: (cardConf: LovelaceCardConfig) => void;
|
||||
@@ -64,10 +81,23 @@ export class HuiCardPicker extends LitElement {
|
||||
|
||||
@query("ha-input-search") private _searchInput?: HaInputSearch;
|
||||
|
||||
@query("#content") private _content?: HTMLElement;
|
||||
|
||||
private _unusedEntities?: string[];
|
||||
|
||||
private _usedEntities?: string[];
|
||||
|
||||
private _suggestedCards: CardElement[] = [];
|
||||
|
||||
private _favoriteElements = new Map<string, CardElement>();
|
||||
|
||||
private _topSection = createRef<HTMLElement>();
|
||||
|
||||
public preserveScrollPosition = new PreserveScrollPositionController(
|
||||
this,
|
||||
this._topSection
|
||||
);
|
||||
|
||||
public async focus(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
// Wait for the input's inner wa-input to render so focus delegation works.
|
||||
@@ -99,18 +129,10 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _suggestedCards = memoizeOne(
|
||||
(cardElements: CardElement[]): CardElement[] =>
|
||||
cardElements.filter(
|
||||
(cardElement: CardElement) => cardElement.card.isSuggested
|
||||
)
|
||||
);
|
||||
|
||||
private _customCards = memoizeOne(
|
||||
(cardElements: CardElement[]): CardElement[] =>
|
||||
cardElements.filter(
|
||||
(cardElement: CardElement) =>
|
||||
cardElement.card.isCustom && !cardElement.card.isSuggested
|
||||
(cardElement: CardElement) => cardElement.card.isCustom
|
||||
)
|
||||
);
|
||||
|
||||
@@ -118,9 +140,7 @@ export class HuiCardPicker extends LitElement {
|
||||
(cardElements: CardElement[]): CardElement[] =>
|
||||
cardElements.filter(
|
||||
(cardElement: CardElement) =>
|
||||
!cardElement.card.isSuggested &&
|
||||
!cardElement.card.isCustom &&
|
||||
!cardElement.card.isEnergy
|
||||
!cardElement.card.isCustom && !cardElement.card.isEnergy
|
||||
)
|
||||
);
|
||||
|
||||
@@ -131,6 +151,34 @@ export class HuiCardPicker extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
private _favoriteCards(): CardElement[] {
|
||||
return this._favorites
|
||||
.map((key) => this._favoriteElement(key))
|
||||
.filter((cardElement): cardElement is CardElement => !!cardElement);
|
||||
}
|
||||
|
||||
// A preview is a live DOM node, so every section a card shows up in needs
|
||||
// its own element.
|
||||
private _toCardElement(card: Card): CardElement {
|
||||
return {
|
||||
card,
|
||||
element: html`${until(this._renderCardElement(card), SPINNER)}`,
|
||||
};
|
||||
}
|
||||
|
||||
private _favoriteElement(key: string): CardElement | undefined {
|
||||
let cardElement = this._favoriteElements.get(key);
|
||||
if (!cardElement) {
|
||||
const card = this._cards.find((item) => cardKey(item.card) === key)?.card;
|
||||
if (!card) {
|
||||
return undefined;
|
||||
}
|
||||
cardElement = this._toCardElement(card);
|
||||
this._favoriteElements.set(key, cardElement);
|
||||
}
|
||||
return cardElement;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -141,7 +189,8 @@ export class HuiCardPicker extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const suggestedCards = this._suggestedCards(this._cards);
|
||||
const favoriteCards = this._favoriteCards();
|
||||
const suggestedCards = favoriteCards.length > 0 ? [] : this._suggestedCards;
|
||||
const othersCards = this._otherCards(this._cards);
|
||||
const energyCardsItems = this._energyCards(this._cards);
|
||||
const customCardsItems = this._customCards(this._cards);
|
||||
@@ -160,53 +209,106 @@ export class HuiCardPicker extends LitElement {
|
||||
this._filter
|
||||
? html`<div class="cards-container">
|
||||
${this._filterCards(this._cards, this._filter).map(
|
||||
(cardElement: CardElement) => cardElement.element
|
||||
(cardElement: CardElement) => this._renderCard(cardElement)
|
||||
)}
|
||||
</div>`
|
||||
: html`
|
||||
${
|
||||
suggestedCards.length > 0
|
||||
? html` <ha-expansion-panel expanded>
|
||||
<div slot="header" class="cards-container-header">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.suggested_cards`
|
||||
)}
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${this._renderClipboardCard()}
|
||||
${suggestedCards.map(
|
||||
(cardElement: CardElement) => cardElement.element
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>`
|
||||
: nothing
|
||||
}
|
||||
<div ${ref(this._topSection)}>
|
||||
${
|
||||
favoriteCards.length > 0
|
||||
? html`<ha-expansion-panel expanded>
|
||||
<div slot="header" class="cards-container-header">
|
||||
<span class="title"
|
||||
>${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.favorite_cards`
|
||||
)}</span
|
||||
>
|
||||
${
|
||||
favoriteCards.length > 1
|
||||
? html`<ha-icon-button
|
||||
class="reorder-favorites"
|
||||
.path=${mdiSort}
|
||||
.label=${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.reorder_favorites`
|
||||
)}
|
||||
@click=${this._reorderFavorites}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${this._renderClipboardCard(this._clipboard, this.hass!.locale)}
|
||||
${repeat(
|
||||
favoriteCards,
|
||||
(cardElement: CardElement) =>
|
||||
cardKey(cardElement.card),
|
||||
(cardElement: CardElement) =>
|
||||
this._renderCard(cardElement)
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
suggestedCards.length > 0
|
||||
? html`<ha-expansion-panel expanded>
|
||||
<div slot="header" class="cards-container-header">
|
||||
<span class="title"
|
||||
>${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.suggested_cards`
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${
|
||||
favoriteCards.length === 0
|
||||
? this._renderClipboardCard(
|
||||
this._clipboard,
|
||||
this.hass!.locale
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${suggestedCards.map((cardElement: CardElement) =>
|
||||
this._renderCard(cardElement)
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-expansion-panel expanded>
|
||||
<div slot="header" class="cards-container-header">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.core_cards`
|
||||
)}
|
||||
<span class="title"
|
||||
>${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.core_cards`
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${
|
||||
suggestedCards.length === 0
|
||||
? this._renderClipboardCard()
|
||||
favoriteCards.length === 0 && suggestedCards.length === 0
|
||||
? this._renderClipboardCard(
|
||||
this._clipboard,
|
||||
this.hass!.locale
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${othersCards.map(
|
||||
(cardElement: CardElement) => cardElement.element
|
||||
${othersCards.map((cardElement: CardElement) =>
|
||||
this._renderCard(cardElement)
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
<ha-expansion-panel>
|
||||
<div slot="header" class="cards-container-header">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.energy_cards`
|
||||
)}
|
||||
<span class="title"
|
||||
>${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.energy_cards`
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${energyCardsItems.map(
|
||||
(cardElement: CardElement) => cardElement.element
|
||||
${energyCardsItems.map((cardElement: CardElement) =>
|
||||
this._renderCard(cardElement)
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
@@ -215,13 +317,15 @@ export class HuiCardPicker extends LitElement {
|
||||
? html`
|
||||
<ha-expansion-panel expanded>
|
||||
<div slot="header" class="cards-container-header">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.custom_cards`
|
||||
)}
|
||||
<span class="title"
|
||||
>${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.custom_cards`
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="cards-container">
|
||||
${customCardsItems.map(
|
||||
(cardElement: CardElement) => cardElement.element
|
||||
${customCardsItems.map((cardElement: CardElement) =>
|
||||
this._renderCard(cardElement)
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
@@ -254,16 +358,12 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (changedProps.size > 1 || !changedProps.has("hass")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (oldHass.locale !== this.hass!.locale) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return !oldHass || oldHass.locale !== this.hass!.locale;
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
@@ -283,16 +383,15 @@ export class HuiCardPicker extends LitElement {
|
||||
this._usedEntities = [...usedEntities].filter(isAvailable);
|
||||
this._unusedEntities = [...unusedEntities].filter(isAvailable);
|
||||
|
||||
this._favorites = this.hass.userData?.dashboard_favorite_card_types ?? [];
|
||||
|
||||
this._loadCards();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_filter")) {
|
||||
const div = this.shadowRoot!.getElementById("content");
|
||||
if (div) {
|
||||
div.scrollTo({ behavior: "auto", top: 0 });
|
||||
}
|
||||
this._content?.scrollTo({ behavior: "auto", top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,19 +407,9 @@ export class HuiCardPicker extends LitElement {
|
||||
...card,
|
||||
}));
|
||||
|
||||
cards = cards.sort((a, b) => {
|
||||
if (a.isSuggested && !b.isSuggested) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.isSuggested && b.isSuggested) {
|
||||
return 1;
|
||||
}
|
||||
return stringCompare(
|
||||
a.name || a.type,
|
||||
b.name || b.type,
|
||||
this.hass?.language
|
||||
);
|
||||
});
|
||||
cards = cards.sort((a, b) =>
|
||||
stringCompare(a.name || a.type, b.name || b.type, this.hass?.language)
|
||||
);
|
||||
|
||||
cards = cards.concat(
|
||||
energyCards.map((card: Card) => ({
|
||||
@@ -354,48 +443,126 @@ export class HuiCardPicker extends LitElement {
|
||||
)
|
||||
);
|
||||
}
|
||||
this._cards = cards.map((card: Card) => ({
|
||||
card: card,
|
||||
element: html`${until(
|
||||
this._renderCardElement(card),
|
||||
html`
|
||||
<div class="card spinner">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
)}`,
|
||||
}));
|
||||
this._cards = cards.map((card: Card) => this._toCardElement(card));
|
||||
this._suggestedCards = cards
|
||||
.filter((card: Card) => card.isSuggested)
|
||||
.map((card: Card) => this._toCardElement(card));
|
||||
}
|
||||
|
||||
private _renderClipboardCard() {
|
||||
if (!this._clipboard) {
|
||||
return nothing;
|
||||
}
|
||||
private _renderClipboardCard = memoizeOne(
|
||||
(clipboard: LovelaceCardConfig | undefined, _locale: unknown) => {
|
||||
if (!clipboard) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html` ${until(
|
||||
this._renderCardElement(
|
||||
{
|
||||
type: this._clipboard.type,
|
||||
showElement: true,
|
||||
isCustom: false,
|
||||
name: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.paste"
|
||||
),
|
||||
description: `${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.paste_description",
|
||||
return html`<div class="card" tabindex="0">
|
||||
${until(
|
||||
this._renderCardElement(
|
||||
{
|
||||
type: this._clipboard.type,
|
||||
}
|
||||
)}`,
|
||||
},
|
||||
this._clipboard
|
||||
type: clipboard.type,
|
||||
showElement: true,
|
||||
isCustom: false,
|
||||
name: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.paste"
|
||||
),
|
||||
description: `${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.paste_description",
|
||||
{
|
||||
type: clipboard.type,
|
||||
}
|
||||
)}`,
|
||||
},
|
||||
clipboard
|
||||
),
|
||||
SPINNER
|
||||
)}
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>`;
|
||||
}
|
||||
);
|
||||
|
||||
private _renderCard(cardElement: CardElement): TemplateResult {
|
||||
const key = cardKey(cardElement.card);
|
||||
const favorite = this._favorites.includes(key);
|
||||
|
||||
return html`
|
||||
<div class="card" tabindex="0">
|
||||
${cardElement.element}
|
||||
<ha-icon-button
|
||||
class="favorite ${classMap({ selected: favorite })}"
|
||||
.path=${favorite ? mdiStar : mdiStarOutline}
|
||||
.label=${this.hass!.localize(
|
||||
favorite
|
||||
? "ui.panel.lovelace.editor.card.generic.remove_favorite"
|
||||
: "ui.panel.lovelace.editor.card.generic.add_favorite"
|
||||
)}
|
||||
data-card=${key}
|
||||
@click=${this._toggleFavorite}
|
||||
@pointerdown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setFavorite(key: string, value: boolean): void {
|
||||
const favorites = this._favorites.filter((favorite) => favorite !== key);
|
||||
this._favorites = value ? [...favorites, key] : favorites;
|
||||
}
|
||||
|
||||
private async _toggleFavorite(ev: Event): Promise<void> {
|
||||
ev.stopPropagation();
|
||||
const key = (ev.currentTarget as HTMLElement).dataset.card!;
|
||||
|
||||
const adding = !this._favorites.includes(key);
|
||||
this._setFavorite(key, adding);
|
||||
|
||||
try {
|
||||
await this._persistFavorites();
|
||||
} catch (_err: any) {
|
||||
this._setFavorite(key, !adding);
|
||||
this._showSaveError();
|
||||
}
|
||||
}
|
||||
|
||||
private _reorderFavorites(ev: Event): void {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showReorderFavoriteCardsDialog(this, {
|
||||
favorites: this._favoriteCards().map((cardElement) => ({
|
||||
key: cardKey(cardElement.card),
|
||||
name: cardElement.card.name || cardElement.card.type,
|
||||
})),
|
||||
saveFavorites: (favorites) => this._saveFavorites(favorites),
|
||||
});
|
||||
}
|
||||
|
||||
private async _saveFavorites(favorites: string[]): Promise<void> {
|
||||
const previous = this._favorites;
|
||||
this._favorites = favorites;
|
||||
|
||||
try {
|
||||
await this._persistFavorites();
|
||||
} catch (_err: any) {
|
||||
this._favorites = previous;
|
||||
this._showSaveError();
|
||||
}
|
||||
}
|
||||
|
||||
private _persistFavorites(): Promise<void> {
|
||||
return saveFrontendUserData(this.hass!.connection, "core", {
|
||||
...this.hass!.userData,
|
||||
dashboard_favorite_card_types: this._favorites,
|
||||
});
|
||||
}
|
||||
|
||||
private _showSaveError(): void {
|
||||
showToast(this, {
|
||||
message: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.favorite_save_failed"
|
||||
),
|
||||
html`
|
||||
<div class="card spinner">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
)}`;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: Event) {
|
||||
@@ -477,32 +644,29 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="card" tabindex="0">
|
||||
<div
|
||||
class="overlay"
|
||||
@click=${this._cardPicked}
|
||||
.config=${cardConfig}
|
||||
></div>
|
||||
<div class="card-header">
|
||||
${customCard ? customCard.name || customCard.type : name}
|
||||
</div>
|
||||
<div
|
||||
class="preview ${classMap({
|
||||
description: !element || element.tagName === "HUI-ERROR-CARD",
|
||||
})}"
|
||||
>
|
||||
${
|
||||
element && element.tagName !== "HUI-ERROR-CARD"
|
||||
? element
|
||||
: customCard
|
||||
? customCard.description ||
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.cardpicker.no_description`
|
||||
)
|
||||
: description
|
||||
}
|
||||
</div>
|
||||
<ha-ripple></ha-ripple>
|
||||
<div
|
||||
class="overlay"
|
||||
@click=${this._cardPicked}
|
||||
.config=${cardConfig}
|
||||
></div>
|
||||
<div class="card-header">
|
||||
${customCard ? customCard.name || customCard.type : name}
|
||||
</div>
|
||||
<div
|
||||
class="preview ${classMap({
|
||||
description: !element || element.tagName === "HUI-ERROR-CARD",
|
||||
})}"
|
||||
>
|
||||
${
|
||||
element && element.tagName !== "HUI-ERROR-CARD"
|
||||
? element
|
||||
: customCard
|
||||
? customCard.description ||
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.cardpicker.no_description`
|
||||
)
|
||||
: description
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -531,6 +695,10 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
|
||||
.cards-container-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
min-width: 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: var(--ha-space-3) var(--ha-space-2);
|
||||
@@ -578,11 +746,55 @@ export class HuiCardPicker extends LitElement {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
letter-spacing: -0.012em;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
padding: var(--ha-space-3) var(--ha-space-11);
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cards-container-header .title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reorder-favorites {
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
/* Keeps the hit area without growing the header row */
|
||||
margin-block: calc(-1 * var(--ha-space-1));
|
||||
--ha-icon-button-size: var(--ha-space-8);
|
||||
--mdc-icon-size: var(--ha-space-5);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
position: absolute;
|
||||
top: var(--ha-space-1);
|
||||
inset-inline-end: var(--ha-space-1);
|
||||
z-index: 2;
|
||||
color: var(--secondary-text-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
|
||||
--ha-icon-button-size: var(--ha-space-10);
|
||||
}
|
||||
|
||||
.card:hover .favorite,
|
||||
.card:focus-within .favorite,
|
||||
.favorite.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.favorite.selected {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
pointer-events: none;
|
||||
margin: var(--ha-space-5);
|
||||
@@ -602,6 +814,8 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
|
||||
.spinner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -622,23 +836,6 @@ export class HuiCardPicker extends LitElement {
|
||||
max-width: none;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
right: var(--ha-space-2);
|
||||
inset-inline-start: var(--ha-space-2);
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--mdc-icon-size: var(--ha-space-4);
|
||||
line-height: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary-color);
|
||||
padding: var(--ha-space-1);
|
||||
}
|
||||
.icon.custom {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
FavoriteCardItem,
|
||||
ReorderFavoriteCardsDialogParams,
|
||||
} from "./show-reorder-favorite-cards-dialog";
|
||||
|
||||
@customElement("hui-dialog-reorder-favorite-cards")
|
||||
export class HuiDialogReorderFavoriteCards extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: ReorderFavoriteCardsDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _favorites: FavoriteCardItem[] = [];
|
||||
|
||||
public async showDialog(
|
||||
params: ReorderFavoriteCardsDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._favorites = params.favorites;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._favorites = [];
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.reorder_favorites"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="favorites">
|
||||
${repeat(
|
||||
this._favorites,
|
||||
(favorite) => favorite.key,
|
||||
(favorite) => html`
|
||||
<div class="favorite">
|
||||
<div class="handle">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<span class="name">${favorite.name}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const favorites = [...this._favorites];
|
||||
const [moved] = favorites.splice(oldIndex, 1);
|
||||
favorites.splice(newIndex, 0, moved);
|
||||
|
||||
this._favorites = favorites;
|
||||
this._params!.saveFavorites(favorites.map((favorite) => favorite.key));
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
.favorites {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.favorite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: var(--ha-space-12);
|
||||
}
|
||||
.handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
padding: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-reorder-favorite-cards": HuiDialogReorderFavoriteCards;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
export interface FavoriteCardItem {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ReorderFavoriteCardsDialogParams {
|
||||
favorites: FavoriteCardItem[];
|
||||
saveFavorites: (favorites: string[]) => void;
|
||||
}
|
||||
|
||||
export const importReorderFavoriteCardsDialog = () =>
|
||||
import("./hui-dialog-reorder-favorite-cards");
|
||||
|
||||
export const showReorderFavoriteCardsDialog = (
|
||||
element: HTMLElement,
|
||||
reorderFavoriteCardsDialogParams: ReorderFavoriteCardsDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-reorder-favorite-cards",
|
||||
dialogImport: importReorderFavoriteCardsDialog,
|
||||
dialogParams: reorderFavoriteCardsDialogParams,
|
||||
});
|
||||
};
|
||||
@@ -76,6 +76,7 @@ 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,6 +94,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const viewConfig = {
|
||||
...this._config,
|
||||
|
||||
@@ -43,6 +43,7 @@ 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,6 +44,7 @@ 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=${stateObj.state}
|
||||
.value=${Number(stateObj.state).toString()}
|
||||
type="number"
|
||||
@change=${this._selectedValueChanged}
|
||||
>
|
||||
|
||||
@@ -104,6 +104,7 @@ 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,6 +2,7 @@ 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,
|
||||
@@ -107,7 +108,24 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
generateEntityFilter(hass, filter)
|
||||
);
|
||||
|
||||
const entitiesWithoutAreas = findEntities(allEntities, otherDevicesFilters);
|
||||
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 floorsSections: LovelaceSectionConfig[] = [];
|
||||
for (const floorStructure of home.floors) {
|
||||
@@ -140,13 +158,13 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (home.areas.length > 0 || entitiesWithoutAreas.length > 0) {
|
||||
if (home.areas.length > 0 || hasOtherDevices) {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
for (const areaId of home.areas) {
|
||||
cards.push(computeAreaCard(areaId, hass));
|
||||
}
|
||||
|
||||
if (entitiesWithoutAreas.length > 0) {
|
||||
if (hasOtherDevices) {
|
||||
cards.push({
|
||||
type: "tile",
|
||||
entity: "zone.home", // zone entity to represent unassigned area as it always exists
|
||||
|
||||
@@ -101,8 +101,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
const columns = Math.floor(
|
||||
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
|
||||
);
|
||||
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
return clamp(columns, 1, maxColumns);
|
||||
return Math.max(columns, 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,7 +154,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _updateMaxColumnCount(): void {
|
||||
const maxColumnCount = this._columnsController.value ?? 1;
|
||||
// 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
|
||||
);
|
||||
|
||||
if (maxColumnCount !== this._maxColumns) {
|
||||
this._maxColumns = maxColumnCount;
|
||||
|
||||
@@ -1193,6 +1193,11 @@
|
||||
"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",
|
||||
@@ -2510,8 +2515,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.",
|
||||
"reload": "Reload",
|
||||
"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",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
"onboarding_survey": {
|
||||
@@ -4244,6 +4249,8 @@
|
||||
"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",
|
||||
@@ -5669,7 +5676,7 @@
|
||||
},
|
||||
"trigger": {
|
||||
"label": "Triggered by",
|
||||
"no_triggers": "There are no triggers with ID's set in this automation. Edit a trigger and give it a Trigger ID name.",
|
||||
"no_triggers": "There are no triggers with IDs 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.",
|
||||
@@ -10056,6 +10063,11 @@
|
||||
"core_cards": "Core cards",
|
||||
"energy_cards": "Energy cards",
|
||||
"custom_cards": "Community cards",
|
||||
"favorite_cards": "Favorite cards",
|
||||
"add_favorite": "Add to favorites",
|
||||
"remove_favorite": "Remove from favorites",
|
||||
"reorder_favorites": "Reorder favorites",
|
||||
"favorite_save_failed": "Failed to save favorite cards",
|
||||
"round_temperature": "Round temperature",
|
||||
"features": "Features",
|
||||
"actions": "Actions",
|
||||
@@ -11252,6 +11264,7 @@
|
||||
"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,5 +1,8 @@
|
||||
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");
|
||||
@@ -23,6 +26,92 @@ 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;
|
||||
|
||||
@@ -37,22 +126,11 @@ export const registerServiceWorker = async (
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
updateReady(installingWorker);
|
||||
});
|
||||
});
|
||||
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
updateReady(reg.waiting);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,22 @@ 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];
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import "../../src/managers/notification-manager";
|
||||
|
||||
vi.mock("../../src/components/ha-button", () => {
|
||||
customElements.define("ha-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
const localize = (key: string, args?: Record<string, string | number>) =>
|
||||
args ? `${key}:${JSON.stringify(args)}` : key;
|
||||
|
||||
@customElement("test-notification-lifecycle-host")
|
||||
class TestNotificationLifecycleHost extends LitElement {
|
||||
@property({ attribute: false }) public hass = {
|
||||
localize,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
protected render() {
|
||||
return html`<notification-manager
|
||||
.hass=${this.hass}
|
||||
></notification-manager>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
|
||||
}
|
||||
}
|
||||
|
||||
let host: TestNotificationLifecycleHost | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("notification toast lifecycle", () => {
|
||||
it("updates a visible toast and closes it after its primary action", async () => {
|
||||
vi.useFakeTimers();
|
||||
host = document.createElement("test-notification-lifecycle-host");
|
||||
document.body.append(host);
|
||||
await host.updateComplete;
|
||||
const manager = host.shadowRoot!.querySelector("notification-manager")!;
|
||||
const action = vi.fn();
|
||||
|
||||
const firstShow = manager.showDialog({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: { action, primary: true, text: "Update now" },
|
||||
duration: -1,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await manager.updateComplete;
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
Object.defineProperty(
|
||||
toast.shadowRoot!.querySelector(".toast"),
|
||||
"getAnimations",
|
||||
{
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
}
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await firstShow;
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
|
||||
"visible"
|
||||
);
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":60'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
await manager.showDialog({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 59 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: { action, primary: true, text: "Update now" },
|
||||
duration: -1,
|
||||
});
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":59'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
toast.addEventListener("toast-closed", () => resolve(), { once: true });
|
||||
});
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
|
||||
await closed;
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import "../../src/managers/notification-manager";
|
||||
|
||||
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
|
||||
args ? `${key}:${JSON.stringify(args)}` : key
|
||||
);
|
||||
|
||||
vi.mock("../../src/components/ha-button", () => {
|
||||
customElements.define("ha-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/ha-icon-button", () => {
|
||||
customElements.define("ha-icon-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/ha-toast", () => {
|
||||
customElements.define(
|
||||
"ha-toast",
|
||||
class extends HTMLElement {
|
||||
public show = vi.fn();
|
||||
|
||||
public hide = vi.fn();
|
||||
}
|
||||
);
|
||||
return {};
|
||||
});
|
||||
|
||||
@customElement("test-notification-manager-host")
|
||||
class TestNotificationManagerHost extends LitElement {
|
||||
@property({ attribute: false }) public hass = {
|
||||
localize,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
protected render() {
|
||||
return html`<notification-manager
|
||||
.hass=${this.hass}
|
||||
></notification-manager>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-notification-manager-host": TestNotificationManagerHost;
|
||||
}
|
||||
}
|
||||
|
||||
let host: TestNotificationManagerHost | undefined;
|
||||
|
||||
const mountManager = async () => {
|
||||
host = document.createElement(
|
||||
"test-notification-manager-host"
|
||||
) as TestNotificationManagerHost;
|
||||
document.body.append(host);
|
||||
await host.updateComplete;
|
||||
return host.shadowRoot!.querySelector("notification-manager")!;
|
||||
};
|
||||
|
||||
const deferred = () => {
|
||||
let resolve: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("notification-manager", () => {
|
||||
it("shows a message with the default duration", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({ message: "Configuration saved" });
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.labelText).toBe("Configuration saved");
|
||||
expect(toast.timeoutMs).toBe(4000);
|
||||
expect(toast.bottomOffset).toBe(0);
|
||||
expect(toast.show).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders a dismiss button and closes with the dismiss reason", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: "Connection lost",
|
||||
dismissable: true,
|
||||
duration: -1,
|
||||
bottomOffset: 16,
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.timeoutMs).toBe(-1);
|
||||
expect(toast.bottomOffset).toBe(16);
|
||||
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
|
||||
expect(toast.hide).toHaveBeenCalledWith("dismiss");
|
||||
});
|
||||
|
||||
it("clears the current notification when duration is zero", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ message: "First message", duration: -1 });
|
||||
|
||||
await manager.showDialog({ message: "", duration: 0 });
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ duration: 1, expected: 4000 },
|
||||
{ duration: 4000, expected: 4000 },
|
||||
{ duration: 4001, expected: 4001 },
|
||||
{ duration: -1, expected: -1 },
|
||||
])(
|
||||
"normalizes a $duration ms duration to $expected ms",
|
||||
async ({ duration, expected }) => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({ message: "Message", duration });
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("localizes visible and assistive messages with numeric arguments", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 59 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.labelText).toContain('"seconds":59');
|
||||
expect(toast.announceText).toContain('"seconds":60');
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 59 }
|
||||
);
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 60 }
|
||||
);
|
||||
});
|
||||
|
||||
it("renders and invokes primary and secondary actions", async () => {
|
||||
const manager = await mountManager();
|
||||
const primary = vi.fn();
|
||||
const secondary = vi.fn();
|
||||
|
||||
await manager.showDialog({
|
||||
message: "Update available",
|
||||
action: { action: primary, primary: true, text: "Update now" },
|
||||
secondaryAction: { action: secondary, text: "Cancel" },
|
||||
duration: -1,
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0].textContent?.trim()).toBe("Cancel");
|
||||
expect(buttons[0].getAttribute("appearance")).toBe("plain");
|
||||
expect(buttons[1].textContent?.trim()).toBe("Update now");
|
||||
expect(buttons[1].getAttribute("appearance")).toBe("filled");
|
||||
|
||||
buttons[0].click();
|
||||
expect(toast.hide).toHaveBeenLastCalledWith("action");
|
||||
expect(secondary).toHaveBeenCalledOnce();
|
||||
expect(primary).not.toHaveBeenCalled();
|
||||
|
||||
buttons[1].click();
|
||||
expect(toast.hide).toHaveBeenLastCalledWith("action");
|
||||
expect(primary).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a same-ID toast visible while replacing its content", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "status", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
vi.mocked(toast.hide).mockClear();
|
||||
|
||||
await manager.showDialog({ id: "status", message: "Second" });
|
||||
|
||||
expect(toast.hide).not.toHaveBeenCalled();
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("hides a toast before replacing it with a different ID", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
vi.mocked(toast.hide).mockClear();
|
||||
|
||||
await manager.showDialog({ id: "second", message: "Second" });
|
||||
|
||||
expect(toast.hide).toHaveBeenCalledOnce();
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("ignores a stale replacement after a newer notification starts", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const delayedHide = deferred();
|
||||
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
|
||||
|
||||
const staleShow = manager.showDialog({ id: "second", message: "Second" });
|
||||
await manager.showDialog({ id: "third", message: "Third" });
|
||||
delayedHide.resolve();
|
||||
await staleShow;
|
||||
|
||||
expect(toast.labelText).toBe("Third");
|
||||
});
|
||||
|
||||
it("clears rendered state after the toast closes", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ message: "Message" });
|
||||
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
|
||||
new CustomEvent("toast-closed", {
|
||||
detail: { reason: "programmatic" },
|
||||
})
|
||||
);
|
||||
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { LitElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dirtyStateContext,
|
||||
type DirtyStateContext,
|
||||
} from "../../src/data/context/dirty-state";
|
||||
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
|
||||
interface TestState {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
options?: { mode: string };
|
||||
}
|
||||
|
||||
class TestDirtyStateConsumer extends LitElement {
|
||||
@consume({ context: dirtyStateContext, subscribe: true })
|
||||
@state()
|
||||
public dirtyState?: DirtyStateContext;
|
||||
}
|
||||
|
||||
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
|
||||
|
||||
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
|
||||
LitElement
|
||||
) {
|
||||
public initialize(
|
||||
strategy: CompareStrategy<TestState>,
|
||||
value?: TestState,
|
||||
normalize?: (value: TestState) => TestState
|
||||
) {
|
||||
this._initDirtyTracking(strategy, value, normalize);
|
||||
}
|
||||
|
||||
public setValue(value: TestState) {
|
||||
this._updateDirtyState(value);
|
||||
}
|
||||
|
||||
public markClean() {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
|
||||
public discardChanges() {
|
||||
this._discardDirtyStateChanges();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-dirty-state-consumer": TestDirtyStateConsumer;
|
||||
"test-dirty-state-provider": TestDirtyStateProvider;
|
||||
}
|
||||
}
|
||||
|
||||
let provider: TestDirtyStateProvider | undefined;
|
||||
|
||||
const mountProvider = async () => {
|
||||
provider = document.createElement(
|
||||
"test-dirty-state-provider"
|
||||
) as TestDirtyStateProvider;
|
||||
document.body.append(provider);
|
||||
await provider.updateComplete;
|
||||
return provider;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
provider = undefined;
|
||||
window.isDirtyState = false;
|
||||
});
|
||||
|
||||
describe("DirtyStateProviderMixin", () => {
|
||||
it("tracks changes against an initial state", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Kitchen light", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the current state as clean", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Kitchen light", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
element.markClean();
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("discards changes back to the initial state", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Kitchen light", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
element.discardChanges();
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
expect(element.isEffectiveDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("uses deep comparison for nested values", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "deep" },
|
||||
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
|
||||
);
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "manual" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("uses reference comparison for nested values with a shallow strategy", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
|
||||
);
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("supports a custom comparison strategy", async () => {
|
||||
const element = await mountProvider();
|
||||
const compare = vi.fn(
|
||||
(initial: TestState, current: TestState) =>
|
||||
initial.name.toLowerCase() === current.name.toLowerCase()
|
||||
);
|
||||
element.initialize(
|
||||
{ type: "custom", compare },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "KITCHEN", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
expect(compare).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the deep baseline isolated from initial state mutations", async () => {
|
||||
const element = await mountProvider();
|
||||
const initial = {
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
};
|
||||
element.initialize({ type: "deep" }, initial);
|
||||
initial.options.mode = "manual";
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the first deferred value as its baseline", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize({ type: "shallow" });
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks raw and effective dirty state separately", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false },
|
||||
(value) => ({ ...value, enabled: false })
|
||||
);
|
||||
element.setValue({ name: "Kitchen", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
expect(element.isEffectiveDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes dirty and clean transitions with event details", async () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("dirty-state-changed", listener);
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
await element.updateComplete;
|
||||
listener.mockClear();
|
||||
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
|
||||
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
window.removeEventListener("dirty-state-changed", listener);
|
||||
});
|
||||
|
||||
it("clears published dirty state when disconnected", async () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("dirty-state-changed", listener);
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
listener.mockClear();
|
||||
|
||||
element.remove();
|
||||
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
|
||||
window.removeEventListener("dirty-state-changed", listener);
|
||||
});
|
||||
|
||||
it("hands global dirty state to the next provider after disconnecting", async () => {
|
||||
const firstProvider = await mountProvider();
|
||||
firstProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
firstProvider.setValue({ name: "Bedroom", enabled: false });
|
||||
await firstProvider.updateComplete;
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
firstProvider.remove();
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
|
||||
const nextProvider = await mountProvider();
|
||||
nextProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Hallway", enabled: false }
|
||||
);
|
||||
nextProvider.setValue({ name: "Hallway", enabled: true });
|
||||
await nextProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("stays globally dirty while any connected provider is dirty", async () => {
|
||||
const dirtyProvider = await mountProvider();
|
||||
dirtyProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
|
||||
await dirtyProvider.updateComplete;
|
||||
|
||||
const cleanProvider = document.createElement("test-dirty-state-provider");
|
||||
document.body.append(cleanProvider);
|
||||
cleanProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Hallway", enabled: false }
|
||||
);
|
||||
await cleanProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
cleanProvider.remove();
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
|
||||
await dirtyProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a provider's dirty state after reconnecting", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
element.remove();
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
|
||||
document.body.append(element);
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks independent context slices and marks all of them clean", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize({ type: "shallow" });
|
||||
const consumer = document.createElement("test-dirty-state-consumer");
|
||||
element.append(consumer);
|
||||
await consumer.updateComplete;
|
||||
const setState = (value: TestState, key: "first" | "second") => {
|
||||
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
|
||||
};
|
||||
|
||||
setState({ name: "Kitchen", enabled: false }, "first");
|
||||
setState({ name: "Bedroom", enabled: false }, "second");
|
||||
await element.updateComplete;
|
||||
setState({ name: "Kitchen light", enabled: false }, "first");
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
|
||||
consumer.dirtyState!.markClean();
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -231,6 +231,96 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
// the top-level parent (10), never the cluster total.
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a nested small device only once (via its small ancestor)", () => {
|
||||
// child's consumption is already included in parent's statistic
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 0.008, child: 0.006 },
|
||||
initialUntracked: 10,
|
||||
})
|
||||
);
|
||||
// Only the parent remains, so it is shown directly instead of "Other"
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["parent"]);
|
||||
expect(result.deviceNodes[0].value).toBeCloseTo(0.008, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
|
||||
});
|
||||
|
||||
it("excludes nested small devices from the Other total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" },
|
||||
{ stat_consumption: "unrelated" }
|
||||
),
|
||||
values: { parent: 0.004, child: 0.003, unrelated: 0.002 },
|
||||
initialUntracked: 10,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
// parent + unrelated only; child is inside parent's value
|
||||
expect(other?.value).toBeCloseTo(0.006, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.006, 10);
|
||||
});
|
||||
|
||||
it("keeps only the top ancestor of a nested small-device chain", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "grandparent" },
|
||||
{ stat_consumption: "parent", included_in_stat: "grandparent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { grandparent: 0.005, parent: 0.004, child: 0.003 },
|
||||
initialUntracked: 10,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["grandparent"]);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.005, 10);
|
||||
});
|
||||
|
||||
it("detects a small ancestor across a device the card skips entirely", () => {
|
||||
// Power-card scenario: "outlet" has no stat_rate so it is never rendered
|
||||
// or collected, but the plug's consumption still flows through it into
|
||||
// the heater group. Requires walking the chain, not a direct-parent check.
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "heater_group" },
|
||||
{ stat_consumption: "outlet", included_in_stat: "heater_group" },
|
||||
{ stat_consumption: "plug", included_in_stat: "outlet" }
|
||||
),
|
||||
values: { heater_group: 0.008, plug: 0.005 },
|
||||
initialUntracked: 10,
|
||||
getId: (device) =>
|
||||
device.stat_consumption === "outlet"
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["heater_group"]);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
|
||||
});
|
||||
|
||||
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", included_in_stat: "b" },
|
||||
{ stat_consumption: "b", included_in_stat: "a" }
|
||||
),
|
||||
values: { a: 0.003, b: 0.004 },
|
||||
initialUntracked: 10,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes).toEqual([]);
|
||||
expect(result.untrackedConsumption).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShowToastParams } from "../../src/managers/notification-manager";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
} from "../../src/util/register-service-worker";
|
||||
|
||||
class FakeWorker extends EventTarget {
|
||||
public state: ServiceWorkerState = "installing";
|
||||
|
||||
public postMessage = vi.fn();
|
||||
}
|
||||
|
||||
class FakeRegistration extends EventTarget {
|
||||
public installing: ServiceWorker | null = null;
|
||||
|
||||
public waiting: ServiceWorker | null = null;
|
||||
}
|
||||
|
||||
class FakeServiceWorkerContainer extends EventTarget {
|
||||
public controller: ServiceWorker | null = null;
|
||||
|
||||
public register = vi.fn();
|
||||
}
|
||||
|
||||
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"serviceWorker"
|
||||
);
|
||||
|
||||
const restoreServiceWorker = () => {
|
||||
if (serviceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
}
|
||||
};
|
||||
|
||||
describe("supportsServiceWorker", () => {
|
||||
afterEach(restoreServiceWorker);
|
||||
|
||||
it("returns true when the service worker API is available", () => {
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: new FakeServiceWorkerContainer(),
|
||||
});
|
||||
|
||||
expect(supportsServiceWorker()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the service worker API is unavailable", () => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
expect(supportsServiceWorker()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
let root: HTMLElement;
|
||||
let worker: FakeWorker;
|
||||
let registration: FakeRegistration;
|
||||
let serviceWorker: FakeServiceWorkerContainer;
|
||||
let notifications: ShowToastParams[];
|
||||
|
||||
const latestNotification = () => notifications[notifications.length - 1];
|
||||
|
||||
const setDirtyState = (isDirty: boolean) => {
|
||||
window.isDirtyState = isDirty;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
|
||||
);
|
||||
};
|
||||
|
||||
const installUpdate = () => {
|
||||
registration.installing = worker as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
worker.state = "installed";
|
||||
worker.dispatchEvent(new Event("statechange"));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.__BUILD__ = "modern";
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
window.isDirtyState = false;
|
||||
notifications = [];
|
||||
root = document.createElement("div");
|
||||
root.addEventListener("hass-notification", (event) => {
|
||||
notifications.push((event as CustomEvent<ShowToastParams>).detail);
|
||||
});
|
||||
worker = new FakeWorker();
|
||||
registration = new FakeRegistration();
|
||||
serviceWorker = new FakeServiceWorkerContainer();
|
||||
serviceWorker.controller = worker as unknown as ServiceWorker;
|
||||
serviceWorker.register.mockResolvedValue(registration);
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
latestNotification()?.secondaryAction?.action();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
window.isDirtyState = false;
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
restoreServiceWorker();
|
||||
});
|
||||
|
||||
it("registers the worker for the current build", async () => {
|
||||
await registerServiceWorker(root, false);
|
||||
|
||||
expect(serviceWorker.register).toHaveBeenCalledOnce();
|
||||
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
|
||||
});
|
||||
|
||||
it("does not register when service workers are unsupported", async () => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(serviceWorker.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("listens for controller changes before registering", async () => {
|
||||
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
|
||||
|
||||
await registerServiceWorker(root, false);
|
||||
|
||||
expect(addEventListener).toHaveBeenCalledWith(
|
||||
"controllerchange",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
serviceWorker.register.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ notifyUpdate: false, dev: false, demo: false },
|
||||
{ notifyUpdate: true, dev: true, demo: false },
|
||||
{ notifyUpdate: true, dev: false, demo: true },
|
||||
])(
|
||||
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
|
||||
async ({ notifyUpdate, dev, demo }) => {
|
||||
globalThis.__DEV__ = dev;
|
||||
globalThis.__DEMO__ = demo;
|
||||
const addEventListener = vi.spyOn(registration, "addEventListener");
|
||||
|
||||
await registerServiceWorker(root, notifyUpdate);
|
||||
|
||||
expect(addEventListener).not.toHaveBeenCalledWith(
|
||||
"updatefound",
|
||||
expect.any(Function)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("ignores update discovery without an installing worker", async () => {
|
||||
await registerServiceWorker(root);
|
||||
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("waits for an installing worker to reach the installed state", async () => {
|
||||
await registerServiceWorker(root);
|
||||
registration.installing = worker as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
|
||||
worker.state = "activating";
|
||||
worker.dispatchEvent(new Event("statechange"));
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores an initial installation without an existing controller", async () => {
|
||||
serviceWorker.controller = null;
|
||||
await registerServiceWorker(root);
|
||||
|
||||
installUpdate();
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts at 60 seconds when an update is installed", async () => {
|
||||
await registerServiceWorker(root);
|
||||
|
||||
installUpdate();
|
||||
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
announceMessage: {
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: {
|
||||
primary: true,
|
||||
text: { translationKey: "ui.notification_toast.update_now" },
|
||||
},
|
||||
secondaryAction: {
|
||||
text: { translationKey: "ui.common.cancel" },
|
||||
},
|
||||
duration: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an already waiting worker", async () => {
|
||||
registration.waiting = worker as unknown as ServiceWorker;
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a waiting worker without an existing controller", async () => {
|
||||
registration.waiting = worker as unknown as ServiceWorker;
|
||||
serviceWorker.controller = null;
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ elapsed: 1_000, visual: 59, announced: 60 },
|
||||
{ elapsed: 20_000, visual: 40, announced: 40 },
|
||||
{ elapsed: 40_000, visual: 20, announced: 20 },
|
||||
{ elapsed: 55_000, visual: 5, announced: 5 },
|
||||
{ elapsed: 59_000, visual: 1, announced: 5 },
|
||||
])(
|
||||
"shows $visual seconds and announces $announced after $elapsed ms",
|
||||
async ({ elapsed, visual, announced }) => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
vi.advanceTimersByTime(elapsed);
|
||||
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: visual },
|
||||
});
|
||||
expect(latestNotification().announceMessage).toMatchObject({
|
||||
args: { seconds: announced },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("activates and hides the update when the countdown finishes", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledOnce();
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("activates and hides the update immediately on request", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
latestNotification().action!.action();
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledOnce();
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
|
||||
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
|
||||
});
|
||||
|
||||
it("cancels automatic activation", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
latestNotification().secondaryAction!.action();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers the message and activation while the page is dirty", async () => {
|
||||
setDirtyState(true);
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
setDirtyState(false);
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides and resets the countdown when the page becomes dirty", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
setDirtyState(true);
|
||||
|
||||
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
setDirtyState(false);
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the latest installed worker", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
const replacement = new FakeWorker();
|
||||
replacement.state = "installed";
|
||||
|
||||
registration.installing = replacement as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
replacement.dispatchEvent(new Event("statechange"));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
expect(replacement.postMessage).toHaveBeenCalledWith({
|
||||
type: "skipWaiting",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7671,10 +7671,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"color-name@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "color-name@npm:2.1.0"
|
||||
checksum: 10/eb014f71d87408e318e95d3f554f188370d354ba8e0ffa4341d0fd19de391bfe2bc96e563d4f6614644d676bc24f475560dffee3fe310c2d6865d007410a9a2b
|
||||
"color-name@npm:2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "color-name@npm:2.1.1"
|
||||
checksum: 10/17bbb03a1e64299e5f61ccc514c502baf66980b88d013ca0161e8c0009edc3813ab3ffc8558ae99b8633d23c2e94b6551a248015b86cb5f3a84b645b4d0f3bb5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9338,14 +9338,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fs-extra@npm:11.3.6, fs-extra@npm:^11.1.1":
|
||||
version: 11.3.6
|
||||
resolution: "fs-extra@npm:11.3.6"
|
||||
"fs-extra@npm:11.4.0, fs-extra@npm:^11.1.1":
|
||||
version: 11.4.0
|
||||
resolution: "fs-extra@npm:11.4.0"
|
||||
dependencies:
|
||||
graceful-fs: "npm:^4.2.0"
|
||||
jsonfile: "npm:^6.0.1"
|
||||
universalify: "npm:^2.0.0"
|
||||
checksum: 10/34a981697b199002ea3b85c3483bcf353637d33ec4c922b136a8d8a597faa0495638d69b20c181448be041c31778c2bb7f7799d589819edf33da50f75b1abde9
|
||||
checksum: 10/a1bd91076fe9de9114bd037a433f1f498857eb3f0cf778e647fd00dc5984374dee5e088854de3d6ca77cd9f11554a85f6f016a6004d0838bbc7cd722e33601ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9986,7 +9986,7 @@ __metadata:
|
||||
barcode-detector: "npm:3.2.1"
|
||||
browserslist-useragent-regexp: "npm:4.1.4"
|
||||
cally: "npm:0.9.2"
|
||||
color-name: "npm:2.1.0"
|
||||
color-name: "npm:2.1.1"
|
||||
comlink: "npm:4.4.2"
|
||||
core-js: "npm:3.49.0"
|
||||
cropperjs: "npm:1.6.2"
|
||||
@@ -10007,7 +10007,7 @@ __metadata:
|
||||
eslint-plugin-unused-imports: "npm:4.4.1"
|
||||
eslint-plugin-wc: "npm:3.1.0"
|
||||
fancy-log: "npm:2.0.0"
|
||||
fs-extra: "npm:11.3.6"
|
||||
fs-extra: "npm:11.4.0"
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
@@ -10023,14 +10023,14 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.12"
|
||||
js-yaml: "npm:5.2.1"
|
||||
js-yaml: "npm:5.2.2"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
|
||||
leaflet.markercluster: "npm:1.5.3"
|
||||
license-checker-rseidelsohn: "npm:5.0.1"
|
||||
lint-staged: "npm:17.1.0"
|
||||
lint-staged: "npm:17.2.0"
|
||||
lit: "npm:3.3.3"
|
||||
lit-analyzer: "npm:2.0.3"
|
||||
lit-html: "npm:3.3.3"
|
||||
@@ -10056,7 +10056,7 @@ __metadata:
|
||||
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
|
||||
stacktrace-js: "npm:2.0.2"
|
||||
superstruct: "npm:2.0.2"
|
||||
tar: "npm:7.5.20"
|
||||
tar: "npm:7.5.21"
|
||||
terser-webpack-plugin: "npm:5.6.1"
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
@@ -10957,14 +10957,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.2.1":
|
||||
version: 5.2.1
|
||||
resolution: "js-yaml@npm:5.2.1"
|
||||
"js-yaml@npm:5.2.2":
|
||||
version: 5.2.2
|
||||
resolution: "js-yaml@npm:5.2.2"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/e1eca2d21c15572585bb236d9fde31d6789eb50b9c63e8753fa7e0777bc480f7521cad517bd7a0c66f27dfc27ddcd7100beeefa51c1a50e10e98f2e009633c3d
|
||||
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11423,9 +11423,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lint-staged@npm:17.1.0":
|
||||
version: 17.1.0
|
||||
resolution: "lint-staged@npm:17.1.0"
|
||||
"lint-staged@npm:17.2.0":
|
||||
version: 17.2.0
|
||||
resolution: "lint-staged@npm:17.2.0"
|
||||
dependencies:
|
||||
picomatch: "npm:^4.0.5"
|
||||
string-argv: "npm:^0.3.2"
|
||||
@@ -11436,7 +11436,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
lint-staged: bin/lint-staged.js
|
||||
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
|
||||
checksum: 10/7c26129d6dd27f20d8fc8d70b13252f15baf569124721d7f5e9ed289e054ed43f5cc2a35de4129b65a7c7f1ad4a5900859a7488f8a1f0b96428b995b29ec2ba8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14501,16 +14501,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.20
|
||||
resolution: "tar@npm:7.5.20"
|
||||
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.21
|
||||
resolution: "tar@npm:7.5.21"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
|
||||
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user