mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-22 11:06:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ef6fe7ad |
@@ -22,8 +22,7 @@ fireEvent(this, "show-dialog", {
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
|
||||
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Test Helpers
|
||||
|
||||
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
|
||||
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
|
||||
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
@@ -39,27 +33,25 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when its configured URL responds; otherwise it performs a slow full build. When a development watcher is being reused, rspack recompiles on save and reruns should not need a restart.
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Background server | Test command |
|
||||
| ------- | -------------------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
|
||||
| Suite | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Prepare dependencies
|
||||
description: Install and cache the complete dependency tree
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
@@ -8,37 +8,16 @@ inputs:
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache before installing
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
if: inputs.node-modules-cache == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
@@ -15,10 +15,6 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/AGENTS.md"
|
||||
- "**/CLAUDE.md"
|
||||
- "**/GEMINI.md"
|
||||
- .agents/**
|
||||
- .claude/**
|
||||
- .github/agents/**
|
||||
- .github/copilot-instructions.md
|
||||
- .github/hooks/**
|
||||
- .github/instructions/**
|
||||
- .github/plugin.json
|
||||
- .github/plugin/**
|
||||
- .github/prompts/**
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
@@ -22,30 +21,16 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
@@ -78,17 +63,14 @@ jobs:
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
@@ -98,7 +80,6 @@ jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -107,10 +88,8 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
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@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
+44
-215
@@ -22,40 +22,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -63,17 +32,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo-e2e
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -86,7 +52,6 @@ jobs:
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -94,17 +59,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app-e2e
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -117,7 +79,6 @@ jobs:
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -125,10 +86,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -144,38 +103,41 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests against Chromium ──────────────────────────────────
|
||||
e2e-demo:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
- prepare-container-dependencies
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
shardTotal:
|
||||
- 2
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
node-modules-cache: true
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -183,133 +145,36 @@ jobs:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Run Playwright demo tests
|
||||
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/demo/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-app:
|
||||
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-e2e-test-app
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Run Playwright app tests
|
||||
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/app/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-gallery:
|
||||
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-gallery
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright gallery tests
|
||||
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload gallery blob report
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/gallery/
|
||||
if-no-files-found: warn
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs:
|
||||
- e2e-demo
|
||||
- e2e-app
|
||||
- e2e-gallery
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -319,31 +184,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
path: test/e2e/reports/gallery/
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
@@ -360,11 +209,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(needs.e2e-demo.result == 'failure' ||
|
||||
needs.e2e-app.result == 'failure' ||
|
||||
needs.e2e-gallery.result == 'failure')
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -372,19 +217,3 @@ jobs:
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
- name: Check suite results
|
||||
run: |
|
||||
failed=0
|
||||
for suite in \
|
||||
"demo:${{ needs.e2e-demo.result }}" \
|
||||
"app:${{ needs.e2e-app.result }}" \
|
||||
"gallery:${{ needs.e2e-gallery.result }}"; do
|
||||
name="${suite%%:*}"
|
||||
result="${suite#*:}"
|
||||
echo "E2E ${name}: ${result}"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -50,11 +50,3 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
|
||||
## AI policy
|
||||
|
||||
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
|
||||
Autonomous contributions are not accepted: a human must review, understand,
|
||||
and be able to explain every change before it is submitted. Do not open
|
||||
issues or pull requests autonomously, and do not post comments on behalf of
|
||||
a user without their review.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Open Home Foundation - AI Policy
|
||||
|
||||
We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
|
||||
|
||||
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
|
||||
|
||||
## Autonomous agents
|
||||
|
||||
**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
|
||||
|
||||
## Communication on issues, pull requests, and code reviews
|
||||
|
||||
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
|
||||
|
||||
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
|
||||
|
||||
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
|
||||
|
||||
## Non-native English speakers
|
||||
|
||||
We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
|
||||
|
||||
## Code and documentation contributions
|
||||
|
||||
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
|
||||
|
||||
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
|
||||
|
||||
## Our use of AI
|
||||
|
||||
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
|
||||
|
||||
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss.
|
||||
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
@@ -232,7 +232,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -247,7 +247,6 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -307,7 +306,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -322,7 +321,6 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,22 +42,6 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo-e2e",
|
||||
"gen-pages-demo-prod-e2e"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
gulp.series(
|
||||
|
||||
@@ -39,18 +39,3 @@ gulp.task(
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app-e2e",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -225,16 +225,6 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-prod-e2e",
|
||||
genPagesProdTask(
|
||||
DEMO_PAGE_ENTRIES,
|
||||
paths.demo_dir,
|
||||
paths.demo_output_root,
|
||||
paths.demo_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
|
||||
|
||||
gulp.task(
|
||||
|
||||
@@ -177,18 +177,6 @@ gulp.task("rspack-prod-demo", () =>
|
||||
bothBuilds(createDemoConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-demo-e2e", () =>
|
||||
prodBuild(
|
||||
createDemoConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -281,18 +269,6 @@ gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
|
||||
prodBuild(
|
||||
createE2eTestAppConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -387,14 +387,9 @@ const createAppConfig = ({
|
||||
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createDemoConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
@@ -406,19 +401,9 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
})
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
|
||||
+13
-13
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -77,7 +77,7 @@
|
||||
"@lit/task": "1.0.3",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
"@material/web": "2.4.1",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -146,13 +146,13 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@lokalise/node-api": "16.1.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -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.0.8",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
|
||||
@@ -58,17 +58,6 @@
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
@@ -97,16 +86,6 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
},
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,17 +6,11 @@ export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "button" || domain === "input_button") {
|
||||
return "press" in services;
|
||||
}
|
||||
if (domain === "lock") {
|
||||
return "lock" in services;
|
||||
}
|
||||
if (domain === "cover") {
|
||||
return "open_cover" in services;
|
||||
}
|
||||
if (domain === "valve") {
|
||||
return "open_valve" in services;
|
||||
}
|
||||
return "turn_on" in services;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { HomeAssistant } from "../../types";
|
||||
import { canToggleDomain } from "./can_toggle_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
@@ -27,10 +26,7 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
}
|
||||
|
||||
if (domain === "climate") {
|
||||
return (
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_ON) &&
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_OFF)
|
||||
);
|
||||
return supportsFeature(stateObj, 4096);
|
||||
}
|
||||
|
||||
return canToggleDomain(hass, domain);
|
||||
|
||||
@@ -89,7 +89,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
device_class: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"carbon_monoxide",
|
||||
"co",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
@@ -227,12 +227,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
"voltage",
|
||||
"volume_flow_rate",
|
||||
],
|
||||
state_class: [
|
||||
"measurement",
|
||||
"measurement_angle",
|
||||
"total",
|
||||
"total_increasing",
|
||||
],
|
||||
state_class: ["measurement", "total", "total_increasing"],
|
||||
},
|
||||
switch: {
|
||||
device_class: ["outlet", "switch"],
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
// Bar axes render from 0, so union the extent with 0 to match.
|
||||
includeZero = false
|
||||
): number {
|
||||
const lo = includeZero ? Math.min(min, 0) : min;
|
||||
const hi = includeZero ? Math.max(max, 0) : max;
|
||||
const range = hi - lo;
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
// A near-zero range is fp noise; deriving digits from it would pad the labels
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
@@ -53,7 +38,9 @@ const resolveYAxisBound = (
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
|
||||
// rendered ticks span from 0 even when the data does not. Union the extent
|
||||
// with 0 to match the labels ECharts actually draws.
|
||||
includeZero?: boolean;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
@@ -65,11 +52,13 @@ export function createYAxisPrecisionBounds(options: {
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
);
|
||||
let extentMin = resolvedMin ?? values.min;
|
||||
let extentMax = resolvedMax ?? values.max;
|
||||
if (includeZero) {
|
||||
extentMin = Math.min(extentMin, 0);
|
||||
extentMax = Math.max(extentMax, 0);
|
||||
}
|
||||
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AssistChip } from "@material/web/chips/internal/assist-chip";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles";
|
||||
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { FilterChip } from "@material/web/chips/internal/filter-chip";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { InputChip } from "@material/web/chips/internal/input-chip";
|
||||
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { styles } from "@material/web/chips/internal/input-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const HIDDEN_ATTRIBUTES = [
|
||||
"battery_level",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === undefined || value === null || value === "";
|
||||
|
||||
const matchFieldCondition = (
|
||||
condition: HaFormFieldCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const actual = data?.[condition.field];
|
||||
switch (condition.operator ?? "eq") {
|
||||
case "eq":
|
||||
return actual === condition.value;
|
||||
case "not_eq":
|
||||
return actual !== condition.value;
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
condition.value.includes(actual as any)
|
||||
);
|
||||
case "not_in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
!condition.value.includes(actual as any)
|
||||
);
|
||||
case "exists":
|
||||
return !isEmpty(actual);
|
||||
case "not_exists":
|
||||
return isEmpty(actual);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluateCondition = (
|
||||
condition: HaFormCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
if ("condition" in condition) {
|
||||
switch (condition.condition) {
|
||||
case "and":
|
||||
return condition.conditions.every((c) => evaluateCondition(c, data));
|
||||
case "or":
|
||||
return condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
case "not":
|
||||
return !condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isFieldHidden = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { hidden } = schema as HaFormBaseSchema;
|
||||
if (!hidden) {
|
||||
return false;
|
||||
}
|
||||
if (hidden === true) {
|
||||
return true;
|
||||
}
|
||||
const conditions = Array.isArray(hidden) ? hidden : [hidden];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -69,21 +68,19 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this.schema.schema
|
||||
.filter((item) => !isFieldHidden(item, this.data))
|
||||
.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
${this.schema.schema.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -99,11 +98,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
this.schema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -169,10 +164,6 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (isFieldHidden(item, this.data)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const error = getError(this.error, item);
|
||||
const warning = getWarning(this.warning, item);
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is hidden while the condition holds. Serializable so it can be
|
||||
// shared with the backend and other renderers.
|
||||
hidden?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -33,36 +30,6 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
export interface HaFormFieldCondition {
|
||||
field: string;
|
||||
operator?: HaFormConditionOperator;
|
||||
value?: HaFormData | readonly HaFormData[];
|
||||
}
|
||||
|
||||
export interface HaFormAndCondition {
|
||||
condition: "and";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormOrCondition {
|
||||
condition: "or";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormNotCondition {
|
||||
condition: "not";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export type HaFormCondition =
|
||||
| HaFormFieldCondition
|
||||
| HaFormAndCondition
|
||||
| HaFormOrCondition
|
||||
| HaFormNotCondition;
|
||||
|
||||
export interface HaFormGridSchema extends HaFormBaseSchema {
|
||||
type: "grid";
|
||||
flatten?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles.cssresult.js";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
|
||||
import { css, html, nothing, type TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "./ha-ripple";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { List } from "@material/web/list/internal/list";
|
||||
import { styles } from "@material/web/list/internal/list-styles.cssresult.js";
|
||||
import { styles } from "@material/web/list/internal/list-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedField } from "@material/web/field/internal/outlined-field";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { literal } from "lit/static-html";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconButton } from "@material/web/iconbutton/internal/icon-button";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AttachableController } from "@material/web/internal/controller/attachable-controller";
|
||||
import { Ripple } from "@material/web/ripple/internal/ripple";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles.cssresult.js";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -133,13 +133,7 @@ export class HaAreaSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.area.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -147,13 +147,7 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean =>
|
||||
ensureArray(this.selector.device!.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -27,8 +23,6 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -43,38 +37,12 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(
|
||||
entitySources: EntitySources,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntries?: ConfigEntry[]
|
||||
) =>
|
||||
getDeviceIntegrationLookup(
|
||||
entitySources,
|
||||
Object.values(entities),
|
||||
Object.values(devices),
|
||||
configEntries
|
||||
)
|
||||
);
|
||||
|
||||
// Which async data the current filter needs to be evaluated: a top-level or
|
||||
// device `integration` filter needs entity sources, and a `device.integration`
|
||||
// filter additionally needs config entries (the device integration lookup is
|
||||
// built from both).
|
||||
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
|
||||
const filters = selector.entity?.filter
|
||||
? ensureArray(selector.entity.filter)
|
||||
: [];
|
||||
return {
|
||||
entitySources: filters.some(
|
||||
(f) => f.integration || f.device?.integration
|
||||
),
|
||||
configEntries: filters.some((f) => f.device?.integration),
|
||||
};
|
||||
});
|
||||
|
||||
private _fetchedConfigEntries = false;
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
return (
|
||||
selector.entity?.filter &&
|
||||
ensureArray(selector.entity.filter).some((filter) => filter.integration)
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||
@@ -89,11 +57,7 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
if (
|
||||
(needs.entitySources && !this._entitySources) ||
|
||||
(needs.configEntries && !this._configEntries)
|
||||
) {
|
||||
if (this._hasIntegration(this.selector) && !this._entitySources) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -132,37 +96,15 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
// The connection changed (e.g. reconnect); refetch config entries.
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (oldHass && oldHass.connection !== this.hass.connection) {
|
||||
this._fetchedConfigEntries = false;
|
||||
this._configEntries = undefined;
|
||||
}
|
||||
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
|
||||
if (needs.entitySources && !this._entitySources) {
|
||||
if (
|
||||
changedProps.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
) {
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
}
|
||||
|
||||
if (needs.configEntries && !this._fetchedConfigEntries) {
|
||||
this._fetchedConfigEntries = true;
|
||||
getConfigEntries(this.hass)
|
||||
.then((entries) => {
|
||||
this._configEntries = entries;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to no entries so the picker still renders. We keep
|
||||
// `_fetchedConfigEntries` set so the failed fetch is not retried on
|
||||
// every re-render; the connection-change handler above retries on
|
||||
// reconnect.
|
||||
this._configEntries = [];
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
}
|
||||
@@ -172,25 +114,8 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrationLookup =
|
||||
this._entitySources && this._dataNeeds(this.selector).configEntries
|
||||
? this._deviceIntegrationLookup(
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this._configEntries
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ensureArray(this.selector.entity.filter).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
deviceIntegrationLookup
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,13 +133,7 @@ export class HaFloorSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.floor.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { initialState } from "@lit/task";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { AsyncValueTask } from "../../common/controllers/async-value-task";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
entitiesContext,
|
||||
} from "../../data/context";
|
||||
import { entityIcon } from "../../data/icons";
|
||||
import type { IconSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-icon-picker";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("ha-selector-icon")
|
||||
export class HaIconSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: IconSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -34,21 +30,10 @@ export class HaIconSelector extends LitElement {
|
||||
icon_entity?: string;
|
||||
};
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["context", "icon_entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
private get _stateObj(): HassEntity | undefined {
|
||||
const iconEntity = this.context?.icon_entity;
|
||||
return iconEntity ? this.hass.states[iconEntity] : undefined;
|
||||
}
|
||||
|
||||
private _placeholderTask = new AsyncValueTask(this, {
|
||||
task: ([
|
||||
@@ -59,31 +44,19 @@ export class HaIconSelector extends LitElement {
|
||||
connection,
|
||||
stateObj,
|
||||
]) => {
|
||||
if (
|
||||
placeholder ||
|
||||
attributeIcon ||
|
||||
!entities ||
|
||||
!config ||
|
||||
!connection ||
|
||||
!stateObj
|
||||
) {
|
||||
if (placeholder || attributeIcon || !stateObj) {
|
||||
return initialState;
|
||||
}
|
||||
return entityIcon(
|
||||
entities,
|
||||
config.config,
|
||||
connection.connection,
|
||||
stateObj
|
||||
);
|
||||
return entityIcon(entities, config, connection, stateObj);
|
||||
},
|
||||
args: () => {
|
||||
const stateObj = this._stateObj;
|
||||
return [
|
||||
this.selector.icon?.placeholder,
|
||||
stateObj?.attributes.icon,
|
||||
this._entities,
|
||||
this._config,
|
||||
this._connection,
|
||||
this.hass.entities,
|
||||
this.hass.config,
|
||||
this.hass.connection,
|
||||
stateObj,
|
||||
] as const;
|
||||
},
|
||||
@@ -99,6 +72,7 @@ export class HaIconSelector extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-icon-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.label}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -93,13 +93,7 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.target.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TimezoneSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-timezone-picker";
|
||||
|
||||
@customElement("ha-selector-timezone")
|
||||
export class HaTimezoneSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimezoneSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -20,6 +23,7 @@ export class HaTimezoneSelector extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -895,13 +895,7 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
if (targetEntities.length) {
|
||||
targetEntities = targetEntities.filter((entity) =>
|
||||
entityMeetsTargetSelector(
|
||||
this.hass.states[entity],
|
||||
targetSelector,
|
||||
undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
|
||||
);
|
||||
}
|
||||
target = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
@@ -54,8 +52,7 @@ export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
|
||||
@customElement("ha-timezone-picker")
|
||||
export class HaTimeZonePicker extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -85,14 +82,15 @@ export class HaTimeZonePicker extends LitElement {
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ??
|
||||
(this._localize("ui.components.timezone-picker.time_zone") ||
|
||||
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
|
||||
"Time zone");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${
|
||||
this._localize("ui.components.timezone-picker.no_timezones") ||
|
||||
this.hass?.localize("ui.components.timezone-picker.no_timezones") ||
|
||||
"No time zones available"
|
||||
}
|
||||
.label=${label}
|
||||
@@ -126,10 +124,9 @@ export class HaTimeZonePicker extends LitElement {
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return (
|
||||
this._localize("ui.components.timezone-picker.no_match", { term }) ||
|
||||
html`No time zones found for ${term}`
|
||||
);
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
|
||||
: html`No time zones found for ${term}`;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { memoize } from "@fullcalendar/core/internal";
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -410,7 +411,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
|
||||
|
||||
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
|
||||
|
||||
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
|
||||
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||
isHassio
|
||||
? BACKUP_TYPE_ORDER
|
||||
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
|
||||
|
||||
@@ -127,6 +127,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
@@ -176,7 +177,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_mode",
|
||||
@@ -190,6 +190,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"access_token",
|
||||
"available_modes",
|
||||
"color_modes",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"entity_picture",
|
||||
@@ -206,7 +207,6 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"sound_mode_list",
|
||||
"source_list",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface SurveyInteraction {
|
||||
date: string;
|
||||
action: "opened" | "dismissed";
|
||||
}
|
||||
|
||||
export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
@@ -21,9 +16,6 @@ export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
onboarded_date?: string;
|
||||
surveys?: {
|
||||
onboarding?: SurveyInteraction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
|
||||
+32
-3
@@ -15,10 +15,26 @@ export interface HttpConfig {
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
// The slot the running HTTP server was actually started with.
|
||||
export type ActiveConfigType = "stable" | "pending" | "default";
|
||||
|
||||
// A stored config slot carries metadata alongside the editable fields:
|
||||
// - created_at: when the slot was staged
|
||||
// - error: null while healthy; set once a slot could not be applied or a
|
||||
// pending trial was not confirmed (then it is kept for display, not retried)
|
||||
export interface HttpConfigWithMeta extends HttpConfig {
|
||||
created_at?: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
stable: HttpConfigWithMeta;
|
||||
pending: HttpConfigWithMeta | null;
|
||||
revert_at: string | null;
|
||||
// Added in the "active HTTP config slot" backend change; optional so the
|
||||
// frontend keeps working against cores without it.
|
||||
active_config_type?: ActiveConfigType;
|
||||
default?: HttpConfigWithMeta;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
@@ -40,6 +56,19 @@ export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
// Keep only the editable fields; the backend storage schema rejects unknown
|
||||
// keys, so the created_at/error metadata that rides along on a fetched slot
|
||||
// must be dropped before configuring.
|
||||
export const stripHttpConfigMeta = (config: HttpConfig): HttpConfig => {
|
||||
const stripped: Partial<Record<keyof HttpConfig, unknown>> = {};
|
||||
for (const key of HTTP_CONFIG_FIELDS) {
|
||||
if (config[key] !== undefined) {
|
||||
stripped[key] = config[key];
|
||||
}
|
||||
}
|
||||
return stripped as HttpConfig;
|
||||
};
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
@@ -49,7 +78,7 @@ export const saveHttpConfig = (
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
config: config ? stripHttpConfigMeta(config) : null,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
|
||||
+10
-53
@@ -266,10 +266,6 @@ interface EntitySelectorFilter {
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
|
||||
device?: DeviceSelectorFilter;
|
||||
}
|
||||
|
||||
export interface EntitySelectorExtraOption {
|
||||
id: string;
|
||||
primary: string;
|
||||
@@ -285,7 +281,7 @@ export interface EntitySelector {
|
||||
multiple?: boolean;
|
||||
include_entities?: string[];
|
||||
exclude_entities?: string[];
|
||||
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
|
||||
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
reorder?: boolean;
|
||||
extra_options?: EntitySelectorExtraOption[];
|
||||
} | null;
|
||||
@@ -675,9 +671,7 @@ export const expandLabelTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -743,9 +737,7 @@ export const expandAreaTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -768,9 +760,7 @@ export const expandDeviceTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -811,9 +801,7 @@ export const areaMeetsTargetSelector = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -861,22 +849,14 @@ export const deviceMeetsTargetSelector = (
|
||||
export const entityMeetsTargetSelector = (
|
||||
entity: HassEntity | undefined,
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources,
|
||||
entities?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"]
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
|
||||
filterSelectorEntities(
|
||||
filterEntity,
|
||||
entity,
|
||||
entitySources,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
filterSelectorEntities(filterEntity, entity, entitySources)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -915,12 +895,9 @@ export const filterSelectorDevices = (
|
||||
};
|
||||
|
||||
export const filterSelectorEntities = (
|
||||
filterEntity: EntitySelectorEntityFilter,
|
||||
filterEntity: EntitySelectorFilter,
|
||||
entity: HassEntity,
|
||||
entitySources?: EntitySources,
|
||||
entityRegistry?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"],
|
||||
deviceIntegrationLookup?: Record<string, Set<string>>
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const {
|
||||
domain: filterDomain,
|
||||
@@ -928,7 +905,6 @@ export const filterSelectorEntities = (
|
||||
supported_features: filterSupportedFeature,
|
||||
unit_of_measurement: filterUnitOfMeasurement,
|
||||
integration: filterIntegration,
|
||||
device: filterDevice,
|
||||
} = filterEntity;
|
||||
|
||||
if (filterDomain) {
|
||||
@@ -975,24 +951,6 @@ export const filterSelectorEntities = (
|
||||
}
|
||||
}
|
||||
|
||||
if (filterDevice) {
|
||||
if (!entityRegistry || !devices) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deviceId = entityRegistry[entity.entity_id]?.device_id;
|
||||
if (!deviceId) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (!filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filterIntegration &&
|
||||
entitySources?.[entity.entity_id]?.domain !== filterIntegration
|
||||
@@ -1062,7 +1020,7 @@ export const handleLegacyDeviceSelector = (
|
||||
export const computeCreateDomains = (
|
||||
selector: EntitySelector | TargetSelector
|
||||
): undefined | string[] => {
|
||||
let entityFilters: EntitySelectorEntityFilter[] | undefined;
|
||||
let entityFilters: EntitySelectorFilter[] | undefined;
|
||||
|
||||
if ("target" in selector) {
|
||||
entityFilters = ensureArray(selector.target?.entity);
|
||||
@@ -1080,7 +1038,6 @@ export const computeCreateDomains = (
|
||||
!entityFilter.integration &&
|
||||
!entityFilter.device_class &&
|
||||
!entityFilter.supported_features &&
|
||||
!entityFilter.device &&
|
||||
entityFilter.domain
|
||||
? ensureArray(entityFilter.domain).filter((domain) =>
|
||||
isHelperDomain(domain)
|
||||
|
||||
@@ -149,7 +149,6 @@ export const weatherAttrIcons = {
|
||||
humidity: mdiWaterPercent,
|
||||
wind_bearing: mdiWeatherWindy,
|
||||
wind_speed: mdiWeatherWindy,
|
||||
wind_gust_speed: mdiWeatherWindy,
|
||||
pressure: mdiGauge,
|
||||
temperature: mdiThermometer,
|
||||
uv_index: mdiSunWireless,
|
||||
@@ -269,7 +268,6 @@ export const getWeatherUnit = (
|
||||
return (
|
||||
stateObj.attributes.temperature_unit || config.unit_system.temperature
|
||||
);
|
||||
case "wind_gust_speed":
|
||||
case "wind_speed":
|
||||
return stateObj.attributes.wind_speed_unit || `${lengthUnit}/h`;
|
||||
case "cloud_coverage":
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
removeLaunchScreen,
|
||||
renderLaunchScreenInfoBox,
|
||||
} from "../util/launch-screen";
|
||||
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
@@ -60,8 +59,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
private _httpPendingDialogOpen = false;
|
||||
|
||||
private _onboardingSurveyChecked = false;
|
||||
|
||||
private _panelUrl: string;
|
||||
|
||||
@storage({ key: "ha-version", state: false, subscribe: false })
|
||||
@@ -111,17 +108,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
if (
|
||||
changedProps.has("hass") &&
|
||||
!this._onboardingSurveyChecked &&
|
||||
this.hass?.user &&
|
||||
this.hass.systemData
|
||||
) {
|
||||
this._onboardingSurveyChecked = true;
|
||||
if (!__DEMO__) {
|
||||
checkOnboardingSurveyToast(this, this.hass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
@@ -264,7 +250,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// The check re-runs on the next reconnect; ignore transient failures.
|
||||
return;
|
||||
}
|
||||
if (!httpConfig.pending || this._httpPendingDialogOpen) {
|
||||
// Only prompt for an active trial. A pending config with an error was
|
||||
// already reverted/failed and is kept only for display in the config form,
|
||||
// so it must not pop the confirm/revert dialog.
|
||||
if (
|
||||
!httpConfig.pending ||
|
||||
httpConfig.pending.error ||
|
||||
this._httpPendingDialogOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._httpPendingDialogOpen = true;
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ShowToastParams {
|
||||
message:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
action?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
bottomOffset?: number;
|
||||
@@ -72,10 +71,7 @@ class NotificationManager extends LitElement {
|
||||
this._toast?.show();
|
||||
}
|
||||
|
||||
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
if (ev.detail.reason === "dismiss") {
|
||||
this._parameters?.dismiss?.();
|
||||
}
|
||||
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
this._parameters = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const SCHEMA = [
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
|
||||
@@ -80,6 +80,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
"available_modes",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
|
||||
@@ -179,6 +179,7 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.time_zone"
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import type {
|
||||
ActiveConfigType,
|
||||
HttpConfig,
|
||||
HttpConfigWithMeta,
|
||||
} from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -161,6 +165,11 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
@state() private _showNoChanges = false;
|
||||
|
||||
@state() private _activeConfigType?: ActiveConfigType;
|
||||
|
||||
// A pending config that was reverted/failed and kept only for display.
|
||||
@state() private _revertedPending?: HttpConfigWithMeta;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
@query("ha-alert") private _firstAlert?: HTMLElement;
|
||||
@@ -201,6 +210,40 @@ class HaConfigHttpForm extends LitElement {
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
this._activeConfigType === "default"
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.running_default"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._revertedPending
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
this._revertedPending.error === "not_promoted"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_not_confirmed"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_failed",
|
||||
{ error: this._revertedPending.error ?? "" }
|
||||
)
|
||||
}
|
||||
<ha-button slot="action" @click=${this._reviewReverted}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
portChanged
|
||||
? html`
|
||||
@@ -266,16 +309,30 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
// Pending is exclusively handled by the global confirm/revert dialog, so
|
||||
// the form only ever displays stable.
|
||||
const { stable } = await fetchHttpConfig(this.hass);
|
||||
const { stable, pending, active_config_type } = await fetchHttpConfig(
|
||||
this.hass
|
||||
);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
this._activeConfigType = active_config_type;
|
||||
// An active trial pending (no error) is handled by the global
|
||||
// confirm/revert dialog. A pending carrying an error was reverted or
|
||||
// failed to apply and is kept only so we can surface it here.
|
||||
this._revertedPending = pending?.error ? pending : undefined;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _reviewReverted(): void {
|
||||
if (!this._revertedPending) {
|
||||
return;
|
||||
}
|
||||
// Load the reverted values into the form so the user can fix and re-save.
|
||||
this._config = { ...this._revertedPending };
|
||||
this._revertedPending = undefined;
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
|
||||
@@ -156,7 +156,6 @@ class MoveDatadiskDialog extends DirtyStateProviderMixin<MoveDatadiskFormState>(
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.storage.datadisk.select_device"
|
||||
)}
|
||||
.value=${this._selectedDevice}
|
||||
@selected=${this._selectDevice}
|
||||
.options=${this._disks.map((disk) => ({
|
||||
value: disk.id,
|
||||
|
||||
@@ -179,16 +179,6 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
|
||||
|
||||
const anyExposed = uiExposed || manExposedAlexa || manExposedGoogle;
|
||||
|
||||
const exposedToAlexa =
|
||||
showAssistants.includes("cloud.alexa") &&
|
||||
(alexaManual ? manExposedAlexa : this.exposed["cloud.alexa"]);
|
||||
const exposedToGoogle =
|
||||
showAssistants.includes("cloud.google_assistant") &&
|
||||
(googleManual
|
||||
? manExposedGoogle
|
||||
: this.exposed["cloud.google_assistant"]);
|
||||
const exposedToAssist = this.exposed.conversation;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
<h3 slot="headline">
|
||||
@@ -285,24 +275,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
|
||||
</h3>
|
||||
|
||||
<p class="description">
|
||||
${[
|
||||
this.hass.localize("ui.dialogs.voice-settings.aliases_description"),
|
||||
exposedToAlexa &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_alexa"
|
||||
),
|
||||
exposedToGoogle &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_google"
|
||||
),
|
||||
exposedToAssist &&
|
||||
(exposedToAlexa || exposedToGoogle) &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_assist"
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
${this.hass.localize("ui.dialogs.voice-settings.aliases_description")}
|
||||
</p>
|
||||
|
||||
${
|
||||
|
||||
@@ -866,10 +866,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
/* Baseline-align so a wrapped multi-line value keeps the subject and
|
||||
the trailing time on its first line, while a single-line entry
|
||||
stays aligned with the text. */
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
@@ -881,9 +878,9 @@ class HaLogbookEntry extends LitElement {
|
||||
.primary-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Wrap long entries onto multiple lines instead of truncating them to
|
||||
a single line with an ellipsis. */
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.primary > .subject {
|
||||
@@ -908,12 +905,13 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
.value {
|
||||
/* Don't shrink: the subject absorbs truncation so a short state stays
|
||||
whole. A long value wraps within its max-width instead of being cut
|
||||
off. */
|
||||
/* Don't shrink: the subject absorbs all truncation so a short state
|
||||
stays whole. max-width still caps a long one. */
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,30 +36,6 @@ const MEASUREMENT_VARIANTS: Variant[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MEASUREMENT_ANGLE_VARIANTS: Variant[] = [
|
||||
{
|
||||
labelKey: "last_24h",
|
||||
days_to_show: 1,
|
||||
period: "hour",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
{
|
||||
labelKey: "last_7d",
|
||||
days_to_show: 7,
|
||||
period: "day",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
{
|
||||
labelKey: "last_30d",
|
||||
days_to_show: 30,
|
||||
period: "day",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
];
|
||||
|
||||
const TOTAL_VARIANTS: Variant[] = [
|
||||
{
|
||||
labelKey: "last_7d",
|
||||
@@ -84,13 +60,6 @@ const TOTAL_VARIANTS: Variant[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const VARIANTS_BY_STATE_CLASS: Record<string, Variant[]> = {
|
||||
measurement: MEASUREMENT_VARIANTS,
|
||||
measurement_angle: MEASUREMENT_ANGLE_VARIANTS,
|
||||
total: TOTAL_VARIANTS,
|
||||
total_increasing: TOTAL_VARIANTS,
|
||||
};
|
||||
|
||||
export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGraphCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
@@ -98,8 +67,8 @@ export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGr
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateClass = stateObj?.attributes.state_class;
|
||||
if (!stateClass) return null;
|
||||
const variants = VARIANTS_BY_STATE_CLASS[stateClass];
|
||||
if (!variants) return null;
|
||||
const variants =
|
||||
stateClass === "measurement" ? MEASUREMENT_VARIANTS : TOTAL_VARIANTS;
|
||||
const suggestions: CardSuggestion<StatisticsGraphCardConfig>[] =
|
||||
variants.map((v) => ({
|
||||
label: hass.localize(`${LABEL_PREFIX}${v.labelKey}` as any),
|
||||
|
||||
@@ -521,7 +521,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
return {
|
||||
chartData: datasets,
|
||||
|
||||
@@ -119,7 +119,7 @@ export function generateEnergyGasGraphData(
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, period)
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const chartData = datasets;
|
||||
const total = processTotal(energyData.stats, gasSources);
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export function generateEnergySolarGraphData(
|
||||
end,
|
||||
compareStart,
|
||||
compareEnd,
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax, true),
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,6 @@ const stackOrder = {
|
||||
to_grid: 2,
|
||||
used_solar: 3,
|
||||
used_battery: 4,
|
||||
from_grid: 5,
|
||||
used_grid: 5,
|
||||
};
|
||||
|
||||
@customElement("hui-energy-usage-graph-card")
|
||||
@@ -463,7 +461,7 @@ export class HuiEnergyUsageGraphCard
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._legendData = this._getLegendData(datasets);
|
||||
this._total = this._processTotal(consumption);
|
||||
|
||||
@@ -274,7 +274,7 @@ export class HuiEnergyWaterGraphCard
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._total = this._processTotal(energyData.stats, waterSources);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export function generatePowerSourcesGraphData(
|
||||
const end = energyData.end || endOfToday();
|
||||
|
||||
const chartData = fillLineGaps(datasets);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||
// fillLineGaps ensures all datasets share the same x values, so iterate the
|
||||
|
||||
@@ -22,9 +22,6 @@ import type {
|
||||
} from "../../../data/lovelace/config/action";
|
||||
import type { ServiceAction } from "../../../data/script";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { canToggleState } from "../../../common/entity/can_toggle_state";
|
||||
import { canToggleDomain } from "../../../common/entity/can_toggle_domain";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
|
||||
export type UiAction = Exclude<ActionConfig["action"], "fire-dom-event">;
|
||||
|
||||
@@ -137,35 +134,15 @@ export class HuiActionEditor extends LitElement {
|
||||
]
|
||||
);
|
||||
|
||||
private _filterToggleAction = memoizeOne(
|
||||
(actions: UiAction[]): UiAction[] => {
|
||||
return actions.filter((a) => a !== "toggle");
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const actions = this.actions ?? DEFAULT_ACTIONS;
|
||||
|
||||
let action = this.config?.action || (this.required ? "" : "default");
|
||||
|
||||
let actions = this.actions ?? DEFAULT_ACTIONS;
|
||||
|
||||
if (
|
||||
this.context?.entity_id &&
|
||||
action !== "toggle" &&
|
||||
actions.includes("toggle")
|
||||
) {
|
||||
const stateObj = this.hass.states[this.context.entity_id];
|
||||
const canToggle = stateObj
|
||||
? canToggleState(this.hass, stateObj)
|
||||
: canToggleDomain(this.hass, computeDomain(this.context.entity_id));
|
||||
if (!canToggle) {
|
||||
actions = this._filterToggleAction(actions);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "call-service") {
|
||||
action = "perform-action";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
array,
|
||||
@@ -12,15 +12,14 @@ import {
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { ValueChangedEvent } from "../../../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { ClockCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
@@ -65,13 +64,17 @@ export class HuiClockCardEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardEditor
|
||||
{
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: ClockCardConfig;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
clockStyle: ClockCardConfig["clock_style"],
|
||||
ticks: ClockCardConfig["ticks"],
|
||||
showSeconds: boolean | undefined
|
||||
) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
@@ -111,122 +114,124 @@ export class HuiClockCardEditor
|
||||
ui_clock_date_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: {
|
||||
field: "clock_style",
|
||||
operator: "not_eq",
|
||||
value: "digital",
|
||||
},
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "border",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "seconds_motion",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "show_seconds", operator: "not_eq", value: true },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "face_style",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "ticks", value: "none" },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["markers", "numbers_upright", "roman"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(clockStyle === "digital"
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: clockStyle === "analog"
|
||||
? ([
|
||||
{
|
||||
name: "border",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(showSeconds
|
||||
? ([
|
||||
{
|
||||
name: "seconds_motion",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(ticks !== "none"
|
||||
? ([
|
||||
{
|
||||
name: "face_style",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: [
|
||||
"markers",
|
||||
"numbers_upright",
|
||||
"roman",
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{ name: "time_zone", selector: { timezone: {} } },
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
@@ -260,14 +265,20 @@ export class HuiClockCardEditor
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data(this._config)}
|
||||
.schema=${this._schema(this._localize)}
|
||||
.schema=${this._schema(
|
||||
this.hass.localize,
|
||||
this._data(this._config).clock_style,
|
||||
this._data(this._config).ticks,
|
||||
this._data(this._config).show_seconds
|
||||
)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -316,43 +327,51 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "title":
|
||||
return this._localize("ui.panel.lovelace.editor.card.generic.title");
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.title"
|
||||
);
|
||||
case "clock_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_style`
|
||||
);
|
||||
case "clock_size":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.clock_size`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_size`
|
||||
);
|
||||
case "time_format":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_format`
|
||||
);
|
||||
case "time_zone":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.time_zone`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_zone`
|
||||
);
|
||||
case "show_seconds":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.show_seconds`
|
||||
);
|
||||
case "no_background":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.no_background`
|
||||
);
|
||||
case "date_format":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.date.label`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.label`
|
||||
);
|
||||
case "border":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.label`
|
||||
);
|
||||
case "ticks":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.label`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.label`
|
||||
);
|
||||
case "face_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.label`
|
||||
);
|
||||
default:
|
||||
@@ -365,23 +384,23 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "date_format":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.description`
|
||||
);
|
||||
case "border":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
);
|
||||
case "ticks":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
);
|
||||
case "face_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -54,65 +54,6 @@ const cardConfigStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
hidden: {
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "min_y_axis", operator: "not_exists" },
|
||||
{ field: "max_y_axis", operator: "not_exists" },
|
||||
],
|
||||
},
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
@customElement("hui-history-graph-card-editor")
|
||||
export class HuiHistoryGraphCardEditor
|
||||
extends LitElement
|
||||
@@ -129,6 +70,65 @@ export class HuiHistoryGraphCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(showFitOption: boolean) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
...(showFitOption
|
||||
? [
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as const
|
||||
);
|
||||
|
||||
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
|
||||
schema: [
|
||||
{ name: "entity", selector: { entity: {} }, required: true },
|
||||
@@ -176,6 +176,11 @@ export class HuiHistoryGraphCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
const schema = this._schema(
|
||||
this._config!.min_y_axis !== undefined ||
|
||||
this._config!.max_y_axis !== undefined
|
||||
);
|
||||
|
||||
const configEntities = this._config.entities
|
||||
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
|
||||
: [];
|
||||
@@ -183,7 +188,7 @@ export class HuiHistoryGraphCardEditor
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
@@ -278,7 +283,9 @@ export class HuiHistoryGraphCardEditor
|
||||
) as HistoryGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "show_names":
|
||||
case "logarithmic_scale":
|
||||
|
||||
@@ -92,6 +92,7 @@ export class HuiTileCardEditor
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | undefined,
|
||||
hideState: boolean,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
[
|
||||
@@ -143,25 +144,31 @@ export class HuiTileCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
hidden: { field: "hide_state", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: !showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
...(!hideState
|
||||
? ([
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
@@ -286,7 +293,12 @@ export class HuiTileCardEditor
|
||||
this._config.state_content
|
||||
);
|
||||
|
||||
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
entityId,
|
||||
this._config.hide_state ?? false,
|
||||
showTimeFormat
|
||||
);
|
||||
|
||||
const vertical = this._config.vertical ?? false;
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export class HuiDialogEditSection
|
||||
case "tab-settings":
|
||||
content = html`
|
||||
<hui-section-settings-editor
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
.viewConfig=${this._viewConfig}
|
||||
@value-changed=${this._configChanged}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { mdiPalette } from "@mdi/js";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
type LovelaceSectionRawConfig,
|
||||
} from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
interface SettingsData {
|
||||
column_span?: number;
|
||||
@@ -27,15 +27,14 @@ interface SettingsData {
|
||||
|
||||
@customElement("hui-section-settings-editor")
|
||||
export class HuiDialogEditSection extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
|
||||
|
||||
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(maxColumns: number, localize: LocalizeFunc) =>
|
||||
(maxColumns: number, backgroundEnabled: boolean, localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "column_span",
|
||||
@@ -51,45 +50,48 @@ export class HuiDialogEditSection extends LitElement {
|
||||
name: "background_enabled",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "background",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
expanded: true,
|
||||
hidden: { field: "background_enabled", value: false },
|
||||
iconPath: mdiPalette,
|
||||
schema: [
|
||||
{
|
||||
name: "background_color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
extra_options: [
|
||||
{
|
||||
value: "default",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.edit_section.settings.background_color_default"
|
||||
),
|
||||
display_color:
|
||||
"var(--ha-section-background-color, var(--secondary-background-color))",
|
||||
...(backgroundEnabled
|
||||
? ([
|
||||
{
|
||||
name: "background",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
expanded: true,
|
||||
iconPath: mdiPalette,
|
||||
schema: [
|
||||
{
|
||||
name: "background_color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
extra_options: [
|
||||
{
|
||||
value: "default",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.edit_section.settings.background_color_default"
|
||||
),
|
||||
display_color:
|
||||
"var(--ha-section-background-color, var(--secondary-background-color))",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background_opacity",
|
||||
selector: {
|
||||
number: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit_of_measurement: "%",
|
||||
mode: "slider",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background_opacity",
|
||||
selector: {
|
||||
number: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit_of_measurement: "%",
|
||||
mode: "slider",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "theme",
|
||||
selector: {
|
||||
@@ -114,11 +116,13 @@ export class HuiDialogEditSection extends LitElement {
|
||||
|
||||
const schema = this._schema(
|
||||
this.viewConfig.max_columns || 4,
|
||||
this._localize
|
||||
backgroundEnabled,
|
||||
this.hass.localize
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@@ -131,14 +135,14 @@ export class HuiDialogEditSection extends LitElement {
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this._localize(
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}`
|
||||
);
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this._localize(
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}_helper`
|
||||
) || "";
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
|
||||
content = html`
|
||||
<hui-view-editor
|
||||
.isNew=${this._params.viewIndex === undefined}
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
@view-config-changed=${this._viewConfigChanged}
|
||||
></hui-view-editor>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { slugify } from "../../../../common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
MASONRY_VIEW_LAYOUT,
|
||||
SECTIONS_VIEW_LAYOUT,
|
||||
@@ -33,8 +33,7 @@ const INTEGER_REGEX = /^[0-9]+$/;
|
||||
|
||||
@customElement("hui-view-editor")
|
||||
export class HuiViewEditor extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public isNew = false;
|
||||
|
||||
@@ -45,7 +44,7 @@ export class HuiViewEditor extends LitElement {
|
||||
private _suggestedPath = false;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
(localize: LocalizeFunc, viewType: string) =>
|
||||
[
|
||||
{
|
||||
name: "type",
|
||||
@@ -88,42 +87,41 @@ export class HuiViewEditor extends LitElement {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section_specifics",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
expanded: true,
|
||||
hidden: {
|
||||
field: "type",
|
||||
operator: "not_eq",
|
||||
value: SECTIONS_VIEW_LAYOUT,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
name: "max_columns",
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
mode: "slider",
|
||||
slider_ticks: true,
|
||||
},
|
||||
...(viewType === SECTIONS_VIEW_LAYOUT
|
||||
? ([
|
||||
{
|
||||
name: "section_specifics",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
expanded: true,
|
||||
schema: [
|
||||
{
|
||||
name: "max_columns",
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
mode: "slider",
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dense_section_placement",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "top_margin",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dense_section_placement",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "top_margin",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies HaFormSchema[])
|
||||
: []),
|
||||
] as const satisfies HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -136,6 +134,12 @@ export class HuiViewEditor extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(this.hass.localize, this._type);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
type: this._type,
|
||||
@@ -151,8 +155,9 @@ export class HuiViewEditor extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${this._schema(this._localize)}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
.computeError=${this._computeError}
|
||||
@@ -202,14 +207,14 @@ export class HuiViewEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _computeError = (error: string) =>
|
||||
this._localize(`ui.panel.lovelace.editor.edit_view.${error}`) || error;
|
||||
this.hass.localize(`ui.panel.lovelace.editor.edit_view.${error}`) || error;
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "path":
|
||||
return this._localize("ui.panel.lovelace.editor.card.generic.url");
|
||||
return this.hass!.localize("ui.panel.lovelace.editor.card.generic.url");
|
||||
case "type":
|
||||
case "show_icon_and_title":
|
||||
case "subview":
|
||||
@@ -217,11 +222,11 @@ export class HuiViewEditor extends LitElement {
|
||||
case "dense_section_placement":
|
||||
case "top_margin":
|
||||
case "section_specifics":
|
||||
return this._localize(
|
||||
return this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_view.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
@@ -236,7 +241,7 @@ export class HuiViewEditor extends LitElement {
|
||||
case "subview":
|
||||
case "dense_section_placement":
|
||||
case "top_margin":
|
||||
return this._localize(
|
||||
return this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_view.${schema.name}_helper`
|
||||
);
|
||||
|
||||
|
||||
@@ -419,6 +419,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: area.icon || "mdi:shape-square-rounded-plus",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-area.no_devices_title"
|
||||
|
||||
@@ -151,6 +151,7 @@ export class HomeOtherDevicesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:check-all",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-other-devices.all_organized_title"
|
||||
|
||||
@@ -483,6 +483,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_title"
|
||||
|
||||
@@ -84,6 +84,7 @@ export class OriginalStatesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_title"
|
||||
|
||||
@@ -348,7 +348,6 @@
|
||||
"temperature": "Temperature",
|
||||
"visibility": "Visibility",
|
||||
"wind_speed": "Wind speed",
|
||||
"wind_gust_speed": "Wind gust speed",
|
||||
"precipitation": "Precipitation"
|
||||
},
|
||||
"cardinal_direction": {
|
||||
@@ -2026,12 +2025,9 @@
|
||||
"voice-settings": {
|
||||
"expose_header": "Expose",
|
||||
"aliases_header": "Aliases",
|
||||
"aliases_description": "Aliases are the names voice assistants use for this entity.",
|
||||
"aliases_description_assist": "Assist uses all aliases equally.",
|
||||
"aliases_description_alexa": "Amazon Alexa uses only the first alias.",
|
||||
"aliases_description_google": "Google Assistant uses the first alias as the main name and the rest as alternatives.",
|
||||
"aliases_description": "Aliases are alternative names to call your entity. Only supported by Assist and Google Assistant.",
|
||||
"aliases_no_unique_id": "Aliases are not supported for entities without a unique ID. See the {faq_link} for more detail.",
|
||||
"entity_name_alias_description": "Default name. When enabled, it is used as the first alias.",
|
||||
"entity_name_alias_description": "Default name. Disable it if you want your voice assistants to ignore it and just use aliases.",
|
||||
"ask_pin": "Ask for PIN",
|
||||
"manual_config": "Managed in configuration.yaml",
|
||||
"unsupported": "Unsupported",
|
||||
@@ -2512,11 +2508,7 @@
|
||||
"new_version_available": "A new version of the frontend is available.",
|
||||
"reload": "Reload",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
"onboarding_survey": {
|
||||
"message": "Hello there! 👋 You've been using Home Assistant for a little while now. We'd love to hear what you think. Just one quick minute!",
|
||||
"action": "Take survey"
|
||||
}
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings."
|
||||
},
|
||||
"sidebar": {
|
||||
"external_app_configuration": "App settings",
|
||||
@@ -8692,6 +8684,10 @@
|
||||
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
|
||||
"invalid_host": "Enter a valid IP address.",
|
||||
"invalid_network": "Enter a valid IP address or network.",
|
||||
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
|
||||
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
|
||||
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
|
||||
"reverted_action": "Review the change",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
cleanupMediaTitle,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import { cleanupMediaTitle } from "../data/media-player";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export default class MediaPlayerEntity {
|
||||
@@ -78,60 +75,51 @@ export default class MediaPlayerEntity {
|
||||
}
|
||||
|
||||
get supportsPause() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PAUSE);
|
||||
return supportsFeature(this.stateObj, 1);
|
||||
}
|
||||
|
||||
get supportsVolumeSet() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_SET);
|
||||
return supportsFeature(this.stateObj, 4);
|
||||
}
|
||||
|
||||
get supportsVolumeMute() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_MUTE);
|
||||
return supportsFeature(this.stateObj, 8);
|
||||
}
|
||||
|
||||
get supportsPreviousTrack() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
);
|
||||
return supportsFeature(this.stateObj, 16);
|
||||
}
|
||||
|
||||
get supportsNextTrack() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.NEXT_TRACK);
|
||||
return supportsFeature(this.stateObj, 32);
|
||||
}
|
||||
|
||||
get supportsTurnOn() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_ON);
|
||||
return supportsFeature(this.stateObj, 128);
|
||||
}
|
||||
|
||||
get supportsTurnOff() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_OFF);
|
||||
return supportsFeature(this.stateObj, 256);
|
||||
}
|
||||
|
||||
get supportsPlayMedia() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY_MEDIA);
|
||||
return supportsFeature(this.stateObj, 512);
|
||||
}
|
||||
|
||||
get supportsVolumeButtons() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_STEP);
|
||||
return supportsFeature(this.stateObj, 1024);
|
||||
}
|
||||
|
||||
get supportsSelectSource() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
);
|
||||
return supportsFeature(this.stateObj, 2048);
|
||||
}
|
||||
|
||||
get supportsSelectSoundMode() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
);
|
||||
return supportsFeature(this.stateObj, 65536);
|
||||
}
|
||||
|
||||
get supportsPlay() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY);
|
||||
return supportsFeature(this.stateObj, 16384);
|
||||
}
|
||||
|
||||
get primaryTitle() {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
CoreFrontendSystemData,
|
||||
SurveyInteraction,
|
||||
} from "../data/frontend";
|
||||
import { saveFrontendSystemData } from "../data/frontend";
|
||||
import type { CurrentUser, HomeAssistant } from "../types";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
const SURVEY_MIN_AGE = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
const SURVEY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month
|
||||
|
||||
// Always the production site: the survey is a single page that is not
|
||||
// version-specific (the version is passed as a query parameter instead), so
|
||||
// beta/dev installs should not be routed to rc./next. like documentationUrl
|
||||
// would.
|
||||
const SURVEY_URL = "https://www.home-assistant.io/surveys/onboarding";
|
||||
|
||||
export const shouldShowOnboardingSurvey = (
|
||||
user: CurrentUser | undefined,
|
||||
systemData: CoreFrontendSystemData | undefined,
|
||||
now: number = Date.now()
|
||||
): boolean => {
|
||||
if (!user?.is_owner || !systemData?.onboarded_date) {
|
||||
return false;
|
||||
}
|
||||
if (systemData.surveys?.onboarding) {
|
||||
return false;
|
||||
}
|
||||
const age = now - new Date(systemData.onboarded_date).getTime();
|
||||
// NaN (invalid date) and future dates (clock skew) both fail these checks
|
||||
return age >= SURVEY_MIN_AGE && age <= SURVEY_MAX_AGE;
|
||||
};
|
||||
|
||||
export const recordOnboardingSurvey = (
|
||||
conn: Connection,
|
||||
systemData: CoreFrontendSystemData,
|
||||
action: SurveyInteraction["action"]
|
||||
): Promise<void> =>
|
||||
// saveFrontendSystemData overwrites the whole "core" object, so spread the
|
||||
// existing data to preserve the other fields.
|
||||
saveFrontendSystemData(conn, "core", {
|
||||
...systemData,
|
||||
surveys: {
|
||||
...systemData.surveys,
|
||||
onboarding: { date: new Date().toISOString(), action },
|
||||
},
|
||||
});
|
||||
|
||||
export const getOnboardingSurveyUrl = (
|
||||
systemData: CoreFrontendSystemData
|
||||
): string =>
|
||||
systemData.onboarded_version
|
||||
? `${SURVEY_URL}?version=${encodeURIComponent(systemData.onboarded_version)}`
|
||||
: SURVEY_URL;
|
||||
|
||||
export const checkOnboardingSurveyToast = (
|
||||
el: HTMLElement,
|
||||
hass: HomeAssistant
|
||||
) => {
|
||||
if (!shouldShowOnboardingSurvey(hass.user, hass.systemData)) {
|
||||
return;
|
||||
}
|
||||
const record = (action: SurveyInteraction["action"]) =>
|
||||
recordOnboardingSurvey(hass.connection, hass.systemData!, action);
|
||||
showToast(el, {
|
||||
id: "onboarding-survey",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.onboarding_survey.message",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: true,
|
||||
action: {
|
||||
text: {
|
||||
translationKey: "ui.notification_toast.onboarding_survey.action",
|
||||
},
|
||||
action: () => {
|
||||
window.open(getOnboardingSurveyUrl(hass.systemData!), "_blank");
|
||||
record("opened");
|
||||
},
|
||||
},
|
||||
dismiss: () => record("dismissed"),
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { canToggleState } from "../../../src/common/entity/can_toggle_state";
|
||||
import { ClimateEntityFeature } from "../../../src/data/climate";
|
||||
|
||||
describe("canToggleState", () => {
|
||||
const hass: any = {
|
||||
@@ -49,8 +48,7 @@ describe("canToggleState", () => {
|
||||
const stateObj: any = {
|
||||
entity_id: "climate.bla",
|
||||
attributes: {
|
||||
supported_features:
|
||||
ClimateEntityFeature.TURN_ON + ClimateEntityFeature.TURN_OFF,
|
||||
supported_features: 4096,
|
||||
},
|
||||
};
|
||||
assert.isTrue(canToggleState(hass, stateObj));
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("getStates", () => {
|
||||
expect.arrayContaining([
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"carbon_monoxide",
|
||||
"co",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
|
||||
@@ -43,23 +43,6 @@ describe("computeYAxisFractionDigits", () => {
|
||||
expect(computeYAxisFractionDigits(1.5, 1.5)).toBe(1);
|
||||
});
|
||||
|
||||
it("treats a floating-point-noise range as flat (issue #53180)", () => {
|
||||
expect(computeYAxisFractionDigits(0.3, 0.3 + 1e-16)).toBe(1);
|
||||
expect(computeYAxisFractionDigits(0.2, 0.20000000000004547)).toBe(1);
|
||||
expect(computeYAxisFractionDigits(1_000_000, 1_000_000.00001)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps precision for a genuinely narrow, non-noise range", () => {
|
||||
expect(computeYAxisFractionDigits(1e-6, 3e-6)).toBe(7);
|
||||
});
|
||||
|
||||
it("unions the extent with zero for anchored (bar) axes", () => {
|
||||
expect(computeYAxisFractionDigits(0.3, 0.3, true)).toBe(2);
|
||||
expect(
|
||||
computeYAxisFractionDigits(0.29999999999999993, 0.3000000000000001, true)
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to one decimal when range is non-finite", () => {
|
||||
expect(computeYAxisFractionDigits(Infinity, -Infinity)).toBe(1);
|
||||
expect(computeYAxisFractionDigits(NaN, 1)).toBe(1);
|
||||
@@ -129,12 +112,4 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
min({ min: 0.02, max: 0.05 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("does not over-pad when the visible extent collapses to noise", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
|
||||
min({ min: 0.3, max: 0.3 + 1e-15 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
|
||||
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
|
||||
|
||||
describe("isFieldHidden", () => {
|
||||
it("shows a field without a hidden condition", () => {
|
||||
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a boolean hidden", () => {
|
||||
expect(isFieldHidden(field(true), {})).toBe(true);
|
||||
expect(isFieldHidden(field(false), {})).toBe(false);
|
||||
});
|
||||
|
||||
describe("operators", () => {
|
||||
it("eq (default) matches equal values", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("not_eq matches different values", () => {
|
||||
const schema = field({ field: "a", operator: "not_eq", value: 1 });
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("in matches membership", () => {
|
||||
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
|
||||
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("not_in matches non-membership", () => {
|
||||
const schema = field({
|
||||
field: "a",
|
||||
operator: "not_in",
|
||||
value: ["x", "y"],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("exists matches a defined non-empty value", () => {
|
||||
const schema = field({ field: "a", operator: "exists" });
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "" })).toBe(false);
|
||||
expect(isFieldHidden(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("not_exists matches a missing or empty value", () => {
|
||||
const schema = field({ field: "a", operator: "not_exists" });
|
||||
expect(isFieldHidden(schema, {})).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinators", () => {
|
||||
it("and requires every condition", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("or requires any condition", () => {
|
||||
const schema = field({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("not negates its conditions", () => {
|
||||
const schema = field({
|
||||
condition: "not",
|
||||
conditions: [{ field: "a", value: 1 }],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("nests combinators", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "b", value: 2 },
|
||||
{ field: "c", value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an array of conditions as AND", () => {
|
||||
const schema = field([
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
]);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing data", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import { filterSelectorEntities } from "../../src/data/selector";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const entity = {
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: {},
|
||||
} as HassEntity;
|
||||
|
||||
const entityRegistry = {
|
||||
"light.living_room": { device_id: "device_1" },
|
||||
} as unknown as HomeAssistant["entities"];
|
||||
|
||||
const devices = {
|
||||
device_1: {
|
||||
id: "device_1",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bulb",
|
||||
model_id: "LCT015",
|
||||
} as DeviceRegistryEntry,
|
||||
} as unknown as HomeAssistant["devices"];
|
||||
|
||||
describe("filterSelectorEntities device filter", () => {
|
||||
it("matches when the nested device manufacturer matches", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when the nested device manufacturer differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Sonos" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches when model and model_id both match", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "LCT015" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when one of model or model_id differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "OTHER" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the device integration via the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "hue" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a device integration that is absent from the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "zha" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when the entity has no underlying device", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
{} as HomeAssistant["entities"],
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("combines device conditions with other entity conditions (AND)", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "light", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "switch", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+462
-68
@@ -4,29 +4,164 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
appSidebar,
|
||||
appSidebarConfig,
|
||||
appSidebarPanel,
|
||||
assertElementContent,
|
||||
defineLinkSmokeTests,
|
||||
defineRouteSmokeTests,
|
||||
ensureAppSidebarPanelVisible,
|
||||
goToPanel,
|
||||
} from "./app/src/helpers";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
appRouteSmokeGroups,
|
||||
configLinks,
|
||||
moreInfoViewElements,
|
||||
} from "./app/src/smoke";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
|
||||
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||
import { e2ePanelRouteAssertions } from "./app/src/ha-test-panels";
|
||||
|
||||
/**
|
||||
* Each More info view renders one root element inside the dialog, plus one or
|
||||
* more characteristic descendants that prove the view actually populated rather
|
||||
* than rendering an empty shell. `text`, when set, asserts the element's text
|
||||
* instead of just its presence.
|
||||
*/
|
||||
const MORE_INFO_VIEW_ELEMENTS: {
|
||||
view: MoreInfoView;
|
||||
element: string;
|
||||
content: { selector: string; text?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
view: "info",
|
||||
element: "ha-more-info-info",
|
||||
content: [
|
||||
{ selector: "more-info-light" },
|
||||
{ selector: "span.title", text: "Test Light" },
|
||||
],
|
||||
},
|
||||
{
|
||||
view: "history",
|
||||
element: "ha-more-info-history-and-logbook",
|
||||
// The demo loads the history component but not logbook.
|
||||
content: [{ selector: "ha-more-info-history" }],
|
||||
},
|
||||
{
|
||||
view: "settings",
|
||||
element: "ha-more-info-settings",
|
||||
// The scenario mocks config/entity_registry/get, so the real registry
|
||||
// panel renders instead of the "no unique ID" warning.
|
||||
content: [{ selector: "entity-registry-settings" }],
|
||||
},
|
||||
{
|
||||
view: "related",
|
||||
element: "ha-related-items",
|
||||
// search/related is mocked to return no relations, so the empty list
|
||||
// renders.
|
||||
content: [{ selector: "ha-related-items >> ha-list" }],
|
||||
},
|
||||
{
|
||||
view: "add_to",
|
||||
element: "ha-more-info-add-to",
|
||||
// Admin users get the default add-to action list.
|
||||
content: [{ selector: "ha-add-to-action-list" }],
|
||||
},
|
||||
{
|
||||
view: "details",
|
||||
element: "ha-more-info-details",
|
||||
// The details view renders the state and attributes cards.
|
||||
content: [{ selector: "ha-card" }],
|
||||
},
|
||||
];
|
||||
|
||||
const URL_NORMALIZATION_ASSERTIONS: {
|
||||
name: string;
|
||||
path: string;
|
||||
element: string;
|
||||
url: RegExp;
|
||||
action?: (page: Page) => Promise<void>;
|
||||
}[] = [
|
||||
{
|
||||
name: "keeps the todo panel when adding the selected entity query",
|
||||
path: "/todo",
|
||||
element: "ha-panel-todo",
|
||||
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the history panel when removing the back query",
|
||||
path: "/?back=1#/history",
|
||||
element: "ha-panel-history, history-panel",
|
||||
url: /\/#\/history$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the logbook panel when removing the back query",
|
||||
path: "/?back=1#/logbook",
|
||||
element: "ha-panel-logbook",
|
||||
url: /\/#\/logbook$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the lovelace panel when adding the edit query",
|
||||
path: "/lovelace",
|
||||
element: "ha-panel-lovelace, hui-root",
|
||||
url: /\/\?edit=1#\/lovelace\/home$/,
|
||||
action: (page) => setLovelaceEditMode(page, true),
|
||||
},
|
||||
{
|
||||
name: "keeps the lovelace panel when removing the edit query",
|
||||
path: "/lovelace",
|
||||
element: "ha-panel-lovelace, hui-root",
|
||||
url: /\/#\/lovelace\/home$/,
|
||||
action: async (page) => {
|
||||
await setLovelaceEditMode(page, true);
|
||||
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await setLovelaceEditMode(page, false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface E2ELovelaceRoot extends HTMLElement {
|
||||
lovelace?: {
|
||||
setEditMode: (editMode: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The test app is built with __DEMO__=true which enables hash-based routing.
|
||||
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
|
||||
// Scenario selection uses query params: /?scenario=foo (always at root).
|
||||
|
||||
/** Navigate to a panel (hash routing) and wait for app to initialize. */
|
||||
async function goToPanel(page: Page, path: string) {
|
||||
// Paths starting with /? are root-level (scenario selection); panel paths
|
||||
// need to use hash routing (/#/panelname).
|
||||
const url = path.startsWith("/?") ? path : `/#${path}`;
|
||||
await page.goto(url);
|
||||
await page.waitForSelector("ha-test", { state: "attached" });
|
||||
// Wait for the app to finish initialising (hassConnected sets panels)
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
}
|
||||
|
||||
async function setLovelaceEditMode(page: Page, editMode: boolean) {
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.evaluate(async (el: Element, value) => {
|
||||
const root = el as E2ELovelaceRoot;
|
||||
const start = performance.now();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const check = () => {
|
||||
if (root.lovelace?.setEditMode) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (performance.now() - start > 2000) {
|
||||
reject(new Error("Lovelace edit mode action was not available"));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
root.lovelace!.setEditMode(value);
|
||||
}, editMode);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell
|
||||
@@ -34,40 +169,60 @@ import {
|
||||
|
||||
test.describe("App shell", () => {
|
||||
test("page loads and ha-test element mounts", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
expectNoPageErrors(errors, undefined, []);
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders with expected panels", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
await Promise.all([
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root.
|
||||
...["lovelace", "map", "energy", "history"].map((urlPath) =>
|
||||
expect(appSidebarPanel(page, urlPath)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
})
|
||||
),
|
||||
// Config has its own special element with id="sidebar-config".
|
||||
expect(appSidebarConfig(page)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
}),
|
||||
]);
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
|
||||
for (const urlPath of ["lovelace", "map", "energy", "history"]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
|
||||
)
|
||||
).toBeAttached();
|
||||
}
|
||||
// Config has its own special element with id="sidebar-config"
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
)
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const historyLink = await ensureAppSidebarPanelVisible(page, "history");
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const historyLink = sidebar.locator("#sidebar-panel-history");
|
||||
if (!(await historyLink.isVisible().catch(() => false))) {
|
||||
await page.locator("ha-test >> home-assistant-main").evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-toggle-menu", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await expect(historyLink).toBeVisible({ timeout: SHELL_TIMEOUT });
|
||||
await historyLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_TIMEOUT });
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: SHELL_TIMEOUT });
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
@@ -76,29 +231,34 @@ test.describe("App shell", () => {
|
||||
test("sidebar renders notification badge", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const sidebar = appSidebar(page);
|
||||
await expect(sidebar).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const notificationsLink = sidebar.locator("#sidebar-notifications");
|
||||
await expect(notificationsLink).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(notificationsLink).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(notificationsLink.locator(".badge").first()).toHaveText("1", {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar marks the active panel as selected", async ({ page }) => {
|
||||
const lovelaceLink = appSidebarPanel(page, "lovelace");
|
||||
const historyLink = appSidebarPanel(page, "history");
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
const lovelaceLink = sidebar.locator("#sidebar-panel-lovelace");
|
||||
const historyLink = sidebar.locator("#sidebar-panel-history");
|
||||
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(lovelaceLink).toHaveClass(/selected/, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await expect(historyLink).not.toHaveClass(/selected/);
|
||||
|
||||
await goToPanel(page, "/history");
|
||||
await expect(historyLink).toHaveClass(/selected/, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await expect(lovelaceLink).not.toHaveClass(/selected/);
|
||||
});
|
||||
@@ -111,16 +271,124 @@ test.describe("App shell", () => {
|
||||
await goToPanel(page, "/?scenario=non-admin#/lovelace");
|
||||
|
||||
// Wait for the sidebar to mount before asserting on its contents.
|
||||
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(
|
||||
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
|
||||
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// Config panel is adminOnly — should not appear for non-admin.
|
||||
await expect(appSidebarConfig(page)).not.toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
const configLink = page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
);
|
||||
await expect(configLink).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Panel navigation", () => {
|
||||
for (const [path, element] of e2ePanelRouteAssertions) {
|
||||
test(`renders registered panel ${path}`, async ({ page }) => {
|
||||
await goToPanel(page, path);
|
||||
await expect(page.locator(element).first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Panel URL normalization", () => {
|
||||
for (const {
|
||||
name,
|
||||
path,
|
||||
element,
|
||||
url,
|
||||
action,
|
||||
} of URL_NORMALIZATION_ASSERTIONS) {
|
||||
test(name, async ({ page }) => {
|
||||
await goToPanel(page, path);
|
||||
await action?.(page);
|
||||
await expect(page).toHaveURL(url, { timeout: SHELL_TIMEOUT });
|
||||
await expect(page.locator(element).first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools panel (formerly Developer tools)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every tool sub-page reachable under /config/tools, mapped to the custom
|
||||
* element tools-router mounts for it (see tools-router.ts). Asserting on the
|
||||
* specific element proves the route actually rendered its tool, not just the
|
||||
* shared ha-panel-tools shell.
|
||||
*/
|
||||
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
|
||||
{ route: "yaml", element: "tools-yaml-config" },
|
||||
{ route: "state", element: "tools-state" },
|
||||
{ route: "action", element: "tools-action" },
|
||||
{ route: "template", element: "tools-template" },
|
||||
{ route: "event", element: "tools-event" },
|
||||
{ route: "statistics", element: "tools-statistics" },
|
||||
{ route: "assist", element: "tools-assist" },
|
||||
{ route: "debug", element: "tools-debug" },
|
||||
];
|
||||
|
||||
test.describe("Tools panel", () => {
|
||||
test("base path renders the tools panel", async ({ page }) => {
|
||||
await goToPanel(page, "/config/tools");
|
||||
await expect(page.locator("ha-panel-tools")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
for (const { route, element } of TOOLS_SUBPAGES) {
|
||||
test(`renders the ${route} sub-page`, async ({ page }) => {
|
||||
await goToPanel(page, `/config/tools/${route}`);
|
||||
await expect(page.locator(element)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("service is an alias for the action tool", async ({ page }) => {
|
||||
await goToPanel(page, "/config/tools/service");
|
||||
await expect(page.locator("tools-action")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools redirects (old developer-tools URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Tools redirects", () => {
|
||||
// The panel moved from top-level /developer-tools (pre-2026.2) to
|
||||
// /config/developer-tools (2026.2), then was renamed to /config/tools
|
||||
// (2026.8). Both old locations must redirect to the new one, and deep links
|
||||
// must keep their sub-page. See the updateRoute() redirect in
|
||||
// src/layouts/home-assistant.ts.
|
||||
for (const oldBase of ["/developer-tools", "/config/developer-tools"]) {
|
||||
test(`redirects ${oldBase} to the tools panel`, async ({ page }) => {
|
||||
await goToPanel(page, oldBase);
|
||||
await expect(page.locator("ha-panel-tools")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test(`redirects ${oldBase}/state to the state tool`, async ({ page }) => {
|
||||
await goToPanel(page, `${oldBase}/state`);
|
||||
await expect(page.locator("tools-state")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
@@ -149,7 +417,7 @@ test.describe("Lovelace dashboard", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Light more-info dialog", () => {
|
||||
for (const { view, element, content } of moreInfoViewElements) {
|
||||
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
|
||||
test(`opens more-info ${view} view for a light entity`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -187,7 +455,16 @@ test.describe("Light more-info dialog", () => {
|
||||
|
||||
// Each view should render its own characteristic content, not just an
|
||||
// empty shell.
|
||||
await assertElementContent(dialog, content);
|
||||
for (const { selector, text } of content) {
|
||||
const locator = dialog.locator(selector).first();
|
||||
if (text) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT });
|
||||
} else {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -244,27 +521,144 @@ test.describe("Theming", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Config panel", () => {
|
||||
const DASHBOARD_LINKS = [
|
||||
{ href: "/config/integrations", label: "Devices & services" },
|
||||
{ href: "/config/automation", label: "Automations & scenes" },
|
||||
{ href: "/config/areas", label: "Areas, labels & zones" },
|
||||
{ href: "/config/apps", label: "Apps" },
|
||||
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
|
||||
{ href: "/config/voice-assistants", label: "Voice assistants" },
|
||||
{ href: "/config/matter", label: "Matter" },
|
||||
{ href: "/config/zha", label: "Zigbee" },
|
||||
{ href: "/config/zwave_js", label: "Z-Wave" },
|
||||
{ href: "/knx", label: "KNX" },
|
||||
{ href: "/config/thread", label: "Thread" },
|
||||
{ href: "/config/bluetooth", label: "Bluetooth" },
|
||||
{ href: "/config/infrared", label: "Infrared" },
|
||||
{ href: "/config/radio-frequency", label: "Radio frequency" },
|
||||
{ href: "/insteon", label: "Insteon" },
|
||||
{ href: "/config/tags", label: "Tags" },
|
||||
{ href: "/config/person", label: "People" },
|
||||
{ href: "/config/system", label: "System" },
|
||||
{ href: "/config/tools", label: "Tools" },
|
||||
{ href: "/config/info", label: "About" },
|
||||
];
|
||||
|
||||
const CONFIG_ROUTES: { path: string; element: string }[] = [
|
||||
{ path: "/config/integrations", element: "ha-config-integrations" },
|
||||
{ path: "/config/devices", element: "ha-config-devices" },
|
||||
{ path: "/config/entities", element: "ha-config-entities" },
|
||||
{ path: "/config/helpers", element: "ha-config-helpers" },
|
||||
{ path: "/config/areas", element: "ha-config-areas" },
|
||||
{ path: "/config/apps", element: "ha-config-apps" },
|
||||
{ path: "/config/app", element: "ha-config-app-dashboard" },
|
||||
{ path: "/config/automation", element: "ha-config-automation" },
|
||||
{ path: "/config/backup", element: "ha-config-backup" },
|
||||
{ path: "/config/scene", element: "ha-config-scene" },
|
||||
{ path: "/config/script", element: "ha-config-script" },
|
||||
{ path: "/config/blueprint", element: "ha-config-blueprint" },
|
||||
{ path: "/config/cloud", element: "ha-config-cloud" },
|
||||
{ path: "/config/energy", element: "ha-config-energy" },
|
||||
{ path: "/config/hardware", element: "ha-config-hardware" },
|
||||
{ path: "/config/labs", element: "ha-config-labs" },
|
||||
{ path: "/config/lovelace", element: "ha-config-lovelace" },
|
||||
{ path: "/config/person", element: "ha-config-person" },
|
||||
{ path: "/config/storage", element: "ha-config-section-storage" },
|
||||
{ path: "/config/tags", element: "ha-config-tags" },
|
||||
{ path: "/config/users", element: "ha-config-users" },
|
||||
{ path: "/config/voice-assistants", element: "ha-config-voice-assistants" },
|
||||
{ path: "/config/system", element: "ha-config-system-navigation" },
|
||||
{ path: "/config/info", element: "ha-config-info" },
|
||||
{ path: "/config/logs", element: "ha-config-logs" },
|
||||
{ path: "/config/general", element: "ha-config-section-general" },
|
||||
{ path: "/config/updates", element: "ha-config-section-updates" },
|
||||
{ path: "/config/repairs", element: "ha-config-repairs-dashboard" },
|
||||
{ path: "/config/analytics", element: "ha-config-section-analytics" },
|
||||
{ path: "/config/ai-tasks", element: "ha-config-section-ai-tasks" },
|
||||
{ path: "/config/labels", element: "ha-config-labels" },
|
||||
{ path: "/config/zone", element: "ha-config-zone" },
|
||||
{ path: "/config/network", element: "ha-config-section-network" },
|
||||
{
|
||||
path: "/config/application_credentials",
|
||||
element: "ha-config-application-credentials",
|
||||
},
|
||||
{ path: "/config/bluetooth", element: "bluetooth-config-dashboard-router" },
|
||||
{ path: "/config/dhcp", element: "dhcp-config-panel" },
|
||||
{ path: "/config/infrared", element: "infrared-config-dashboard-router" },
|
||||
{ path: "/config/matter", element: "matter-config-panel" },
|
||||
{ path: "/config/mqtt", element: "mqtt-config-panel" },
|
||||
{
|
||||
path: "/config/radio-frequency",
|
||||
element: "radio-frequency-config-dashboard-router",
|
||||
},
|
||||
{ path: "/config/ssdp", element: "ssdp-config-panel" },
|
||||
{ path: "/config/thread", element: "thread-config-panel" },
|
||||
{ path: "/config/zeroconf", element: "zeroconf-config-panel" },
|
||||
{ path: "/config/zha", element: "zha-config-dashboard-router" },
|
||||
{ path: "/config/zwave_js", element: "zwave_js-config-router" },
|
||||
];
|
||||
|
||||
const NESTED_CONFIG_ROUTES: { path: string; element: string }[] = [
|
||||
{
|
||||
path: "/config/integrations/dashboard",
|
||||
element: "ha-config-integrations-dashboard",
|
||||
},
|
||||
{
|
||||
path: "/config/devices/dashboard",
|
||||
element: "ha-config-devices-dashboard",
|
||||
},
|
||||
{ path: "/config/areas/dashboard", element: "ha-config-areas-dashboard" },
|
||||
{ path: "/config/backup/settings", element: "ha-config-backup-settings" },
|
||||
];
|
||||
|
||||
test("config panel loads without JS errors", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/config");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, ha-config-dashboard").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
|
||||
|
||||
expectNoPageErrors(errors);
|
||||
// Filter known pre-existing errors from vendor code
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
const getDashboard = async (page) => {
|
||||
test("dashboard renders key settings links", async ({ page }) => {
|
||||
await goToPanel(page, "/config");
|
||||
const dashboard = page.locator("ha-config-dashboard");
|
||||
await expect(dashboard).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
return dashboard;
|
||||
};
|
||||
|
||||
defineLinkSmokeTests(
|
||||
"config links point to expected pages",
|
||||
configLinks,
|
||||
getDashboard
|
||||
);
|
||||
const dashboard = page.locator("ha-config-dashboard");
|
||||
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
|
||||
for (const { href, label } of DASHBOARD_LINKS) {
|
||||
const link = dashboard.getByRole("link", {
|
||||
name: new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(link).toHaveAttribute("href", href, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const { path, element } of CONFIG_ROUTES) {
|
||||
test(`renders ${path}`, async ({ page }) => {
|
||||
await goToPanel(page, path);
|
||||
await expect(page.locator(element)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { path, element } of NESTED_CONFIG_ROUTES) {
|
||||
test(`renders ${path}`, async ({ page }) => {
|
||||
await goToPanel(page, path);
|
||||
await expect(page.locator(element)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import {
|
||||
defineParallelSmokeTests,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
} from "../../helpers";
|
||||
|
||||
const APP_MAIN_SELECTOR = "ha-test >> home-assistant-main";
|
||||
const APP_SIDEBAR_SELECTOR = `${APP_MAIN_SELECTOR} >> ha-sidebar`;
|
||||
|
||||
// The app e2e harness is built with __DEMO__=true, which enables hash routing.
|
||||
// Scenario selection uses query params at root: /?scenario=foo#/lovelace.
|
||||
export async function goToPanel(page: Page, path: string) {
|
||||
const url = path.startsWith("/?") ? path : `/#${path}`;
|
||||
await page.goto(url);
|
||||
await Promise.all([
|
||||
page.waitForSelector("ha-test", {
|
||||
state: "attached",
|
||||
timeout: SHELL_TIMEOUT,
|
||||
}),
|
||||
page.waitForFunction(
|
||||
() => "__mockHass" in window && Boolean(window.__mockHass),
|
||||
undefined,
|
||||
{ timeout: SHELL_TIMEOUT }
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
|
||||
|
||||
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
|
||||
|
||||
export const appSidebarPanel = (page: Page, panel: string) =>
|
||||
appSidebar(page).locator(`#sidebar-panel-${panel}`);
|
||||
|
||||
export const appSidebarConfig = (page: Page) =>
|
||||
appSidebar(page).locator("#sidebar-config");
|
||||
|
||||
export async function openAppSidebar(page: Page) {
|
||||
await appMain(page).evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-toggle-menu", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureAppSidebarPanelVisible(page: Page, panel: string) {
|
||||
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const link = appSidebarPanel(page, panel);
|
||||
if (!(await link.isVisible().catch(() => false))) {
|
||||
await openAppSidebar(page);
|
||||
}
|
||||
await expect(link).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
return link;
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export interface LinkSmokeCase {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export async function assertLink(
|
||||
root: Locator,
|
||||
{ href, label }: LinkSmokeCase
|
||||
) {
|
||||
const link = root.getByRole("link", {
|
||||
name: new RegExp(`^${escapeRegExp(label)}\\b`),
|
||||
});
|
||||
await expect(link).toHaveAttribute("href", href, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export function defineLinkSmokeTests(
|
||||
name: string,
|
||||
links: LinkSmokeCase[],
|
||||
getRoot: (page: Page) => Promise<Locator>
|
||||
) {
|
||||
test(name, async ({ page }) => {
|
||||
const root = await getRoot(page);
|
||||
|
||||
await Promise.all(
|
||||
links.map((link) =>
|
||||
test.step(`${link.label} links to ${link.href}`, async () => {
|
||||
await assertLink(root, link);
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ElementContentAssertion {
|
||||
selector: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ViewElementSmokeCase<TView extends string = string> {
|
||||
view: TView;
|
||||
element: string;
|
||||
content: ElementContentAssertion[];
|
||||
}
|
||||
|
||||
export async function assertElementContent(
|
||||
root: Locator,
|
||||
content: ElementContentAssertion[]
|
||||
) {
|
||||
await Promise.all(
|
||||
content.map(({ selector, text }) => {
|
||||
const locator = root.locator(selector).first();
|
||||
return text
|
||||
? expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT })
|
||||
: expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export interface RouteSmokeCase {
|
||||
name?: string;
|
||||
path: string;
|
||||
element: string;
|
||||
url?: RegExp;
|
||||
action?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RouteSmokeGroup {
|
||||
name: string;
|
||||
routes: RouteSmokeCase[];
|
||||
testName?: (route: RouteSmokeCase) => string;
|
||||
}
|
||||
|
||||
export const routeCase = (path: string, element: string): RouteSmokeCase => ({
|
||||
path,
|
||||
element,
|
||||
});
|
||||
|
||||
export const routeCases = (routes: [string, string][]): RouteSmokeCase[] =>
|
||||
routes.map(([path, element]) => routeCase(path, element));
|
||||
|
||||
export const rendersRoute = (route: RouteSmokeCase) => `renders ${route.path}`;
|
||||
|
||||
async function assertRouteSmoke(page: Page, route: RouteSmokeCase) {
|
||||
await goToPanel(page, route.path);
|
||||
await route.action?.(page);
|
||||
if (route.url) {
|
||||
await expect(page).toHaveURL(route.url, { timeout: QUICK_TIMEOUT });
|
||||
}
|
||||
await expect(page.locator(route.element).first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export function defineRouteSmokeTests(groups: RouteSmokeGroup[]) {
|
||||
defineParallelSmokeTests({
|
||||
groups,
|
||||
groupName: (group) => group.name,
|
||||
cases: (group) => group.routes,
|
||||
testName: (route, group) =>
|
||||
route.name ?? group.testName?.(route) ?? rendersRoute(route),
|
||||
run: async ({ page, smokeCase }) => {
|
||||
await assertRouteSmoke(page, smokeCase);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MoreInfoView } from "../../../../src/dialogs/more-info/const";
|
||||
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "../../helpers";
|
||||
import {
|
||||
rendersRoute,
|
||||
routeCase,
|
||||
routeCases,
|
||||
type LinkSmokeCase,
|
||||
type RouteSmokeCase,
|
||||
type RouteSmokeGroup,
|
||||
type ViewElementSmokeCase,
|
||||
} from "./helpers";
|
||||
import { e2ePanelRouteAssertions } from "./ha-test-panels";
|
||||
|
||||
// ── Config dashboard links ───────────────────────────────────────────────────
|
||||
|
||||
export const configLinks: LinkSmokeCase[] = [
|
||||
{ href: "/config/integrations", label: "Devices & services" },
|
||||
{ href: "/config/automation", label: "Automations & scenes" },
|
||||
{ href: "/config/areas", label: "Areas, labels & zones" },
|
||||
{ href: "/config/apps", label: "Apps" },
|
||||
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
|
||||
{ href: "/config/voice-assistants", label: "Voice assistants" },
|
||||
{ href: "/config/matter", label: "Matter" },
|
||||
{ href: "/config/zha", label: "Zigbee" },
|
||||
{ href: "/config/zwave_js", label: "Z-Wave" },
|
||||
{ href: "/knx", label: "KNX" },
|
||||
{ href: "/config/thread", label: "Thread" },
|
||||
{ href: "/config/bluetooth", label: "Bluetooth" },
|
||||
{ href: "/config/infrared", label: "Infrared" },
|
||||
{ href: "/config/radio-frequency", label: "Radio frequency" },
|
||||
{ href: "/insteon", label: "Insteon" },
|
||||
{ href: "/config/tags", label: "Tags" },
|
||||
{ href: "/config/person", label: "People" },
|
||||
{ href: "/config/system", label: "System" },
|
||||
{ href: "/config/tools", label: "Tools" },
|
||||
{ href: "/config/info", label: "About" },
|
||||
];
|
||||
|
||||
// ── More-info dialog views ───────────────────────────────────────────────────
|
||||
|
||||
export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
|
||||
{
|
||||
view: "info",
|
||||
element: "ha-more-info-info",
|
||||
content: [
|
||||
{ selector: "more-info-light" },
|
||||
{ selector: "span.title", text: "Test Light" },
|
||||
],
|
||||
},
|
||||
{
|
||||
view: "history",
|
||||
element: "ha-more-info-history-and-logbook",
|
||||
// The demo loads the history component but not logbook.
|
||||
content: [{ selector: "ha-more-info-history" }],
|
||||
},
|
||||
{
|
||||
view: "settings",
|
||||
element: "ha-more-info-settings",
|
||||
// The scenario mocks config/entity_registry/get, so the real registry
|
||||
// panel renders instead of the "no unique ID" warning.
|
||||
content: [{ selector: "entity-registry-settings" }],
|
||||
},
|
||||
{
|
||||
view: "related",
|
||||
element: "ha-related-items",
|
||||
// search/related is mocked to return no relations, so the empty list
|
||||
// renders.
|
||||
content: [{ selector: "ha-related-items >> ha-list" }],
|
||||
},
|
||||
{
|
||||
view: "add_to",
|
||||
element: "ha-more-info-add-to",
|
||||
// Admin users get the default add-to action list.
|
||||
content: [{ selector: "ha-add-to-action-list" }],
|
||||
},
|
||||
{
|
||||
view: "details",
|
||||
element: "ha-more-info-details",
|
||||
// The details view renders the state and attributes cards.
|
||||
content: [{ selector: "ha-card" }],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Route smoke tests ────────────────────────────────────────────────────────
|
||||
|
||||
interface E2ELovelaceRoot extends HTMLElement {
|
||||
lovelace?: {
|
||||
setEditMode: (editMode: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
async function setLovelaceEditMode(page: Page, editMode: boolean) {
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.evaluate(async (el: Element, value) => {
|
||||
const root = el as E2ELovelaceRoot;
|
||||
const start = performance.now();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const check = () => {
|
||||
if (root.lovelace?.setEditMode) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (performance.now() - start > 2000) {
|
||||
reject(new Error("Lovelace edit mode action was not available"));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
root.lovelace!.setEditMode(value);
|
||||
}, editMode);
|
||||
}
|
||||
|
||||
const PANEL_ROUTE_ASSERTIONS = Array.from(
|
||||
e2ePanelRouteAssertions,
|
||||
([path, element]) => routeCase(path, element)
|
||||
);
|
||||
|
||||
const URL_NORMALIZATION_ASSERTIONS: RouteSmokeCase[] = [
|
||||
{
|
||||
name: "keeps the todo panel when adding the selected entity query",
|
||||
path: "/todo",
|
||||
element: "ha-panel-todo",
|
||||
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the history panel when removing the back query",
|
||||
path: "/?back=1#/history",
|
||||
element: "ha-panel-history, history-panel",
|
||||
url: /\/#\/history$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the logbook panel when removing the back query",
|
||||
path: "/?back=1#/logbook",
|
||||
element: "ha-panel-logbook",
|
||||
url: /\/#\/logbook$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the lovelace panel when removing the edit query",
|
||||
path: "/lovelace",
|
||||
element: "ha-panel-lovelace, hui-root",
|
||||
url: /\/#\/lovelace\/home$/,
|
||||
action: async (page) => {
|
||||
await setLovelaceEditMode(page, true);
|
||||
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await setLovelaceEditMode(page, false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
|
||||
{ route: "yaml", element: "tools-yaml-config" },
|
||||
{ route: "state", element: "tools-state" },
|
||||
{ route: "action", element: "tools-action" },
|
||||
{ route: "template", element: "tools-template" },
|
||||
{ route: "event", element: "tools-event" },
|
||||
{ route: "statistics", element: "tools-statistics" },
|
||||
{ route: "assist", element: "tools-assist" },
|
||||
{ route: "debug", element: "tools-debug" },
|
||||
];
|
||||
|
||||
const TOOLS_ROUTE_ASSERTIONS = [
|
||||
routeCase("/config/tools", "ha-panel-tools"),
|
||||
...TOOLS_SUBPAGES.map(({ route, element }) =>
|
||||
routeCase(`/config/tools/${route}`, element)
|
||||
),
|
||||
routeCase("/config/tools/service", "tools-action"),
|
||||
];
|
||||
|
||||
const TOOLS_REDIRECT_ASSERTIONS = [
|
||||
...["/developer-tools", "/config/developer-tools"].flatMap((oldBase) => [
|
||||
routeCase(oldBase, "ha-panel-tools"),
|
||||
routeCase(`${oldBase}/state`, "tools-state"),
|
||||
]),
|
||||
];
|
||||
|
||||
const CONFIG_ROUTES = routeCases([
|
||||
["/config/integrations", "ha-config-integrations"],
|
||||
["/config/devices", "ha-config-devices"],
|
||||
["/config/entities", "ha-config-entities"],
|
||||
["/config/helpers", "ha-config-helpers"],
|
||||
["/config/areas", "ha-config-areas"],
|
||||
["/config/apps", "ha-config-apps"],
|
||||
["/config/app", "ha-config-app-dashboard"],
|
||||
["/config/automation", "ha-config-automation"],
|
||||
["/config/backup", "ha-config-backup"],
|
||||
["/config/scene", "ha-config-scene"],
|
||||
["/config/script", "ha-config-script"],
|
||||
["/config/blueprint", "ha-config-blueprint"],
|
||||
["/config/cloud", "ha-config-cloud"],
|
||||
["/config/energy", "ha-config-energy"],
|
||||
["/config/hardware", "ha-config-hardware"],
|
||||
["/config/labs", "ha-config-labs"],
|
||||
["/config/lovelace", "ha-config-lovelace"],
|
||||
["/config/person", "ha-config-person"],
|
||||
["/config/storage", "ha-config-section-storage"],
|
||||
["/config/tags", "ha-config-tags"],
|
||||
["/config/users", "ha-config-users"],
|
||||
["/config/voice-assistants", "ha-config-voice-assistants"],
|
||||
["/config/system", "ha-config-system-navigation"],
|
||||
["/config/info", "ha-config-info"],
|
||||
["/config/logs", "ha-config-logs"],
|
||||
["/config/general", "ha-config-section-general"],
|
||||
["/config/updates", "ha-config-section-updates"],
|
||||
["/config/repairs", "ha-config-repairs-dashboard"],
|
||||
["/config/analytics", "ha-config-section-analytics"],
|
||||
["/config/ai-tasks", "ha-config-section-ai-tasks"],
|
||||
["/config/labels", "ha-config-labels"],
|
||||
["/config/zone", "ha-config-zone"],
|
||||
["/config/network", "ha-config-section-network"],
|
||||
["/config/application_credentials", "ha-config-application-credentials"],
|
||||
["/config/bluetooth", "bluetooth-config-dashboard-router"],
|
||||
["/config/dhcp", "dhcp-config-panel"],
|
||||
["/config/infrared", "infrared-config-dashboard-router"],
|
||||
["/config/matter", "matter-config-panel"],
|
||||
["/config/mqtt", "mqtt-config-panel"],
|
||||
["/config/radio-frequency", "radio-frequency-config-dashboard-router"],
|
||||
["/config/ssdp", "ssdp-config-panel"],
|
||||
["/config/thread", "thread-config-panel"],
|
||||
["/config/zeroconf", "zeroconf-config-panel"],
|
||||
["/config/zha", "zha-config-dashboard-router"],
|
||||
["/config/zwave_js", "zwave_js-config-router"],
|
||||
]);
|
||||
|
||||
const NESTED_CONFIG_ROUTES = routeCases([
|
||||
["/config/integrations/dashboard", "ha-config-integrations-dashboard"],
|
||||
["/config/devices/dashboard", "ha-config-devices-dashboard"],
|
||||
["/config/areas/dashboard", "ha-config-areas-dashboard"],
|
||||
["/config/backup/settings", "ha-config-backup-settings"],
|
||||
]);
|
||||
|
||||
export const appRouteSmokeGroups: RouteSmokeGroup[] = [
|
||||
{
|
||||
name: "Panel navigation",
|
||||
routes: PANEL_ROUTE_ASSERTIONS,
|
||||
testName: (route) => `renders registered panel ${route.path}`,
|
||||
},
|
||||
{
|
||||
name: "Panel URL normalization",
|
||||
routes: URL_NORMALIZATION_ASSERTIONS,
|
||||
testName: (route) => route.name!,
|
||||
},
|
||||
{
|
||||
name: "Tools panel",
|
||||
routes: TOOLS_ROUTE_ASSERTIONS,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
{
|
||||
name: "Tools redirects",
|
||||
routes: TOOLS_REDIRECT_ASSERTIONS,
|
||||
testName: (route) => `redirects ${route.path}`,
|
||||
},
|
||||
{
|
||||
name: "Config routes",
|
||||
routes: CONFIG_ROUTES,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
{
|
||||
name: "Nested config routes",
|
||||
routes: NESTED_CONFIG_ROUTES,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
];
|
||||
@@ -5,29 +5,6 @@
|
||||
// Usage: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { join, relative } from "path";
|
||||
|
||||
const findBlobReports = (dir) => {
|
||||
const files = [];
|
||||
const walk = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(entryPath);
|
||||
} else if (entry.name.endsWith(".zip")) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
walk(dir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const dest = "test/e2e/reports/blob";
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
@@ -35,8 +12,10 @@ mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const suite of ["demo", "app", "gallery"]) {
|
||||
const src = `test/e2e/reports/${suite}`;
|
||||
const files = findBlobReports(src);
|
||||
if (!files?.length) {
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
|
||||
} catch {
|
||||
// Suite report directory doesn't exist (e.g. job was skipped or failed
|
||||
// before uploading). Skip gracefully.
|
||||
process.stderr.write(
|
||||
@@ -45,7 +24,6 @@ for (const suite of ["demo", "app", "gallery"]) {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
const name = relative(src, file).replace(/[\\/]/g, "-");
|
||||
cpSync(file, join(dest, `${suite}-${name}`));
|
||||
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-34
@@ -1,68 +1,146 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
NAVIGATION_TIMEOUT,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
appErrors as filterAppErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
activateDemoSidebarPanel,
|
||||
demoCardSelector,
|
||||
moreInfoCardSelector,
|
||||
openDemoSidebar,
|
||||
waitForDemoReady,
|
||||
} from "./demo/helpers";
|
||||
|
||||
test.describe("Home Assistant Demo", () => {
|
||||
let pageErrors: ReturnType<typeof trackPageErrors>;
|
||||
// Collect JS errors during each test so we can assert no unexpected crashes.
|
||||
let pageErrors: Error[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = trackPageErrors(page);
|
||||
pageErrors = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err));
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
function appErrors() {
|
||||
return filterAppErrors(pageErrors);
|
||||
}
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
// ── 1. Page loads ──────────────────────────────────────────────────────────
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
// The custom element is present in the document
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
// The launch screen should disappear once the app is ready
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
// No unhandled JS exceptions
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||
|
||||
await expect(page.locator(demoCardSelector).first()).toBeVisible({
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
const cardSelector = [
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
"hui-glance-card",
|
||||
"hui-button-card",
|
||||
"hui-markdown-card",
|
||||
].join(", ");
|
||||
|
||||
await expect(page.locator(cardSelector).first()).toBeVisible({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
await openDemoSidebar(page);
|
||||
await activateDemoSidebarPanel(page, "map");
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
|
||||
// inside a modal drawer that is closed by default. Open it first via
|
||||
// the ha-menu-button in the top app-bar.
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
await menuButton.click();
|
||||
await expect(page.locator("ha-sidebar")).toBeVisible({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
} else {
|
||||
await expect(page.locator("ha-sidebar")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
const candidatePanels = ["map", "logbook", "history", "config"];
|
||||
|
||||
let clicked = false;
|
||||
for (const panel of candidatePanels) {
|
||||
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const visible = await navItem.isVisible().catch(() => false);
|
||||
if (visible) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await navItem.click();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(clicked, "No known sidebar panel was found to click").toBe(true);
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await waitForDemoReady(page);
|
||||
|
||||
// Tile cards are the most common card type in the demo; fall back to other
|
||||
// clickable card types in case this platform renders a different layout.
|
||||
await expect(page.locator(moreInfoCardSelector).first()).toBeVisible({
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await page.locator(moreInfoCardSelector).first().click();
|
||||
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(dialog.locator("span.title")).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
// Tile cards are the most common card type in the demo; they open the
|
||||
// more-info dialog on click. Fall back to other clickable card types in
|
||||
// case the demo layout on this platform doesn't include tile cards.
|
||||
const cardSelector =
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
|
||||
|
||||
await expect(page.locator(cardSelector).first()).toBeVisible({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await page.locator(cardSelector).first().click();
|
||||
|
||||
// The more-info dialog is a top-level custom element appended to the body.
|
||||
// We verify it is attached, then confirm it rendered by checking the title
|
||||
// span which is slotted into the light DOM and has real layout dimensions.
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const title = dialog.locator("span.title");
|
||||
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
|
||||
|
||||
export const demoCardSelector = [
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
"hui-glance-card",
|
||||
"hui-button-card",
|
||||
"hui-markdown-card",
|
||||
].join(", ");
|
||||
|
||||
export const moreInfoCardSelector =
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
|
||||
|
||||
export async function waitForDemoReady(page: Page) {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDemoSidebar(page: Page) {
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
const modalDrawer = page.locator("ha-drawer").locator("wa-drawer");
|
||||
await Promise.all([
|
||||
modalDrawer.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
element.addEventListener("wa-after-show", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
),
|
||||
menuButton.click(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("ha-sidebar")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateDemoSidebarPanel(page: Page, panel: string) {
|
||||
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||
await expect(navItem).toBeVisible({ timeout: SHELL_TIMEOUT });
|
||||
await navItem.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${panel}(?:/|$)`), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
+306
-61
@@ -7,117 +7,362 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:gallery
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
defineGallerySmokeTests,
|
||||
expectGalleryDemoElement,
|
||||
galleryLocator,
|
||||
getGalleryDemo,
|
||||
goToGalleryHome,
|
||||
GALLERY_SHELL_IGNORED_PAGE_ERRORS,
|
||||
} from "./gallery/helpers";
|
||||
import { componentPages, lovelacePages, moreInfoPages } from "./gallery/pages";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Navigate to a gallery page via hash and wait for it to render. */
|
||||
async function goToGalleryPage(page: Page, hash: string) {
|
||||
// First visit to let ha-gallery boot up
|
||||
await page.goto(`/#${hash}`);
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// Wait for the demo element to appear in ha-gallery's shadow root.
|
||||
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
|
||||
// page-description is only rendered for pages that have a description field,
|
||||
// so we cannot use it as a universal readiness signal.
|
||||
const demoTag = `demo-${hash.replace("/", "-")}`;
|
||||
await page.waitForFunction((tag) => {
|
||||
const gallery = document.querySelector("ha-gallery") as any;
|
||||
return gallery?.shadowRoot?.querySelector(tag) != null;
|
||||
}, demoTag);
|
||||
}
|
||||
|
||||
/** Assert a gallery page loads without console errors.
|
||||
* Demo elements live inside ha-gallery's shadow root — use >> to pierce it.
|
||||
*/
|
||||
async function assertPageLoads(page: Page, hash: string, selector: string) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToGalleryPage(page, hash);
|
||||
|
||||
// Pierce ha-gallery's shadow root with >>
|
||||
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
|
||||
);
|
||||
expect(
|
||||
realErrors,
|
||||
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
// Errors that are gallery-harness artifacts rather than bugs in the component
|
||||
// under test. The Lit-context init-error family that used to live here is gone:
|
||||
// ha-gallery now provides fallback contexts for every demo (mirroring the real
|
||||
// app's root), so context-consuming components resolve `localize`, formatters,
|
||||
// config, etc. synchronously instead of throwing during init.
|
||||
const IGNORED_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data — pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Gallery shell", () => {
|
||||
test("page loads and ha-gallery mounts", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToGalleryHome(page);
|
||||
await page.goto("/");
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
|
||||
expectNoPageErrors(errors, undefined, GALLERY_SHELL_IGNORED_PAGE_ERRORS);
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders navigation links", async ({ page }) => {
|
||||
await goToGalleryHome(page);
|
||||
await expect(galleryLocator(page, "ha-drawer")).toBeAttached({
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// The gallery drawer sidebar is inside ha-gallery's shadow root
|
||||
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
defineGallerySmokeTests("Components", "components", componentPages);
|
||||
defineGallerySmokeTests("More-info dialogs", "more-info", moreInfoPages);
|
||||
defineGallerySmokeTests("Lovelace cards", "lovelace", lovelacePages);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentPages: { name: string; selector: string }[] = [
|
||||
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
||||
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
||||
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
||||
{ name: "ha-button", selector: "demo-components-ha-button" },
|
||||
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
||||
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
||||
{
|
||||
name: "ha-control-circular-slider",
|
||||
selector: "demo-components-ha-control-circular-slider",
|
||||
},
|
||||
{
|
||||
name: "ha-control-number-buttons",
|
||||
selector: "demo-components-ha-control-number-buttons",
|
||||
},
|
||||
{
|
||||
name: "ha-control-select-menu",
|
||||
selector: "demo-components-ha-control-select-menu",
|
||||
},
|
||||
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
||||
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
||||
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
||||
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
||||
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
||||
{
|
||||
name: "ha-expansion-panel",
|
||||
selector: "demo-components-ha-expansion-panel",
|
||||
},
|
||||
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
||||
{ name: "ha-form", selector: "demo-components-ha-form" },
|
||||
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
||||
{
|
||||
name: "ha-hs-color-picker",
|
||||
selector: "demo-components-ha-hs-color-picker",
|
||||
},
|
||||
{ name: "ha-input", selector: "demo-components-ha-input" },
|
||||
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
||||
{ name: "ha-list", selector: "demo-components-ha-list" },
|
||||
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
||||
{
|
||||
name: "ha-progress-button",
|
||||
selector: "demo-components-ha-progress-button",
|
||||
},
|
||||
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
||||
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
||||
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
||||
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
||||
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
||||
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
||||
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
||||
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
||||
{
|
||||
name: "ha-adaptive-dialog",
|
||||
selector: "demo-components-ha-adaptive-dialog",
|
||||
},
|
||||
{
|
||||
name: "ha-adaptive-popover",
|
||||
selector: "demo-components-ha-adaptive-popover",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Components", () => {
|
||||
for (const { name, selector } of componentPages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `components/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const moreInfoPages: { name: string; selector: string }[] = [
|
||||
{ name: "light", selector: "demo-more-info-light" },
|
||||
{ name: "climate", selector: "demo-more-info-climate" },
|
||||
{ name: "cover", selector: "demo-more-info-cover" },
|
||||
{ name: "fan", selector: "demo-more-info-fan" },
|
||||
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
||||
{ name: "input-number", selector: "demo-more-info-input-number" },
|
||||
{ name: "input-text", selector: "demo-more-info-input-text" },
|
||||
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
||||
{ name: "lock", selector: "demo-more-info-lock" },
|
||||
{ name: "media-player", selector: "demo-more-info-media-player" },
|
||||
{ name: "number", selector: "demo-more-info-number" },
|
||||
{ name: "scene", selector: "demo-more-info-scene" },
|
||||
{ name: "timer", selector: "demo-more-info-timer" },
|
||||
{ name: "update", selector: "demo-more-info-update" },
|
||||
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
||||
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
||||
];
|
||||
|
||||
test.describe("More-info dialogs", () => {
|
||||
for (const { name, selector } of moreInfoPages) {
|
||||
test(`more-info ${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `more-info/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace card pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lovelacePages: { name: string; selector: string }[] = [
|
||||
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
||||
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
||||
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
||||
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
||||
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
||||
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
||||
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
||||
{
|
||||
name: "grid-and-stack-card",
|
||||
selector: "demo-lovelace-grid-and-stack-card",
|
||||
},
|
||||
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
||||
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
||||
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
||||
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
||||
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
||||
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
||||
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
||||
{
|
||||
name: "picture-elements-card",
|
||||
selector: "demo-lovelace-picture-elements-card",
|
||||
},
|
||||
{
|
||||
name: "picture-entity-card",
|
||||
selector: "demo-lovelace-picture-entity-card",
|
||||
},
|
||||
{
|
||||
name: "picture-glance-card",
|
||||
selector: "demo-lovelace-picture-glance-card",
|
||||
},
|
||||
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
||||
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
||||
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
||||
];
|
||||
|
||||
test.describe("Lovelace cards", () => {
|
||||
for (const { name, selector } of lovelacePages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `lovelace/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific interaction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Component interactions", () => {
|
||||
test("ha-alert renders all four types", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-alert");
|
||||
await goToGalleryPage(page, "components/ha-alert");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// The demo uses property binding (.alertType) not attribute binding, so we
|
||||
// verify that multiple ha-alert elements are present.
|
||||
// The demo uses property binding (.alertType) not attribute binding,
|
||||
// so we verify that multiple ha-alert elements are present.
|
||||
const alerts = demo.locator("ha-alert");
|
||||
await expect(alerts.nth(3)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
// There should be at least 4 alerts (one per type)
|
||||
await expect(alerts)
|
||||
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
|
||||
.catch(async () => {
|
||||
// If not exactly 4, just verify there are some (demo may include more)
|
||||
const count = await alerts.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-button renders primary action button", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-button");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-button, mwc-button");
|
||||
await goToGalleryPage(page, "components/ha-button");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-control-slider");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-control-slider");
|
||||
await goToGalleryPage(page, "components/ha-control-slider");
|
||||
const demo = page.locator(
|
||||
"ha-gallery >> demo-components-ha-control-slider"
|
||||
);
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-form renders schema-driven fields", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-form");
|
||||
await goToGalleryPage(page, "components/ha-form");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-form");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-form").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-form");
|
||||
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-dialog");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("tile-card renders entity state", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "lovelace/tile-card");
|
||||
|
||||
await expectGalleryDemoElement(demo, "hui-tile-card");
|
||||
await goToGalleryPage(page, "lovelace/tile-card");
|
||||
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("more-info light renders controls", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "more-info/light");
|
||||
await goToGalleryPage(page, "more-info/light");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-light");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
// Light more-info should contain a brightness or color-temp control
|
||||
await expect(
|
||||
demo
|
||||
.locator("ha-control-slider, ha-more-info-light, more-info-content")
|
||||
.first()
|
||||
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
});
|
||||
|
||||
await expectGalleryDemoElement(
|
||||
demo,
|
||||
"ha-control-slider, ha-more-info-light, more-info-content",
|
||||
SHELL_TIMEOUT
|
||||
);
|
||||
test("more-info cover renders position controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/cover");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-cover");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("ha-gauge renders a gauge element", async ({ page }) => {
|
||||
await getGalleryDemo(page, "components/ha-gauge");
|
||||
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area.
|
||||
await expect(galleryLocator(page, "ha-gauge").first()).toBeAttached({
|
||||
await goToGalleryPage(page, "components/ha-gauge");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-switch toggles state on click", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-switch");
|
||||
await goToGalleryPage(page, "components/ha-switch");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// Find the first interactive (non-disabled) switch. Pull its checked state
|
||||
// from the property because ha-switch toggles via property, not attribute.
|
||||
// from the property — ha-switch toggles via property, not the attribute.
|
||||
const switchEl = demo.locator("ha-switch:not([disabled])").first();
|
||||
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const before = await switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
);
|
||||
const before = await switchEl.evaluate((el: any) => el.checked === true);
|
||||
await switchEl.click();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
),
|
||||
{ timeout: QUICK_TIMEOUT }
|
||||
)
|
||||
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
})
|
||||
.toBe(!before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import {
|
||||
defineParallelSmokeTests,
|
||||
expectNoPageErrors,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "../helpers";
|
||||
|
||||
export const GALLERY_SHELL_IGNORED_PAGE_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
];
|
||||
|
||||
export const GALLERY_IGNORED_PAGE_ERRORS: RegExp[] = [
|
||||
...GALLERY_SHELL_IGNORED_PAGE_ERRORS,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data - pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
];
|
||||
|
||||
export interface GalleryPageSmokeCase {
|
||||
name: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export const galleryLocator = (page: Page, selector: string) =>
|
||||
page.locator(`ha-gallery >> ${selector}`);
|
||||
|
||||
const galleryDemoTag = (hash: string) => `demo-${hash.replace(/\//g, "-")}`;
|
||||
|
||||
async function waitForGalleryReady(page: Page) {
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function goToGalleryHome(page: Page) {
|
||||
await page.goto("/");
|
||||
await waitForGalleryReady(page);
|
||||
}
|
||||
|
||||
export async function goToGalleryPage(page: Page, hash: string) {
|
||||
await page.goto(`/#${hash}`);
|
||||
}
|
||||
|
||||
async function expectGalleryPageSelector(page: Page, selector: string) {
|
||||
const locator = galleryLocator(page, selector).first();
|
||||
await expect(locator).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
return locator;
|
||||
}
|
||||
|
||||
export async function getGalleryDemo(page: Page, hash: string) {
|
||||
await goToGalleryPage(page, hash);
|
||||
return expectGalleryPageSelector(page, galleryDemoTag(hash));
|
||||
}
|
||||
|
||||
export async function assertGalleryPageLoads(
|
||||
page: Page,
|
||||
hash: string,
|
||||
selector: string
|
||||
) {
|
||||
const errors = trackPageErrors(page);
|
||||
await goToGalleryPage(page, hash);
|
||||
await expectGalleryPageSelector(page, selector);
|
||||
expectNoPageErrors(errors, hash, GALLERY_IGNORED_PAGE_ERRORS);
|
||||
}
|
||||
|
||||
export function defineGallerySmokeTests(
|
||||
groupName: string,
|
||||
routePrefix: string,
|
||||
pages: GalleryPageSmokeCase[]
|
||||
) {
|
||||
defineParallelSmokeTests({
|
||||
groups: [{ name: groupName, routePrefix, pages }],
|
||||
groupName: (group) => group.name,
|
||||
cases: (group) => group.pages,
|
||||
testName: (smokeCase) => `${smokeCase.name} renders without errors`,
|
||||
run: async ({ page, group, smokeCase }) => {
|
||||
await assertGalleryPageLoads(
|
||||
page,
|
||||
`${group.routePrefix}/${smokeCase.name}`,
|
||||
smokeCase.selector
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectGalleryDemoElement(
|
||||
demo: Locator,
|
||||
selector: string,
|
||||
timeout = QUICK_TIMEOUT
|
||||
) {
|
||||
await expect(demo.locator(selector).first()).toBeAttached({ timeout });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user