mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-24 17:22:48 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cb6c0ccf9 | |||
| 8006eff03c |
@@ -1,244 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
# Cache the downloaded browser build keyed on the pinned Playwright
|
||||
# version (yarn.lock), so re-runs skip the ~170 MB download.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
- name: Merge blob reports
|
||||
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report link to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
@@ -54,16 +54,8 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/lib/cook-raw-quasi.js b/lib/cook-raw-quasi.js
|
||||
index 3ea8fa7be8e357c1066d7417caeeecd841415208..6bf04ab0bed8897b5ff2898ca835867aec5cee6a 100644
|
||||
--- a/lib/cook-raw-quasi.js
|
||||
+++ b/lib/cook-raw-quasi.js
|
||||
@@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
-function cookRawQuasi({transform}, raw) {
|
||||
+function cookRawQuasi({transformSync}, raw) {
|
||||
// This nasty hack is needed until https://github.com/babel/babel/issues/9242 is resolved.
|
||||
const args = {raw};
|
||||
|
||||
- transform('cooked`' + args.raw + '`', {
|
||||
+ // Babel 8 removed synchronous `transform`; use `transformSync` instead.
|
||||
+ transformSync('cooked`' + args.raw + '`', {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
plugins: [
|
||||
+33
-36
@@ -1,3 +1,4 @@
|
||||
/* global require, module, __dirname, process */
|
||||
const path = require("path");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
@@ -83,7 +84,12 @@ module.exports.swcOptions = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
module.exports.babelOptions = ({
|
||||
latestBuild,
|
||||
isProdBuild,
|
||||
isTestBuild,
|
||||
sw,
|
||||
}) => ({
|
||||
babelrc: false,
|
||||
compact: false,
|
||||
assumptions: {
|
||||
@@ -96,22 +102,13 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
useBuiltIns: "usage",
|
||||
corejs: dependencies["core-js"],
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
|
||||
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
|
||||
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
|
||||
[
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
{
|
||||
method: "usage-global",
|
||||
version: dependencies["core-js"],
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
|
||||
{
|
||||
@@ -119,14 +116,32 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
// internally when it owned the polyfill injection via `useBuiltIns`.
|
||||
// Minify template literals for production
|
||||
isProdBuild && [
|
||||
"template-html-minifier",
|
||||
{
|
||||
modules: {
|
||||
...Object.fromEntries(
|
||||
["lit", "lit-element", "lit-html"].map((m) => [
|
||||
m,
|
||||
[
|
||||
"html",
|
||||
{ name: "svg", encapsulation: "svg" },
|
||||
{ name: "css", encapsulation: "style" },
|
||||
],
|
||||
])
|
||||
),
|
||||
"@polymer/polymer/lib/utils/html-tag.js": ["html"],
|
||||
},
|
||||
strictCSS: true,
|
||||
htmlMinifier: module.exports.htmlMinifierOptions,
|
||||
failOnError: false, // we can turn this off in case of false positives
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{ version: dependencies["@babel/runtime"], moduleName: "@babel/runtime" },
|
||||
{ version: dependencies["@babel/runtime"] },
|
||||
],
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
@@ -305,22 +320,4 @@ module.exports.config = {
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global module */
|
||||
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||
// The original helper evaluates `Function('return require("...")')()`
|
||||
// when it detects a Node environment, which causes a runtime
|
||||
|
||||
@@ -45,10 +45,3 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
gulp.task(
|
||||
"develop-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-dev-server-e2e-test-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global process */
|
||||
// Tasks to generate entry HTML
|
||||
|
||||
import {
|
||||
@@ -267,24 +268,3 @@ gulp.task(
|
||||
paths.landingPage_output_es5
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
genPagesDevTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
genPagesProdTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root,
|
||||
paths.e2eTestApp_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
// Copy app static files (icons, polyfills, etc.)
|
||||
fs.copySync(
|
||||
polyPath("public/static"),
|
||||
path.resolve(paths.e2eTestApp_output_root, "static")
|
||||
);
|
||||
// Copy e2e test app public files (manifest, sw stubs)
|
||||
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
|
||||
if (fs.existsSync(e2ePublic)) {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
copyMdiIcons(paths.e2eTestApp_output_static);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Gulp task to generate third-party license notices.
|
||||
|
||||
import { readFile, access, readdir } from "fs/promises";
|
||||
import { readFile, access } from "fs/promises";
|
||||
import { generateLicenseFile } from "generate-license-file";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
@@ -11,98 +11,58 @@ const OUTPUT_FILE = path.join(
|
||||
"third-party-licenses.txt"
|
||||
);
|
||||
|
||||
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
|
||||
|
||||
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
||||
// redistributed alongside the compiled output per Apache License §4(d).
|
||||
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
|
||||
const NOTICE_FILES = [
|
||||
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
|
||||
];
|
||||
|
||||
// Some packages need a manual license override (e.g. they ship multiple
|
||||
// license files and we must pick the right one for the bundled code).
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
//
|
||||
// Each entry is pinned to a specific version. If a package is updated,
|
||||
// this list must be reviewed and the version updated after verifying
|
||||
// that the new version's license still matches. The build will fail if
|
||||
// the pinned version is no longer installed.
|
||||
// that the new version's license still matches. The build will fail
|
||||
// if the installed version does not match the pinned version.
|
||||
const LICENSE_OVERRIDES = [
|
||||
{
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.7.0",
|
||||
licenseFile: "license-mit",
|
||||
licensePath: path.resolve(
|
||||
paths.root_dir,
|
||||
"node_modules/type-fest/license-mit"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Locate the directory of an installed package matching an exact version.
|
||||
//
|
||||
// The copy we care about may be hoisted to the top-level node_modules or
|
||||
// nested under a dependency when a different version occupies the hoisted
|
||||
// slot (e.g. a build-only dependency pulling in an older release). Searching
|
||||
// both keeps this check independent of yarn's hoisting decisions, which can
|
||||
// shift when unrelated dependencies are added.
|
||||
async function findPackageDir(packageName, version) {
|
||||
const candidateDirs = [path.join(NODE_MODULES, packageName)];
|
||||
|
||||
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
|
||||
// node_modules/@scope/<dep>/node_modules/<pkg>.
|
||||
let topLevel = [];
|
||||
try {
|
||||
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
|
||||
} catch {
|
||||
// node_modules unreadable — fall back to the hoisted candidate only.
|
||||
}
|
||||
for (const entry of topLevel) {
|
||||
if (!entry.isDirectory() || entry.name === packageName) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith("@")) {
|
||||
const scopeDir = path.join(NODE_MODULES, entry.name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
|
||||
() => []
|
||||
);
|
||||
for (const dep of scoped) {
|
||||
if (dep.isDirectory()) {
|
||||
candidateDirs.push(
|
||||
path.join(scopeDir, dep.name, "node_modules", packageName)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
candidateDirs.push(
|
||||
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of candidateDirs) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
|
||||
.then(JSON.parse)
|
||||
.catch(() => null);
|
||||
if (pkg?.version === version) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
gulp.task("gen-licenses", async () => {
|
||||
const licenseOverrides = {};
|
||||
|
||||
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const packageDir = await findPackageDir(packageName, version);
|
||||
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
|
||||
const pkgJsonPath = path.resolve(
|
||||
paths.root_dir,
|
||||
`node_modules/${packageName}/package.json`
|
||||
);
|
||||
|
||||
if (!packageDir) {
|
||||
let packageJSON;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
|
||||
} catch {
|
||||
throw new Error(
|
||||
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
|
||||
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
|
||||
);
|
||||
}
|
||||
|
||||
if (packageJSON.version !== version) {
|
||||
throw new Error(
|
||||
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
|
||||
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
||||
);
|
||||
}
|
||||
|
||||
const licensePath = path.join(packageDir, licenseFile);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await access(licensePath);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -232,22 +231,3 @@ gulp.task("rspack-prod-landing-page", () =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
prodBuild(
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -48,12 +48,6 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
(plugin) =>
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
@@ -65,16 +59,16 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
if (presetEnvOpts.useBuiltIns) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
corejsOpts.shippedProposals
|
||||
`${presetEnvOpts.useBuiltIns}-global`,
|
||||
presetEnvOpts?.corejs?.proposals,
|
||||
presetEnvOpts?.shippedProposals
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/* global module */
|
||||
|
||||
module.exports = function litDisableDevModeLoader(source) {
|
||||
return source.replace(
|
||||
/\b(const|let|var) DEV_MODE = true;/g,
|
||||
"$1 DEV_MODE = false;"
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
//
|
||||
// It runs between swc and babel: swc has already stripped TS types and
|
||||
// decorators (so minify-literals' acorn parser only sees plain ESM), but the
|
||||
// `html`/`css`/`svg` tagged templates are still intact at ES2021. Running after
|
||||
// babel instead would miss the legacy build, where babel lowers the templates
|
||||
// to `_taggedTemplateLiteral()` calls that no longer look like tagged templates.
|
||||
|
||||
const remapping = require("@ampproject/remapping");
|
||||
|
||||
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
|
||||
let minifyPromise;
|
||||
const getMinifier = () => {
|
||||
if (!minifyPromise) {
|
||||
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
|
||||
}
|
||||
return minifyPromise;
|
||||
};
|
||||
|
||||
// HTML options mirror the previous babel-plugin-template-html-minifier config
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
const htmlOptions = {
|
||||
caseSensitive: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
decodeEntities: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
};
|
||||
|
||||
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const callback = this.async();
|
||||
getMinifier()
|
||||
.then((minifyHTMLLiterals) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
// No tagged templates changed; pass through untouched (incl. incoming map).
|
||||
callback(null, source, map, meta);
|
||||
return;
|
||||
}
|
||||
// minify-literals builds its map from `source` alone, so `result.map`
|
||||
// describes minified output -> this loader's input (the swc output), not
|
||||
// the original file. Compose it over the incoming map (swc output ->
|
||||
// original source) so the map handed downstream still points at the
|
||||
// original source; otherwise every minified file's source map is wrong.
|
||||
const outMap =
|
||||
map && result.map
|
||||
? remapping([result.map, map], () => null)
|
||||
: (result.map ?? map);
|
||||
callback(null, result.code, outMap, meta);
|
||||
})
|
||||
.catch(callback);
|
||||
};
|
||||
@@ -50,15 +50,4 @@ module.exports = {
|
||||
),
|
||||
|
||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
||||
|
||||
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
|
||||
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
|
||||
e2eTestApp_output_static: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/static"
|
||||
),
|
||||
e2eTestApp_output_latest: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/frontend_latest"
|
||||
),
|
||||
};
|
||||
|
||||
+19
-88
@@ -1,3 +1,4 @@
|
||||
/* global require, module, __dirname */
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -47,12 +48,6 @@ const createRspackConfig = ({
|
||||
dontHash = new Set();
|
||||
}
|
||||
const ignorePackages = bundle.ignorePackages({ latestBuild });
|
||||
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
|
||||
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
|
||||
const litDisableDevModeLoader = path.join(
|
||||
__dirname,
|
||||
"lit-disable-dev-mode-loader.cjs"
|
||||
);
|
||||
return {
|
||||
name,
|
||||
mode: isProdBuild ? "production" : "development",
|
||||
@@ -72,42 +67,25 @@ const createRspackConfig = ({
|
||||
{
|
||||
test: /\.m?js$|\.ts$/,
|
||||
exclude: /node_modules[\\/]core-js/,
|
||||
use: (info) =>
|
||||
[
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
...bundle.babelOptions({
|
||||
latestBuild,
|
||||
isTestBuild,
|
||||
sw: info.issuerLayer === "sw",
|
||||
}),
|
||||
cacheDirectory: !isProdBuild,
|
||||
cacheCompression: false,
|
||||
},
|
||||
use: (info) => [
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
...bundle.babelOptions({
|
||||
latestBuild,
|
||||
isProdBuild,
|
||||
isTestBuild,
|
||||
sw: info.issuerLayer === "sw",
|
||||
}),
|
||||
cacheDirectory: !isProdBuild,
|
||||
cacheCompression: false,
|
||||
},
|
||||
// Minify lit html/svg/css tagged template literals for production.
|
||||
// Must run after swc (TS/decorators stripped, but templates kept at
|
||||
// ES2021) and before babel — otherwise the legacy build lowers
|
||||
// html`` to _taggedTemplateLiteral() calls that can no longer be
|
||||
// matched, leaving legacy templates unminified.
|
||||
isProdBuild && {
|
||||
loader: path.join(
|
||||
__dirname,
|
||||
"minify-template-literals-loader.cjs"
|
||||
),
|
||||
},
|
||||
!latestBuild &&
|
||||
info.resource.startsWith(
|
||||
`${litHtmlDevelopmentRoot}${path.sep}`
|
||||
) && {
|
||||
loader: litDisableDevModeLoader,
|
||||
},
|
||||
{
|
||||
loader: "builtin:swc-loader",
|
||||
options: bundle.swcOptions(),
|
||||
},
|
||||
].filter(Boolean),
|
||||
},
|
||||
{
|
||||
loader: "builtin:swc-loader",
|
||||
options: bundle.swcOptions(),
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
@@ -154,47 +132,6 @@ const createRspackConfig = ({
|
||||
// Only include the JS of entrypoints
|
||||
filter: (file) => file.isInitial && !file.name.endsWith(".map"),
|
||||
}),
|
||||
// Babel can miscompile Lit's pre-minified runtime when downleveling to
|
||||
// ES5. Compile lit-html from its development sources for legacy builds,
|
||||
// then let the normal production minifier handle the final bundle.
|
||||
!latestBuild &&
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/^(?:lit-html(?:\/.*)?|\.{1,2}\/.*\.js)$/,
|
||||
(resource) => {
|
||||
if (resource.request === "lit-html") {
|
||||
resource.request = path.join(
|
||||
litHtmlDevelopmentRoot,
|
||||
"lit-html.js"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resource.request.startsWith("lit-html/")) {
|
||||
if (resource.request.startsWith("lit-html/development/")) {
|
||||
return;
|
||||
}
|
||||
resource.request = path.join(
|
||||
litHtmlDevelopmentRoot,
|
||||
resource.request.slice("lit-html/".length)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
resource.context.startsWith(`${litHtmlRoot}${path.sep}`) &&
|
||||
resource.context !== litHtmlDevelopmentRoot &&
|
||||
!resource.context.startsWith(
|
||||
`${litHtmlDevelopmentRoot}${path.sep}`
|
||||
)
|
||||
) {
|
||||
resource.request = path.join(
|
||||
litHtmlDevelopmentRoot,
|
||||
path.relative(
|
||||
litHtmlRoot,
|
||||
path.resolve(resource.context, resource.request)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
new rspack.DefinePlugin(
|
||||
bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })
|
||||
),
|
||||
@@ -401,11 +338,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
createAppConfig,
|
||||
createDemoConfig,
|
||||
@@ -413,5 +345,4 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
+1
-3
@@ -65,9 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
// `false` for contexts: HomeAssistantAppEl already provides them via
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
const hass = provideHass(this, initial, true);
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
// the UI does not hang waiting for a response.
|
||||
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
type: "run-end",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -234,12 +234,6 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { mdiCog, mdiMenu } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -20,22 +19,6 @@ import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/ha-top-app-bar-fixed";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../src/data/context";
|
||||
import { updateHassGroups } from "../../src/data/context/updateContext";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../src/types";
|
||||
import { PAGES, SIDEBAR } from "../build/import-pages";
|
||||
import {
|
||||
@@ -130,65 +113,6 @@ class HaGallery extends LitElement {
|
||||
|
||||
@state() private _drawerOpen = !this._narrow;
|
||||
|
||||
// Fallback Lit context providers for the whole gallery. The real app's root
|
||||
// element provides these via `contextMixin`; here we mirror that so demos
|
||||
// which render context-consuming components without setting up their own hass
|
||||
// (e.g. bare component demos) still resolve `localize`, formatters, config,
|
||||
// etc. instead of throwing during init. Demos that call `provideHass`
|
||||
// register their own providers closer in the tree, which take precedence.
|
||||
private _contextProviders = {
|
||||
registries: new ContextProvider(this, { context: registriesContext }),
|
||||
internationalization: new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
}),
|
||||
api: new ContextProvider(this, { context: apiContext }),
|
||||
connection: new ContextProvider(this, { context: connectionContext }),
|
||||
ui: new ContextProvider(this, { context: uiContext }),
|
||||
config: new ContextProvider(this, { context: configContext }),
|
||||
formatters: new ContextProvider(this, { context: formattersContext }),
|
||||
};
|
||||
|
||||
// The individual (non-grouped) contexts contextMixin also provides. Components
|
||||
// such as ha-area-picker / ha-entity-picker consume these directly, so the
|
||||
// fallback must cover them too.
|
||||
private _singleContextProviders = {
|
||||
states: new ContextProvider(this, { context: statesContext }),
|
||||
services: new ContextProvider(this, { context: servicesContext }),
|
||||
entities: new ContextProvider(this, { context: entitiesContext }),
|
||||
devices: new ContextProvider(this, { context: devicesContext }),
|
||||
areas: new ContextProvider(this, { context: areasContext }),
|
||||
floors: new ContextProvider(this, { context: floorsContext }),
|
||||
};
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Refresh the fallback contexts before each render so theme/page changes in
|
||||
// the gallery hass propagate to consuming components.
|
||||
const hass = this._galleryHass;
|
||||
(
|
||||
Object.keys(
|
||||
this._contextProviders
|
||||
) as (keyof typeof this._contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = this._contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
hass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
(
|
||||
Object.keys(
|
||||
this._singleContextProviders
|
||||
) as (keyof typeof this._singleContextProviders)[]
|
||||
).forEach((key) => {
|
||||
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
|
||||
hass[key]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const isSettingsPage = this._page === SETTINGS_PAGE;
|
||||
const page = isSettingsPage ? undefined : PAGES[this._page];
|
||||
@@ -652,21 +576,6 @@ class HaGallery extends LitElement {
|
||||
callWS: async () => undefined,
|
||||
fetchWithAuth: async () => new Response(),
|
||||
sendWS: () => undefined,
|
||||
formatEntityState: (stateObj, stateValue) =>
|
||||
(stateValue != null ? stateValue : stateObj.state) ?? "",
|
||||
formatEntityStateToParts: (stateObj, stateValue) => [
|
||||
{
|
||||
type: "value",
|
||||
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
|
||||
},
|
||||
],
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
value != null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../src/data/context";
|
||||
import { updateHassGroups } from "../../../../src/data/context/updateContext";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
@@ -522,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
|
||||
private data = SCHEMAS.map(() => ({}));
|
||||
|
||||
// The date/datetime selectors and the date-picker dialog consume these
|
||||
// contexts (provided by the root element in the real app). Provide them here
|
||||
// so they work in the gallery.
|
||||
private _i18nProvider = new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
});
|
||||
|
||||
private _configProvider = new ContextProvider(this, {
|
||||
context: configContext,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
@@ -543,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
this._i18nProvider.setValue(
|
||||
updateHassGroups.internationalization(this.hass)
|
||||
);
|
||||
this._configProvider.setValue(updateHassGroups.config(this.hass));
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const SHADOWS = ["s", "m", "l"] as const;
|
||||
@@ -18,9 +17,7 @@ export class DemoMiscBoxShadow extends LitElement {
|
||||
(size) => html`
|
||||
<div
|
||||
class="box"
|
||||
style=${styleMap({
|
||||
boxShadow: `var(--ha-box-shadow-${size})`,
|
||||
})}
|
||||
style="box-shadow: var(--ha-box-shadow-${size})"
|
||||
>
|
||||
${size}
|
||||
</div>
|
||||
|
||||
+7
-16
@@ -22,12 +22,7 @@
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
@@ -132,11 +127,10 @@
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.1",
|
||||
"@babel/core": "8.0.0",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@babel/plugin-transform-runtime": "8.0.0",
|
||||
"@babel/preset-env": "8.0.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.62.0",
|
||||
@@ -144,11 +138,9 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.0",
|
||||
"@rsdoctor/rspack-plugin": "1.5.15",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
@@ -165,7 +157,7 @@
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"babel-plugin-template-html-minifier": "patch:babel-plugin-template-html-minifier@npm%3A4.1.0#~/.yarn/patches/babel-plugin-template-html-minifier-npm-4.1.0-9a3c00055a.patch",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.5.0",
|
||||
@@ -190,12 +182,11 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.8",
|
||||
"lint-staged": "17.0.7",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.0.2",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -225,6 +216,6 @@
|
||||
},
|
||||
"packageManager": "yarn@4.17.0",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.0"
|
||||
version = "20260527.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export default function durationToSeconds(duration: string): number {
|
||||
const parts = duration.split(":").map(Number);
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
export const durationDataToSeconds = (duration: HaDurationData): number =>
|
||||
(duration.days || 0) * 86400 +
|
||||
(duration.hours || 0) * 3600 +
|
||||
(duration.minutes || 0) * 60 +
|
||||
(duration.seconds || 0) +
|
||||
(duration.milliseconds || 0) / 1000;
|
||||
|
||||
@@ -125,15 +125,7 @@ export interface EntityPickerDisplay {
|
||||
}
|
||||
|
||||
export const computeEntityPickerDisplay = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "language"
|
||||
| "translationMetadata"
|
||||
>,
|
||||
hass: HomeAssistant,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
|
||||
@@ -1463,12 +1463,6 @@ export class HaDataTable extends LitElement {
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
@media (min-width: 871px) {
|
||||
ha-input-search {
|
||||
--ha-input-search-height: 32px;
|
||||
--ha-input-search-border-radius: 10px;
|
||||
}
|
||||
}
|
||||
slot[name="header"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
} from "../../common/dom/fire_event";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-sortable";
|
||||
import "./ha-entity-picker";
|
||||
import type { HaEntityPicker } from "./ha-entity-picker";
|
||||
|
||||
@customElement("ha-entities-picker")
|
||||
class HaEntitiesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Array }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@@ -85,6 +87,10 @@ class HaEntitiesPicker extends LitElement {
|
||||
public reorder = false;
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const currentEntities = this._currentEntities;
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
@@ -99,6 +105,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
<div class="entity">
|
||||
<ha-entity-picker
|
||||
.curValue=${entityId}
|
||||
.hass=${this.hass}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.excludeDomains=${this.excludeDomains}
|
||||
.includeEntities=${this.includeEntities}
|
||||
@@ -126,6 +133,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
</ha-sortable>
|
||||
<div>
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.excludeDomains=${this.excludeDomains}
|
||||
.includeEntities=${this.includeEntities}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -8,14 +8,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { RelatedIdSets } from "../../common/search/related-context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
relatedContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import { relatedContext } from "../../data/context";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import {
|
||||
entityComboBoxKeys,
|
||||
@@ -45,21 +38,7 @@ const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
export class HaEntityPicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
@@ -171,11 +150,12 @@ export class HaEntityPicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProperties.has("_states") &&
|
||||
this._states[this._pendingEntityId]
|
||||
changedProperties.has("hass") &&
|
||||
this.hass.states !== changedProperties.get("hass")?.states &&
|
||||
this.hass.states[this._pendingEntityId]
|
||||
) {
|
||||
this._setValue(this._pendingEntityId);
|
||||
this._pendingEntityId = undefined;
|
||||
@@ -185,7 +165,7 @@ export class HaEntityPicker extends LitElement {
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
this._i18n.loadBackendTranslation("title");
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
private _findExtraOption(value: string | undefined) {
|
||||
@@ -196,7 +176,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
|
||||
const stateObj = extraOption.entity_id
|
||||
? this._states[extraOption.entity_id]
|
||||
? this.hass.states[extraOption.entity_id]
|
||||
: undefined;
|
||||
if (stateObj) {
|
||||
return html`
|
||||
@@ -232,7 +212,7 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const stateObj = this._states[entityId];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
|
||||
if (!stateObj) {
|
||||
return html`
|
||||
@@ -246,11 +226,7 @@ export class HaEntityPicker extends LitElement {
|
||||
}
|
||||
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
{
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
},
|
||||
this.hass,
|
||||
stateObj
|
||||
);
|
||||
|
||||
@@ -262,7 +238,7 @@ export class HaEntityPicker extends LitElement {
|
||||
};
|
||||
|
||||
private get _showEntityId() {
|
||||
return this.showEntityId || this._config.userData?.showEntityIdPicker;
|
||||
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
|
||||
}
|
||||
|
||||
private _rowRenderer: RenderItemFunction<EntityComboBoxItem> = (
|
||||
@@ -310,14 +286,17 @@ export class HaEntityPicker extends LitElement {
|
||||
};
|
||||
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this._i18n.localize, this.createDomains);
|
||||
this._getCreateItems(this.hass.localize, this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(localize: LocalizeFunc, createDomains: this["createDomains"]) => {
|
||||
(
|
||||
localize: this["hass"]["localize"],
|
||||
createDomains: this["createDomains"]
|
||||
) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
this._i18n.loadFragmentTranslation("config");
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
return createDomains.map((domain) => {
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
@@ -342,9 +321,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
hass: HomeAssistant,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
@@ -354,25 +331,16 @@ export class HaEntityPicker extends LitElement {
|
||||
excludeEntities?: string[],
|
||||
value?: string
|
||||
) =>
|
||||
getEntities(
|
||||
{
|
||||
states,
|
||||
...registries,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
localize: i18n.localize,
|
||||
},
|
||||
{
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
}
|
||||
)
|
||||
getEntities(hass, {
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
})
|
||||
);
|
||||
|
||||
private _sortByRelatedContext = memoizeOne(
|
||||
@@ -391,9 +359,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
private _getItems = () => {
|
||||
const entityItems = this._getEntitiesMemoized(
|
||||
this._states,
|
||||
this._registries,
|
||||
this._i18n,
|
||||
this.hass,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.entityFilter,
|
||||
@@ -407,15 +373,15 @@ export class HaEntityPicker extends LitElement {
|
||||
? this._sortByRelatedContext(
|
||||
entityItems,
|
||||
this._relatedIdSets!,
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._i18n.locale.language
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.locale.language
|
||||
)
|
||||
: entityItems;
|
||||
if (this.extraOptions?.length) {
|
||||
const resolvedExtras = this.extraOptions.map((opt) => ({
|
||||
...opt,
|
||||
stateObj: opt.entity_id ? this._states[opt.entity_id] : undefined,
|
||||
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
|
||||
}));
|
||||
return [...resolvedExtras, ...sortedItems];
|
||||
}
|
||||
@@ -429,10 +395,11 @@ export class HaEntityPicker extends LitElement {
|
||||
protected render() {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this._i18n.localize("ui.components.entity.entity-picker.placeholder");
|
||||
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
@@ -454,9 +421,9 @@ export class HaEntityPicker extends LitElement {
|
||||
use-top-label
|
||||
.addButtonLabel=${this.addButton
|
||||
? (this.addButtonLabel ??
|
||||
this._i18n.localize("ui.components.entity.entity-picker.add"))
|
||||
this.hass.localize("ui.components.entity.entity-picker.add"))
|
||||
: undefined}
|
||||
.unknownItemText=${this._i18n.localize(
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.entity.entity-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -509,7 +476,7 @@ export class HaEntityPicker extends LitElement {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this._states[item.entityId]) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
@@ -535,7 +502,7 @@ export class HaEntityPicker extends LitElement {
|
||||
}
|
||||
|
||||
private _notFoundLabel = (search: string) =>
|
||||
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
|
||||
this.hass.localize("ui.components.entity.entity-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
if (!this.selector.entity?.multiple) {
|
||||
return html`<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.label=${this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
@@ -79,6 +80,7 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-entities-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
|
||||
@@ -91,6 +91,7 @@ export class HaMediaSelector extends LitElement {
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${entityId}
|
||||
.label=${this.label ||
|
||||
this.hass.localize(
|
||||
|
||||
@@ -558,6 +558,7 @@ export class HaServiceControl extends LitElement {
|
||||
></ha-settings-row>`
|
||||
: entityId
|
||||
? html`<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this._value?.data?.entity_id}
|
||||
.label=${this.hass.localize(
|
||||
|
||||
@@ -41,11 +41,7 @@ export class HaInputSearch extends HaInput {
|
||||
...HaInput.styles,
|
||||
css`
|
||||
:host([appearance="outlined"]) wa-input.no-label::part(base) {
|
||||
height: var(--ha-input-search-height, 40px);
|
||||
border-radius: var(
|
||||
--ha-input-search-border-radius,
|
||||
var(--ha-border-radius-md)
|
||||
);
|
||||
height: 40px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -331,14 +331,7 @@ export interface AutomationElementGroupCollection {
|
||||
|
||||
export type AutomationElementGroup = Record<
|
||||
string,
|
||||
{
|
||||
icon?: string;
|
||||
members?: AutomationElementGroup;
|
||||
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
|
||||
// are bundled into this group instead of appearing as their own dynamic
|
||||
// domain group.
|
||||
domains?: string[];
|
||||
}
|
||||
{ icon?: string; members?: AutomationElementGroup }
|
||||
>;
|
||||
|
||||
export type LegacyCondition =
|
||||
|
||||
+7
-12
@@ -1,7 +1,7 @@
|
||||
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { AutomationElementGroupCollection } from "./automation";
|
||||
import type { Selector, TargetSelector } from "./selector";
|
||||
|
||||
@@ -9,14 +9,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
dynamicGroups: {},
|
||||
time: {
|
||||
icon: mdiClockOutline,
|
||||
members: { time: {} },
|
||||
domains: ["calendar", "schedule"],
|
||||
},
|
||||
sun: {
|
||||
icon: mdiWeatherSunny,
|
||||
domains: ["sun"],
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { sun: {}, time: {}, zone: {} },
|
||||
},
|
||||
helpers: {},
|
||||
template: {},
|
||||
@@ -73,10 +68,10 @@ export interface ConditionDescription {
|
||||
export type ConditionDescriptions = Record<string, ConditionDescription>;
|
||||
|
||||
export const subscribeConditions = (
|
||||
connection: Connection,
|
||||
hass: HomeAssistant,
|
||||
callback: (conditions: ConditionDescriptions) => void
|
||||
) =>
|
||||
connection.subscribeMessage<ConditionDescriptions>(callback, {
|
||||
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
|
||||
type: "condition_platforms/subscribe",
|
||||
});
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import type { RelatedIdSets } from "../../common/search/related-context";
|
||||
import type { ConditionDescriptions } from "../condition";
|
||||
import type { ConfigEntry } from "../config_entries";
|
||||
import type { EntityRegistryEntry } from "../entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../integration";
|
||||
import type { LabelRegistryEntry } from "../label/label_registry";
|
||||
import type { ItemType } from "../search";
|
||||
import type { TriggerDescriptions } from "../trigger";
|
||||
|
||||
/**
|
||||
* Entity, device, area, and floor registries
|
||||
@@ -133,19 +131,6 @@ export const configEntriesContext =
|
||||
export const manifestsContext =
|
||||
createContext<DomainManifestLookup>("manifests");
|
||||
|
||||
/**
|
||||
* Lazy loaded trigger platform descriptions, keyed by trigger key.
|
||||
*/
|
||||
export const triggerDescriptionsContext = createContext<TriggerDescriptions>(
|
||||
"triggerDescriptions"
|
||||
);
|
||||
|
||||
/**
|
||||
* Lazy loaded condition platform descriptions, keyed by condition key.
|
||||
*/
|
||||
export const conditionDescriptionsContext =
|
||||
createContext<ConditionDescriptions>("conditionDescriptions");
|
||||
|
||||
// #endregion lazy-contexts
|
||||
|
||||
// #region deprecated-contexts
|
||||
|
||||
+7
-42
@@ -42,12 +42,6 @@ import {
|
||||
|
||||
export const ENERGY_COLLECTION_KEY_PREFIX = "energy_";
|
||||
|
||||
// Collection key for the statistics-based energy dashboard views (Overview,
|
||||
// Electricity, Gas, Water).
|
||||
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
|
||||
// Collection key for the real-time "Now" view (live power + 5-minute stats).
|
||||
export const DEFAULT_POWER_COLLECTION_KEY = "energy_dashboard_now";
|
||||
|
||||
// All collection keys created this session
|
||||
const energyCollectionKeys = new Set<string | undefined>();
|
||||
|
||||
@@ -793,30 +787,9 @@ const findEnergyDataCollection = (
|
||||
return (hass.connection as any)[key];
|
||||
};
|
||||
|
||||
// When does the collection's day period need to roll over to the next day?
|
||||
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
|
||||
// midnight. Otherwise it waits an hour, until the new day's first hourly
|
||||
// statistic exists — rolling over at midnight would show an empty graph.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"]
|
||||
): Date => {
|
||||
const dayEnd = calcDate(now, endOfDay, locale, config);
|
||||
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
|
||||
};
|
||||
|
||||
export const getEnergyDataCollection = (
|
||||
hass: HomeAssistant,
|
||||
options: {
|
||||
prefs?: EnergyPreferences;
|
||||
key?: string;
|
||||
// The real-time "Now" view opts in to rolling its day period over at
|
||||
// midnight rather than an hour later (it shows live data, so it always
|
||||
// tracks today and never falls back to yesterday in the first hour).
|
||||
midnightRollover?: boolean;
|
||||
} = {}
|
||||
options: { prefs?: EnergyPreferences; key?: string } = {}
|
||||
): EnergyCollection => {
|
||||
const [key, collectionKey] = convertCollectionKeyToConnection(
|
||||
hass,
|
||||
@@ -826,8 +799,6 @@ export const getEnergyDataCollection = (
|
||||
return (hass.connection as any)[key];
|
||||
}
|
||||
|
||||
const midnightRollover = options.midnightRollover ?? false;
|
||||
|
||||
energyCollectionKeys.add(collectionKey);
|
||||
|
||||
const collection = getCollection<EnergyData>(
|
||||
@@ -886,16 +857,12 @@ export const getEnergyDataCollection = (
|
||||
|
||||
const now = new Date();
|
||||
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
|
||||
// Set start to start of today if we have data for today, otherwise yesterday.
|
||||
// The real-time "Now" view always tracks today; it shows live data even
|
||||
// before today's first statistic exists, so it never falls back to yesterday.
|
||||
// Set start to start of today if we have data for today, otherwise yesterday
|
||||
const preferredPeriod =
|
||||
(localStorage.getItem(`energy-default-period-${key}`) as DateRange) ||
|
||||
"today";
|
||||
const period =
|
||||
preferredPeriod === "today" && hour === "0" && !midnightRollover
|
||||
? "yesterday"
|
||||
: preferredPeriod;
|
||||
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
|
||||
|
||||
const [start, end] = calcDateRange(hass.locale, hass.config, period);
|
||||
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
|
||||
@@ -919,12 +886,10 @@ export const getEnergyDataCollection = (
|
||||
collection.refresh();
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
new Date(),
|
||||
hass.locale,
|
||||
hass.config
|
||||
).getTime() - Date.now()
|
||||
addHours(
|
||||
calcDate(new Date(), endOfDay, hass.locale, hass.config),
|
||||
1
|
||||
).getTime() - Date.now() // Switch to next day an hour after the day changed
|
||||
);
|
||||
};
|
||||
scheduleUpdatePeriod();
|
||||
|
||||
@@ -58,17 +58,7 @@ export interface GetEntitiesOptions {
|
||||
}
|
||||
|
||||
export const getEntities = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
| "states"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "language"
|
||||
| "translationMetadata"
|
||||
| "localize"
|
||||
>,
|
||||
hass: HomeAssistant,
|
||||
options?: GetEntitiesOptions
|
||||
): EntityComboBoxItem[] => {
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
|
||||
import type { LovelaceBadgeConfig } from "./lovelace/config/badge";
|
||||
import type { LovelaceCardConfig } from "./lovelace/config/card";
|
||||
|
||||
export interface CustomCardSuggestion<
|
||||
@@ -11,13 +10,6 @@ export interface CustomCardSuggestion<
|
||||
config: T;
|
||||
}
|
||||
|
||||
export interface CustomBadgeSuggestion<
|
||||
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
|
||||
> {
|
||||
label?: string;
|
||||
config: T;
|
||||
}
|
||||
|
||||
export interface CustomCardEntry {
|
||||
type: string;
|
||||
name?: string;
|
||||
@@ -36,10 +28,6 @@ export interface CustomBadgeEntry {
|
||||
description?: string;
|
||||
preview?: boolean;
|
||||
documentationURL?: string;
|
||||
getEntitySuggestion?: (
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
) => CustomBadgeSuggestion | CustomBadgeSuggestion[] | null;
|
||||
}
|
||||
|
||||
export interface CustomCardFeatureEntry {
|
||||
|
||||
@@ -153,21 +153,6 @@ export const getRecorderInfo = (conn: Connection) =>
|
||||
type: "recorder/info",
|
||||
});
|
||||
|
||||
export type EntityRecordingDisabler = "user";
|
||||
|
||||
export interface RecorderEntityOptions {
|
||||
recording_disabled_by: EntityRecordingDisabler | null;
|
||||
}
|
||||
|
||||
export const getRecorderEntityOptions = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entity_id: string
|
||||
) =>
|
||||
hass.callWS<RecorderEntityOptions>({
|
||||
type: "recorder/entity_options/get",
|
||||
entity_id,
|
||||
});
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_type?: "mean" | "sum"
|
||||
|
||||
+9
-11
@@ -1,8 +1,8 @@
|
||||
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type {
|
||||
AutomationElementGroupCollection,
|
||||
Trigger,
|
||||
@@ -14,17 +14,15 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
dynamicGroups: {},
|
||||
time: {
|
||||
icon: mdiClockOutline,
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: {
|
||||
calendar: {},
|
||||
sun: {},
|
||||
time: {},
|
||||
time_pattern: {},
|
||||
zone: {},
|
||||
},
|
||||
domains: ["calendar", "schedule"],
|
||||
},
|
||||
sun: {
|
||||
icon: mdiWeatherSunny,
|
||||
domains: ["sun"],
|
||||
},
|
||||
event: {},
|
||||
geo_location: {},
|
||||
@@ -75,10 +73,10 @@ export interface TriggerDescription {
|
||||
export type TriggerDescriptions = Record<string, TriggerDescription>;
|
||||
|
||||
export const subscribeTriggers = (
|
||||
connection: Connection,
|
||||
hass: HomeAssistant,
|
||||
callback: (triggers: TriggerDescriptions) => void
|
||||
) =>
|
||||
connection.subscribeMessage<TriggerDescriptions>(callback, {
|
||||
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
|
||||
type: "trigger_platforms/subscribe",
|
||||
});
|
||||
|
||||
|
||||
@@ -59,12 +59,14 @@ import {
|
||||
getExtendedEntityRegistryEntry,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import { subscribeLabFeature } from "../../data/labs";
|
||||
import type { ItemType } from "../../data/search";
|
||||
import { SearchableDomains } from "../../data/search";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
|
||||
import type { Helper } from "../../panels/config/helpers/const";
|
||||
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
@@ -124,7 +126,7 @@ const DEFAULT_VIEW: MoreInfoView = "info";
|
||||
export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
EntitySettingsState | Helper | Record<string, string[]> | null,
|
||||
"entity-registry" | "helper" | "vacuum-segment-mapping"
|
||||
>()(ScrollableFadeMixin(LitElement)) {
|
||||
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public large = false;
|
||||
@@ -161,6 +163,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _isEscapeEnabled = true;
|
||||
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
protected scrollFadeThreshold = 24;
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
@@ -256,11 +260,24 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
|
||||
private _shouldShowAddEntityTo(): boolean {
|
||||
return (
|
||||
!!this.hass.user?.is_admin ||
|
||||
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
|
||||
!!this.hass.auth.external?.config.hasEntityAddTo
|
||||
);
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeLabFeature(
|
||||
this.hass.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersAndConditions = feature.enabled;
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _getDeviceId(): string | null {
|
||||
const entity = this.hass.entities[this._entityId!] as
|
||||
| EntityRegistryEntry
|
||||
|
||||
@@ -42,7 +42,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.time=${this._time}
|
||||
.entityIds=${this._entityIdAsList(this.entityId)}
|
||||
name-detail="none"
|
||||
.scope=${"entity"}
|
||||
narrow
|
||||
no-icon
|
||||
graph-color
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import {
|
||||
applyThemesOnElement,
|
||||
@@ -7,22 +6,6 @@ import {
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeFormatFunctions } from "../common/translations/entity-state";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import { updateHassGroups } from "../data/context/updateContext";
|
||||
import type { IconCategory } from "../data/icons";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
|
||||
import {
|
||||
@@ -102,84 +85,13 @@ export interface MockHomeAssistant extends HomeAssistant {
|
||||
export const provideHass = (
|
||||
elements,
|
||||
overrideData: Partial<HomeAssistant> = {},
|
||||
setHassProperty = false,
|
||||
// Provide the grouped Lit contexts (registries, internationalization, api,
|
||||
// connection, ui, config, formatters) that the real app's root element
|
||||
// provides via `contextMixin`. On by default so that any standalone hass root
|
||||
// (e.g. a gallery demo) automatically feeds context-consuming components the
|
||||
// same way the real app does, instead of each demo wiring up a partial set by
|
||||
// hand. Pass `false` for hosts that already provide these contexts themselves
|
||||
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
|
||||
// registering duplicate providers on the same element.
|
||||
provideContexts = true
|
||||
setHassProperty = false
|
||||
): MockHomeAssistant => {
|
||||
elements = ensureArray(elements);
|
||||
// Can happen because we store sidebar, more info etc on hass.
|
||||
const baseEl = () => elements[0];
|
||||
const hass = (): MockHomeAssistant => baseEl().hass;
|
||||
|
||||
const contextProviders = provideContexts
|
||||
? {
|
||||
registries: new ContextProvider(baseEl(), {
|
||||
context: registriesContext,
|
||||
}),
|
||||
internationalization: new ContextProvider(baseEl(), {
|
||||
context: internationalizationContext,
|
||||
}),
|
||||
api: new ContextProvider(baseEl(), { context: apiContext }),
|
||||
connection: new ContextProvider(baseEl(), {
|
||||
context: connectionContext,
|
||||
}),
|
||||
ui: new ContextProvider(baseEl(), { context: uiContext }),
|
||||
config: new ContextProvider(baseEl(), { context: configContext }),
|
||||
formatters: new ContextProvider(baseEl(), {
|
||||
context: formattersContext,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// The individual (non-grouped) contexts that contextMixin also provides.
|
||||
// Components such as ha-area-picker / ha-entity-picker consume these directly
|
||||
// (e.g. `Object.values(areas)`), so they must be provided alongside the
|
||||
// grouped contexts or those components throw once they render.
|
||||
const singleContextProviders = provideContexts
|
||||
? {
|
||||
states: new ContextProvider(baseEl(), { context: statesContext }),
|
||||
services: new ContextProvider(baseEl(), { context: servicesContext }),
|
||||
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
|
||||
devices: new ContextProvider(baseEl(), { context: devicesContext }),
|
||||
areas: new ContextProvider(baseEl(), { context: areasContext }),
|
||||
floors: new ContextProvider(baseEl(), { context: floorsContext }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const updateContextProviders = (newHass: HomeAssistant) => {
|
||||
if (contextProviders) {
|
||||
(
|
||||
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
newHass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (singleContextProviders) {
|
||||
(
|
||||
Object.keys(
|
||||
singleContextProviders
|
||||
) as (keyof typeof singleContextProviders)[]
|
||||
).forEach((key) => {
|
||||
(singleContextProviders[key] as ContextProvider<any>).setValue(
|
||||
newHass[key]
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const wsCommands = {};
|
||||
const restResponses: [string | RegExp, MockRestCallback][] = [];
|
||||
|
||||
@@ -484,7 +396,6 @@ export const provideHass = (
|
||||
elements.forEach((el) => {
|
||||
el.hass = newHass;
|
||||
});
|
||||
updateContextProviders(newHass);
|
||||
},
|
||||
updateStates,
|
||||
updateTranslations,
|
||||
@@ -546,10 +457,6 @@ export const provideHass = (
|
||||
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
},
|
||||
],
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
...overrideData,
|
||||
};
|
||||
|
||||
|
||||
@@ -793,12 +793,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
@media (min-width: 871px) {
|
||||
ha-input-search {
|
||||
--ha-input-search-height: 32px;
|
||||
--ha-input-search-border-radius: 10px;
|
||||
}
|
||||
}
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -228,6 +228,7 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
|
||||
></ha-textarea>
|
||||
<ha-entity-picker
|
||||
name="calendar"
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize("ui.components.calendar.label")}
|
||||
.value=${this._calendarId!}
|
||||
.includeDomains=${CALENDAR_DOMAINS}
|
||||
|
||||
@@ -251,6 +251,7 @@ class DialogAreaDetail
|
||||
>
|
||||
<div class="content">
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.temperature_entity"
|
||||
)}
|
||||
@@ -265,6 +266,7 @@ class DialogAreaDetail
|
||||
></ha-entity-picker>
|
||||
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.humidity_entity"
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
mdiShape,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
|
||||
import type {
|
||||
HassEntity,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket/dist/types";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -56,6 +59,7 @@ import {
|
||||
computeEntityRegistryName,
|
||||
sortEntityRegistryByName,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { subscribeLabFeature } from "../../../data/labs";
|
||||
import type { SceneEntity } from "../../../data/scene";
|
||||
import type { ScriptEntity } from "../../../data/script";
|
||||
import type { RelatedResult } from "../../../data/search";
|
||||
@@ -65,6 +69,7 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
@@ -138,7 +143,7 @@ const NAVIGATION_ACTIONS: {
|
||||
const MAX_COLUMNS = 3;
|
||||
|
||||
@customElement("ha-config-area-page")
|
||||
class HaConfigAreaPage extends LitElement {
|
||||
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public areaId!: string;
|
||||
@@ -153,6 +158,8 @@ class HaConfigAreaPage extends LitElement {
|
||||
|
||||
@state() private _related?: RelatedResult;
|
||||
|
||||
@state() private _newTriggersConditions = false;
|
||||
|
||||
private _logbookTime = { recent: 86400 };
|
||||
|
||||
private _columnsController = createColumnsController(this, MAX_COLUMNS);
|
||||
@@ -248,6 +255,23 @@ class HaConfigAreaPage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
|
||||
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||
if (!isComponentLoaded(this.hass!.config, "automation")) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
subscribeLabFeature(
|
||||
this.hass!.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersConditions = feature.enabled;
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
|
||||
return nothing;
|
||||
@@ -353,26 +377,32 @@ class HaConfigAreaPage extends LitElement {
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="action-buttons">
|
||||
${area.picture
|
||||
? nothing
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.add_picture")}
|
||||
</ha-button>`}
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._showAddToDialog}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.dialogs.more_info_control.add_to.item")}
|
||||
</ha-button>
|
||||
</div>
|
||||
${area.picture && !this._newTriggersConditions
|
||||
? nothing
|
||||
: html`<div class="action-buttons">
|
||||
${area.picture
|
||||
? nothing
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.add_picture")}
|
||||
</ha-button>`}
|
||||
${this._newTriggersConditions
|
||||
? html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._showAddToDialog}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>`}
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
@@ -589,7 +619,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._allEntities(memberships)}
|
||||
.deviceIds=${this._allDeviceIds(memberships.devices)}
|
||||
name-detail="device"
|
||||
.scope=${"area"}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -22,9 +21,10 @@ import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
subscribeConditions,
|
||||
} from "../../../../../data/condition";
|
||||
import { conditionDescriptionsContext } from "../../../../../data/context";
|
||||
import { domainToName } from "../../../../../data/integration";
|
||||
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
|
||||
import "../../condition/ha-automation-condition-editor";
|
||||
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
|
||||
@@ -42,7 +42,10 @@ import "../../condition/types/ha-automation-condition-zone";
|
||||
import type { ActionElement } from "../ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action-condition")
|
||||
export class HaConditionAction extends LitElement implements ActionElement {
|
||||
export class HaConditionAction
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements ActionElement
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@@ -55,9 +58,7 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "indent" }) public indent = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions: ConditionDescriptions = {};
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@query("ha-automation-condition-editor")
|
||||
private _conditionEditor?: HaAutomationConditionEditor;
|
||||
@@ -66,6 +67,21 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
return { condition: "state" };
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeConditions(this.hass, (conditions) =>
|
||||
this._addConditions(conditions)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _addConditions(conditions: ConditionDescriptions) {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
|
||||
this.action.condition
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
mdiHelpCircleOutline,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassServiceTarget,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -77,16 +80,13 @@ import {
|
||||
CONDITION_COLLECTIONS,
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
subscribeConditions,
|
||||
} from "../../../data/condition";
|
||||
import {
|
||||
getConfigEntries,
|
||||
type ConfigEntry,
|
||||
} from "../../../data/config_entries";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
labelsContext,
|
||||
triggerDescriptionsContext,
|
||||
} from "../../../data/context";
|
||||
import { labelsContext } from "../../../data/context";
|
||||
import { getDeviceEntityLookup } from "../../../data/device/device_registry";
|
||||
import type { EntityComboBoxItem } from "../../../data/entity/entity_picker";
|
||||
import { getFloorAreaLookup } from "../../../data/floor_registry";
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
fetchIntegrationManifests,
|
||||
} from "../../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
|
||||
import { subscribeLabFeature } from "../../../data/labs";
|
||||
import { filterSelectorEntities } from "../../../data/selector";
|
||||
import {
|
||||
TARGET_SEPARATOR,
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
TRIGGER_COLLECTIONS,
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
subscribeTriggers,
|
||||
} from "../../../data/trigger";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
@@ -192,11 +194,6 @@ const DYNAMIC_KEYWORDS = [
|
||||
|
||||
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
|
||||
|
||||
// Group keys surfaced as their own section in the "by target" tab because
|
||||
// their elements have no target (time/calendar/schedule, sun). Picking one
|
||||
// drills into its items, like selecting the matching group in the "by type" tab.
|
||||
const TIME_LOCATION_GROUPS = ["time", "sun"];
|
||||
|
||||
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
|
||||
|
||||
@customElement("add-automation-element-dialog")
|
||||
@@ -230,13 +227,7 @@ class DialogAddAutomationElement
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: triggerDescriptionsContext, subscribe: true })
|
||||
private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions: ConditionDescriptions = {};
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
@state() private _targetItems?: {
|
||||
title: string;
|
||||
@@ -245,8 +236,12 @@ class DialogAddAutomationElement
|
||||
|
||||
@state() private _loadItemsError = false;
|
||||
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
@state() private _openedFromQuery = false;
|
||||
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
@@ -264,6 +259,10 @@ class DialogAddAutomationElement
|
||||
|
||||
// #region variables
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
private _closing = false;
|
||||
@@ -279,6 +278,31 @@ class DialogAddAutomationElement
|
||||
) {
|
||||
this._calculateUsedDomains();
|
||||
}
|
||||
|
||||
if (changedProps.has("_newTriggersAndConditions")) {
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribeDescriptions() {
|
||||
this._unsubscribe();
|
||||
if (this._params?.type === "trigger") {
|
||||
this._triggerDescriptions = {};
|
||||
this._unsub = subscribeTriggers(this.hass, (triggers) => {
|
||||
this._triggerDescriptions = {
|
||||
...this._triggerDescriptions,
|
||||
...triggers,
|
||||
};
|
||||
});
|
||||
} else if (this._params?.type === "condition") {
|
||||
this._conditionDescriptions = {};
|
||||
this._unsub = subscribeConditions(this.hass, (conditions) => {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public showDialog(params: AddAutomationElementDialogParams): void {
|
||||
@@ -310,9 +334,28 @@ class DialogAddAutomationElement
|
||||
|
||||
this._loadConfigEntries();
|
||||
|
||||
this._unsubscribe();
|
||||
this._fetchManifests();
|
||||
this._calculateUsedDomains();
|
||||
|
||||
this._unsubscribeLabFeatures = subscribeLabFeature(
|
||||
this.hass.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersAndConditions = feature.enabled;
|
||||
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
|
||||
if (
|
||||
queryTarget &&
|
||||
this._newTriggersAndConditions &&
|
||||
!this._selectedTarget
|
||||
) {
|
||||
this._selectedTarget = queryTarget;
|
||||
this._getItemsByTarget();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!queryTarget) {
|
||||
// add initial dialog view state to history
|
||||
mainWindow.history.pushState(
|
||||
@@ -329,9 +372,11 @@ class DialogAddAutomationElement
|
||||
} else if (this._params?.type === "trigger") {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
getTriggerIcons(this.hass.connection, this.hass.config);
|
||||
this._subscribeDescriptions();
|
||||
} else if (this._params?.type === "condition") {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
getConditionIcons(this.hass.connection, this.hass.config);
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", this._updateNarrow);
|
||||
@@ -340,7 +385,11 @@ class DialogAddAutomationElement
|
||||
// prevent view mode switch when resizing window
|
||||
this._bottomSheetMode = this._narrow;
|
||||
|
||||
if (queryTarget && !this._selectedTarget) {
|
||||
if (
|
||||
queryTarget &&
|
||||
this._newTriggersAndConditions &&
|
||||
!this._selectedTarget
|
||||
) {
|
||||
this._selectedTarget = queryTarget;
|
||||
this._tab = "targets";
|
||||
this._getItemsByTarget();
|
||||
@@ -385,6 +434,7 @@ class DialogAddAutomationElement
|
||||
}
|
||||
|
||||
this.removeKeyboardShortcuts();
|
||||
this._unsubscribe();
|
||||
if (this._params) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
@@ -400,7 +450,7 @@ class DialogAddAutomationElement
|
||||
this._selectedCollectionIndex = undefined;
|
||||
this._selectedGroup = undefined;
|
||||
this._selectedTarget = undefined;
|
||||
this._tab = "targets";
|
||||
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
|
||||
this._filter = "";
|
||||
this._manifests = undefined;
|
||||
this._domains = undefined;
|
||||
@@ -539,6 +589,7 @@ class DialogAddAutomationElement
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._updateNarrow);
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected supportedShortcuts(): SupportedShortcuts {
|
||||
@@ -547,10 +598,39 @@ class DialogAddAutomationElement
|
||||
};
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
if (this._unsubscribeLabFeatures) {
|
||||
this._unsubscribeLabFeatures.then((unsub) => unsub());
|
||||
this._unsubscribeLabFeatures = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion lifecycle
|
||||
|
||||
// #region render
|
||||
|
||||
private _getEmptyNote(automationElementType: string) {
|
||||
if (
|
||||
automationElementType !== "trigger" &&
|
||||
automationElementType !== "condition"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target_note`,
|
||||
{
|
||||
labs_link: html`<a href="/config/labs" @click=${this._close}
|
||||
>${this.hass.localize("ui.panel.config.labs.caption")}</a
|
||||
>`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -584,12 +664,6 @@ class DialogAddAutomationElement
|
||||
const automationElementType = this._params!.type;
|
||||
|
||||
const tabButtons = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.tabs.target"
|
||||
),
|
||||
value: "targets",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.tabs.type"
|
||||
@@ -598,6 +672,15 @@ class DialogAddAutomationElement
|
||||
},
|
||||
];
|
||||
|
||||
if (this._newTriggersAndConditions) {
|
||||
tabButtons.unshift({
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.tabs.target"
|
||||
),
|
||||
value: "targets",
|
||||
});
|
||||
}
|
||||
|
||||
if (this._params?.type !== "trigger") {
|
||||
tabButtons.push({
|
||||
label: this.hass.localize("ui.panel.config.automation.editor.blocks"),
|
||||
@@ -680,6 +763,7 @@ class DialogAddAutomationElement
|
||||
this._manifests
|
||||
)}
|
||||
.convertToItem=${this._convertToItem}
|
||||
.newTriggersAndConditions=${this._newTriggersAndConditions}
|
||||
@search-element-picked=${this._searchItemSelected}
|
||||
>
|
||||
</ha-automation-add-search>`
|
||||
@@ -688,28 +772,13 @@ class DialogAddAutomationElement
|
||||
.hass=${this.hass}
|
||||
.value=${this._selectedTarget}
|
||||
@value-changed=${this._handleTargetSelected}
|
||||
@time-location-group-selected=${this
|
||||
._handleTimeLocationGroupSelected}
|
||||
.narrow=${this._narrow}
|
||||
.timeLocationLabel=${this._getTimeLocationLabel(
|
||||
automationElementType
|
||||
)}
|
||||
.timeLocationGroups=${this._getTimeLocationGroups(
|
||||
automationElementType,
|
||||
this.hass.localize,
|
||||
automationElementType === "condition"
|
||||
? this._conditionDescriptions
|
||||
: this._triggerDescriptions
|
||||
)}
|
||||
.selectedGroup=${this._selectedGroup}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
hidden:
|
||||
!!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
) ||
|
||||
(this._narrow && !!this._selectedGroup),
|
||||
hidden: !!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
),
|
||||
})}
|
||||
.manifests=${this._manifests}
|
||||
></ha-automation-add-from-target>`
|
||||
@@ -815,13 +884,13 @@ class DialogAddAutomationElement
|
||||
)
|
||||
: undefined}
|
||||
.selectLabel=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
|
||||
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
|
||||
)}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
|
||||
)}
|
||||
.tooltipDescription=${this._tab === "targets" &&
|
||||
!this._selectedGroup}
|
||||
.emptyNote=${this._getEmptyNote(automationElementType)}
|
||||
.tooltipDescription=${this._tab === "targets"}
|
||||
.target=${(this._tab === "targets" &&
|
||||
this._selectedTarget &&
|
||||
([
|
||||
@@ -981,7 +1050,7 @@ class DialogAddAutomationElement
|
||||
items: this._getBlockItems(this._params!.type, this.hass.localize),
|
||||
},
|
||||
]
|
||||
: !this._filter && this._selectedGroup
|
||||
: !this._filter && this._tab === "groups" && this._selectedGroup
|
||||
? [
|
||||
{
|
||||
title: this.hass.localize(
|
||||
@@ -1039,10 +1108,7 @@ class DialogAddAutomationElement
|
||||
Object.entries(grp).map(([key, options]) =>
|
||||
options.members
|
||||
? flattenGroups(options.members)
|
||||
: options.domains
|
||||
? // domain elements are appended below from the backend descriptions
|
||||
[]
|
||||
: this._convertToItem(key, options, type, localize)
|
||||
: this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
|
||||
const items = flattenGroups(groups).flat();
|
||||
@@ -1083,8 +1149,6 @@ class DialogAddAutomationElement
|
||||
let genericCollectionIndex = -1;
|
||||
let dynamicCollectionIndex = -1;
|
||||
|
||||
const exclusiveDomains = this._getExclusiveDomains(type);
|
||||
|
||||
collections.forEach((collection, index) => {
|
||||
let collectionGroups = Object.entries(collection.groups);
|
||||
const groups: AddAutomationElementListItem[] = [];
|
||||
@@ -1115,8 +1179,7 @@ class DialogAddAutomationElement
|
||||
triggerDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
types
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1135,8 +1198,7 @@ class DialogAddAutomationElement
|
||||
conditionDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
types
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1169,19 +1231,9 @@ class DialogAddAutomationElement
|
||||
}
|
||||
|
||||
groups.push(
|
||||
...collectionGroups
|
||||
.filter(([, options]) =>
|
||||
this._groupHasItems(
|
||||
type,
|
||||
options,
|
||||
type === "condition"
|
||||
? conditionDescriptions
|
||||
: triggerDescriptions
|
||||
)
|
||||
)
|
||||
.map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
)
|
||||
...collectionGroups.map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
)
|
||||
);
|
||||
|
||||
if (groups.length) {
|
||||
@@ -1278,28 +1330,11 @@ class DialogAddAutomationElement
|
||||
return this._services(localize, services, manifests, group);
|
||||
}
|
||||
|
||||
const groupDef =
|
||||
TYPES[type].collections[collectionIndex]?.groups[group] ??
|
||||
TYPES[type].collections.find((collection) => group in collection.groups)
|
||||
?.groups[group];
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
|
||||
let result: AddAutomationElementListItem[];
|
||||
|
||||
if (groupDef?.domains && !groupDef.members) {
|
||||
// Curated group whose items come solely from backend domains (e.g. Sun).
|
||||
result = this._getDomainElementItems(type, groupDef.domains, localize);
|
||||
} else {
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
result = Object.entries(groups).map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
if (groupDef?.domains) {
|
||||
// Curated group with both static members and backend domains (Time).
|
||||
result.push(
|
||||
...this._getDomainElementItems(type, groupDef.domains, localize)
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = Object.entries(groups).map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
|
||||
if (type === "action") {
|
||||
if (!this._selectedGroup) {
|
||||
@@ -1419,8 +1454,7 @@ class DialogAddAutomationElement
|
||||
triggers: TriggerDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
types: CollectionGroupType[]
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!triggers || !manifests) {
|
||||
return [];
|
||||
@@ -1430,7 +1464,7 @@ class DialogAddAutomationElement
|
||||
Object.keys(triggers).forEach((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
@@ -1492,8 +1526,7 @@ class DialogAddAutomationElement
|
||||
conditions: ConditionDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
types: CollectionGroupType[]
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!conditions || !manifests) {
|
||||
return [];
|
||||
@@ -1503,7 +1536,7 @@ class DialogAddAutomationElement
|
||||
Object.keys(conditions).forEach((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
@@ -1763,93 +1796,22 @@ class DialogAddAutomationElement
|
||||
options,
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
localize: LocalizeFunc
|
||||
): AddAutomationElementListItem => {
|
||||
// A group either lists explicit members or bundles backend element domains.
|
||||
const isGroup = !!(options.members || options.domains);
|
||||
return {
|
||||
key,
|
||||
name: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
isGroup ? "groups" : "type"
|
||||
}.${key}.label`
|
||||
),
|
||||
description: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
isGroup ? "groups" : "type"
|
||||
}.${key}.description${isGroup ? "" : ".picker"}`
|
||||
),
|
||||
iconPath: options.icon || TYPES[type].icons[key],
|
||||
};
|
||||
};
|
||||
|
||||
// Domains owned exclusively by a curated group, i.e. a group that bundles
|
||||
// only domains and no static members (e.g. "sun" under the Sun group). Those
|
||||
// are hidden from the generic dynamic domain grouping so they don't appear
|
||||
// both standalone and inside the curated group. Domains of a mixed group
|
||||
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
|
||||
// hidden — they still surface as their own domain group as well.
|
||||
private _getExclusiveDomains = memoizeOne(
|
||||
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
|
||||
const domains = new Set<string>();
|
||||
TYPES[type].collections.forEach((collection) =>
|
||||
Object.values(collection.groups).forEach((group) => {
|
||||
if (group.domains && !group.members) {
|
||||
group.domains.forEach((domain) => domains.add(domain));
|
||||
}
|
||||
})
|
||||
);
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDomainElementItems(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
domains: string[],
|
||||
localize: LocalizeFunc
|
||||
): AddAutomationElementListItem[] {
|
||||
const domainSet = new Set(domains);
|
||||
if (type === "trigger") {
|
||||
return Object.keys(this._triggerDescriptions)
|
||||
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
|
||||
.map((trigger) =>
|
||||
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
|
||||
);
|
||||
}
|
||||
if (type === "condition") {
|
||||
return Object.keys(this._conditionDescriptions)
|
||||
.filter((condition) => domainSet.has(getConditionDomain(condition)))
|
||||
.map((condition) =>
|
||||
this._getConditionListItem(
|
||||
localize,
|
||||
getConditionDomain(condition),
|
||||
condition
|
||||
)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private _groupHasItems(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
options: { members?: object; domains?: string[] },
|
||||
descriptions: TriggerDescriptions | ConditionDescriptions
|
||||
): boolean {
|
||||
if (options.members && Object.keys(options.members).length) {
|
||||
return true;
|
||||
}
|
||||
if (options.domains) {
|
||||
const domainSet = new Set(options.domains);
|
||||
const getDomain =
|
||||
type === "condition" ? getConditionDomain : getTriggerDomain;
|
||||
return Object.keys(descriptions).some((key) =>
|
||||
domainSet.has(getDomain(key))
|
||||
);
|
||||
}
|
||||
// plain single-element group
|
||||
return true;
|
||||
}
|
||||
): AddAutomationElementListItem => ({
|
||||
key,
|
||||
name: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
options.members ? "groups" : "type"
|
||||
}.${key}.label`
|
||||
),
|
||||
description: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
options.members ? "groups" : "type"
|
||||
}.${key}.description${options.members ? "" : ".picker"}`
|
||||
),
|
||||
iconPath: options.icon || TYPES[type].icons[key],
|
||||
});
|
||||
|
||||
private _getDomainGroupedListItems(
|
||||
localize: LocalizeFunc,
|
||||
@@ -2093,8 +2055,6 @@ class DialogAddAutomationElement
|
||||
) => {
|
||||
this._targetItems = undefined;
|
||||
this._loadItemsError = false;
|
||||
this._selectedGroup = undefined;
|
||||
this._selectedCollectionIndex = undefined;
|
||||
this._selectedTarget = ev.detail.value;
|
||||
mainWindow.history.pushState(
|
||||
{
|
||||
@@ -2116,67 +2076,6 @@ class DialogAddAutomationElement
|
||||
this._getItemsByTarget();
|
||||
};
|
||||
|
||||
// Time & location groups have no target; picking one drills into its items
|
||||
// (the same list as the matching group in the "by type" tab).
|
||||
private _handleTimeLocationGroupSelected = (
|
||||
ev: ValueChangedEvent<string>
|
||||
) => {
|
||||
this._targetItems = undefined;
|
||||
this._loadItemsError = false;
|
||||
this._selectedTarget = undefined;
|
||||
this._selectedGroup = ev.detail.value;
|
||||
this._selectedCollectionIndex = 0;
|
||||
mainWindow.history.pushState(
|
||||
{
|
||||
dialogData: {
|
||||
group: this._selectedGroup,
|
||||
collectionIndex: this._selectedCollectionIndex,
|
||||
},
|
||||
},
|
||||
""
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (this._narrow) {
|
||||
this._contentElement?.scrollTo(0, 0);
|
||||
} else {
|
||||
this._itemsListElement?.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _getTimeLocationLabel(
|
||||
type: AddAutomationElementDialogParams["type"]
|
||||
): string | undefined {
|
||||
if (type !== "trigger" && type !== "condition") {
|
||||
return undefined;
|
||||
}
|
||||
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
|
||||
}
|
||||
|
||||
private _getTimeLocationGroups = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
localize: LocalizeFunc,
|
||||
descriptions: TriggerDescriptions | ConditionDescriptions
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (type !== "trigger" && type !== "condition") {
|
||||
return [];
|
||||
}
|
||||
return TIME_LOCATION_GROUPS.map(
|
||||
(group) => [group, TYPES[type].collections[0].groups[group]] as const
|
||||
)
|
||||
.filter(
|
||||
([, options]) =>
|
||||
options && this._groupHasItems(type, options, descriptions)
|
||||
)
|
||||
.map(([group, options]) =>
|
||||
this._convertToItem(group, options, type, localize)
|
||||
)
|
||||
.filter((item) => item.name);
|
||||
}
|
||||
);
|
||||
|
||||
private _getDefaultStateItems(
|
||||
type: "trigger" | "condition"
|
||||
): AddAutomationElementListItem[] {
|
||||
|
||||
+7
-84
@@ -63,7 +63,6 @@ import {
|
||||
} from "../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
|
||||
|
||||
interface Level1Entries {
|
||||
open: boolean;
|
||||
@@ -94,16 +93,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public manifests?: DomainManifestLookup;
|
||||
|
||||
// Section title + group rows (Time, Location) for the targetless element
|
||||
// groups. Picking a row drills into that group's items, just like selecting
|
||||
// the matching group in the "by type" tab.
|
||||
@property({ attribute: false }) public timeLocationLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public timeLocationGroups?: AddAutomationElementListItem[];
|
||||
|
||||
@property({ attribute: false }) public selectedGroup?: string;
|
||||
|
||||
// #endregion properties
|
||||
|
||||
// #region context
|
||||
@@ -193,20 +182,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
? this._renderNarrow(this._entries, this.value)
|
||||
: html`
|
||||
${this._renderFloors(this.narrow, this._entries, this.value)}
|
||||
${this._renderTimeLocation(
|
||||
this.narrow,
|
||||
this.timeLocationLabel,
|
||||
this.timeLocationGroups,
|
||||
this.selectedGroup
|
||||
)}
|
||||
${this._renderUnassigned(this.narrow, this._entries, this.value)}
|
||||
${this._renderLabels(
|
||||
this.narrow,
|
||||
this.states,
|
||||
this._registries,
|
||||
this._labelRegistry,
|
||||
this.value
|
||||
)}
|
||||
${this._renderLabels(this.narrow, this.value)}
|
||||
`}
|
||||
${this.narrow && this._showShowMoreButton && !this._fullHeight
|
||||
? html`
|
||||
@@ -366,58 +343,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _renderTimeLocation = memoizeOne(
|
||||
(
|
||||
narrow: boolean,
|
||||
label?: string,
|
||||
groups?: AddAutomationElementListItem[],
|
||||
selectedGroup?: string
|
||||
) => {
|
||||
if (!label || !groups?.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-section-title>${label}</ha-section-title>
|
||||
<ha-list-base>
|
||||
${groups.map(
|
||||
(group) =>
|
||||
html`<ha-list-item-button
|
||||
.value=${group.key}
|
||||
@click=${this._selectTimeLocationGroup}
|
||||
class=${group.key === selectedGroup ? "selected" : ""}
|
||||
>
|
||||
${group.icon
|
||||
? html`<span slot="start">${group.icon}</span>`
|
||||
: group.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${group.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<div slot="headline">${group.name}</div>
|
||||
${narrow
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: nothing}
|
||||
</ha-list-item-button>`
|
||||
)}
|
||||
</ha-list-base>`;
|
||||
}
|
||||
);
|
||||
|
||||
private _renderLabels = memoizeOne(
|
||||
(
|
||||
narrow: boolean,
|
||||
states: ContextType<typeof statesContext>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
labelRegistry: LabelRegistryEntry[],
|
||||
value?: SingleHassServiceTarget
|
||||
) => {
|
||||
(narrow: boolean, value?: SingleHassServiceTarget) => {
|
||||
const labels = this._getLabelsMemoized(
|
||||
states,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
labelRegistry,
|
||||
this.states,
|
||||
this._registries.areas,
|
||||
this._registries.devices,
|
||||
this._registries.entities,
|
||||
this._labelRegistry,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1240,13 +1173,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _selectTimeLocationGroup(ev: CustomEvent) {
|
||||
const value = (ev.currentTarget as any).value;
|
||||
if (value) {
|
||||
fireEvent(this, "time-location-group-selected", { value });
|
||||
}
|
||||
}
|
||||
|
||||
private async _valueChanged(itemId: string, expand = false) {
|
||||
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
|
||||
|
||||
@@ -1586,7 +1512,4 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-add-from-target": HaAutomationAddFromTarget;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"time-location-group-selected": { value: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
@@ -40,6 +40,8 @@ export class HaAutomationAddItems extends LitElement {
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel!: string;
|
||||
|
||||
@property({ attribute: false }) public emptyNote?: string | TemplateResult;
|
||||
|
||||
@property({ attribute: false }) public target?: Target;
|
||||
|
||||
@property({ attribute: false }) public getLabel!: (
|
||||
@@ -81,6 +83,9 @@ export class HaAutomationAddItems extends LitElement {
|
||||
? html`${this.emptyLabel}
|
||||
${this.target
|
||||
? html`<div>${this._renderTarget(this.target)}</div>`
|
||||
: nothing}
|
||||
${this.emptyNote
|
||||
? html`<div class="empty-note">${this.emptyNote}</div>`
|
||||
: nothing}`
|
||||
: repeat(
|
||||
this.items,
|
||||
@@ -227,6 +232,17 @@ export class HaAutomationAddItems extends LitElement {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
color: var(--ha-color-text-secondary);
|
||||
margin-top: var(--ha-space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-note a {
|
||||
color: currentColor;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.items.error {
|
||||
background-color: var(--ha-color-fill-danger-quiet-resting);
|
||||
color: var(--ha-color-on-danger-normal);
|
||||
|
||||
@@ -117,6 +117,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "new-triggers-and-conditions" })
|
||||
public newTriggersAndConditions = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public convertToItem!: (
|
||||
key: string,
|
||||
@@ -206,6 +209,7 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
this.filter,
|
||||
this.configEntryLookup,
|
||||
this.items,
|
||||
this.newTriggersAndConditions,
|
||||
this._selectedSearchSection,
|
||||
this._relatedIdSets
|
||||
);
|
||||
@@ -256,13 +260,19 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
}
|
||||
|
||||
private _renderSections() {
|
||||
if (this.addElementType === "trigger" && !this.newTriggersAndConditions) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const searchSections: ("separator" | SearchSection)[] = ["item"];
|
||||
|
||||
if (this.addElementType !== "trigger") {
|
||||
searchSections.push("block");
|
||||
}
|
||||
|
||||
searchSections.push(...TARGET_SEARCH_SECTIONS);
|
||||
if (this.newTriggersAndConditions) {
|
||||
searchSections.push(...TARGET_SEARCH_SECTIONS);
|
||||
}
|
||||
return html`
|
||||
<ha-chip-set class="sections">
|
||||
${searchSections.map((section) =>
|
||||
@@ -492,6 +502,7 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
searchTerm: string,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
automationItems: AddAutomationElementListItem[],
|
||||
newTriggersAndConditions: boolean,
|
||||
selectedSection?: SearchSection,
|
||||
relatedIdSets?: RelatedIdSets
|
||||
) => {
|
||||
@@ -559,185 +570,191 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
resultItems.push(...blocks);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
`entity${TARGET_SEPARATOR}`
|
||||
);
|
||||
if (newTriggersAndConditions) {
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
`entity${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
if (relatedIdSets?.entities.size) {
|
||||
entityItems = entityItems.map((item) => ({
|
||||
...item,
|
||||
isRelated: relatedIdSets.entities.has(
|
||||
(item as EntityComboBoxItem).stateObj?.entity_id || ""
|
||||
if (relatedIdSets?.entities.size) {
|
||||
entityItems = entityItems.map((item) => ({
|
||||
...item,
|
||||
isRelated: relatedIdSets.entities.has(
|
||||
(item as EntityComboBoxItem).stateObj?.entity_id || ""
|
||||
),
|
||||
})) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
entityItems = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"entity",
|
||||
entityItems,
|
||||
searchTerm,
|
||||
entityComboBoxKeys
|
||||
)
|
||||
) as EntityComboBoxItem[];
|
||||
} else if (relatedIdSets?.entities.size) {
|
||||
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && entityItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.entities")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...entityItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
`device${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
if (relatedIdSets?.devices.size) {
|
||||
deviceItems = deviceItems.map((item) => ({
|
||||
...item,
|
||||
isRelated: relatedIdSets.devices.has(
|
||||
item.id.split(TARGET_SEPARATOR)[1] || ""
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
deviceItems = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys
|
||||
)
|
||||
);
|
||||
} else if (relatedIdSets?.devices.size) {
|
||||
deviceItems = sortRelatedFirst(deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection && deviceItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "area") {
|
||||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
memoizeOne((value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(TARGET_SEPARATOR)
|
||||
),
|
||||
})) as EntityComboBoxItem[];
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
entityItems = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"entity",
|
||||
entityItems,
|
||||
searchTerm,
|
||||
entityComboBoxKeys
|
||||
)
|
||||
) as EntityComboBoxItem[];
|
||||
} else if (relatedIdSets?.entities.size) {
|
||||
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
|
||||
}
|
||||
if (relatedIdSets?.areas.size) {
|
||||
areasAndFloors = areasAndFloors.map((item) => ({
|
||||
...item,
|
||||
isRelated:
|
||||
item.type === "area"
|
||||
? relatedIdSets.areas.has(
|
||||
item.id.split(TARGET_SEPARATOR)[1] || ""
|
||||
)
|
||||
: false,
|
||||
})) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
areasAndFloors = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"area",
|
||||
areasAndFloors,
|
||||
searchTerm,
|
||||
areaFloorComboBoxKeys,
|
||||
false
|
||||
)
|
||||
) as FloorComboBoxItem[];
|
||||
} else if (relatedIdSets?.areas.size) {
|
||||
areasAndFloors = sortRelatedFirst(
|
||||
areasAndFloors
|
||||
) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && areasAndFloors.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.areas")
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection && entityItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.entities")
|
||||
...areasAndFloors.map((item, index) => {
|
||||
const nextItem = areasAndFloors[index + 1];
|
||||
|
||||
if (
|
||||
!nextItem ||
|
||||
(item.type === "area" && nextItem.type === "floor")
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
last: true,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...entityItems);
|
||||
}
|
||||
if (!selectedSection || selectedSection === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labelRegistry,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`label${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
`device${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
if (relatedIdSets?.devices.size) {
|
||||
deviceItems = deviceItems.map((item) => ({
|
||||
...item,
|
||||
isRelated: relatedIdSets.devices.has(
|
||||
item.id.split(TARGET_SEPARATOR)[1] || ""
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
deviceItems = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
if (searchTerm) {
|
||||
labels = this._filterGroup(
|
||||
"label",
|
||||
labels,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys
|
||||
)
|
||||
);
|
||||
} else if (relatedIdSets?.devices.size) {
|
||||
deviceItems = sortRelatedFirst(deviceItems);
|
||||
labelComboBoxKeys
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection && labels.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.labels")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...labels);
|
||||
}
|
||||
|
||||
if (!selectedSection && deviceItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
}
|
||||
|
||||
resultItems.push(...deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "area") {
|
||||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
memoizeOne((value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(TARGET_SEPARATOR)
|
||||
),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (relatedIdSets?.areas.size) {
|
||||
areasAndFloors = areasAndFloors.map((item) => ({
|
||||
...item,
|
||||
isRelated:
|
||||
item.type === "area"
|
||||
? relatedIdSets.areas.has(
|
||||
item.id.split(TARGET_SEPARATOR)[1] || ""
|
||||
)
|
||||
: false,
|
||||
})) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
areasAndFloors = sortRelatedFirst(
|
||||
this._filterGroup(
|
||||
"area",
|
||||
areasAndFloors,
|
||||
searchTerm,
|
||||
areaFloorComboBoxKeys,
|
||||
false
|
||||
)
|
||||
) as FloorComboBoxItem[];
|
||||
} else if (relatedIdSets?.areas.size) {
|
||||
areasAndFloors = sortRelatedFirst(
|
||||
areasAndFloors
|
||||
) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && areasAndFloors.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.areas"));
|
||||
}
|
||||
|
||||
resultItems.push(
|
||||
...areasAndFloors.map((item, index) => {
|
||||
const nextItem = areasAndFloors[index + 1];
|
||||
|
||||
if (
|
||||
!nextItem ||
|
||||
(item.type === "area" && nextItem.type === "floor")
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
last: true,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labelRegistry,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`label${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
labels = this._filterGroup(
|
||||
"label",
|
||||
labels,
|
||||
searchTerm,
|
||||
labelComboBoxKeys
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection && labels.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.labels"));
|
||||
}
|
||||
|
||||
resultItems.push(...labels);
|
||||
}
|
||||
|
||||
return resultItems;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassServiceTarget,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
@@ -17,8 +19,12 @@ import {
|
||||
type Condition,
|
||||
} from "../../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { conditionDescriptionsContext } from "../../../../data/context";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
subscribeConditions,
|
||||
} from "../../../../data/condition";
|
||||
import { subscribeLabFeature } from "../../../../data/labs";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
|
||||
import {
|
||||
getAddAutomationElementTargetFromQuery,
|
||||
@@ -32,7 +38,7 @@ import type HaAutomationConditionRow from "./ha-automation-condition-row";
|
||||
|
||||
@customElement("ha-automation-condition")
|
||||
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
|
||||
LitElement
|
||||
SubscribeMixin(LitElement)
|
||||
) {
|
||||
@property({ attribute: false }) public conditions!: Condition[];
|
||||
|
||||
@@ -42,13 +48,16 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public editorDirty = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions: ConditionDescriptions = {};
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@queryAll("ha-automation-condition-row")
|
||||
private _conditionRowElements?: HaAutomationConditionRow[];
|
||||
|
||||
// @ts-ignore
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _openedAddDialogFromQuery = false;
|
||||
|
||||
protected get items(): Condition[] {
|
||||
@@ -63,6 +72,49 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
this.highlightedConditions = items;
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeLabFeature(
|
||||
this.hass!.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersAndConditions = feature.enabled;
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _subscribeDescriptions() {
|
||||
this._unsubscribe();
|
||||
this._conditionDescriptions = {};
|
||||
this._unsub = subscribeConditions(this.hass, (descriptions) => {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...descriptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_newTriggersAndConditions")) {
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-tooltip";
|
||||
import type {
|
||||
ForDict,
|
||||
PlatformCondition,
|
||||
} from "../../../../../data/automation";
|
||||
import type { PlatformCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
@@ -23,21 +15,11 @@ import {
|
||||
} from "../../../../../data/condition";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import { getRecorderEntityOptions } from "../../../../../data/recorder";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import {
|
||||
extractFromTarget,
|
||||
getTargetEntityCount,
|
||||
} from "../../../../../data/target";
|
||||
import { getTargetEntityCount } from "../../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
|
||||
// when a condition has a `for:` duration, the recorder is only queried this far
|
||||
// back to prime it at setup, so longer durations can't be fully satisfied from
|
||||
// history after a restart or reload.
|
||||
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
|
||||
|
||||
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
|
||||
field.selector &&
|
||||
!field.required &&
|
||||
@@ -59,11 +41,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
|
||||
@state() private _resolvedTargetEntityCount?: number;
|
||||
|
||||
@state() private _targetHasUnrecordedEntity = false;
|
||||
|
||||
// Incremented on each recording check so stale async responses are ignored.
|
||||
private _recordingCheckId = 0;
|
||||
|
||||
public static get defaultConfig(): PlatformCondition {
|
||||
return { condition: "" };
|
||||
}
|
||||
@@ -74,26 +51,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
|
||||
// The `for:` priming info depends on both the condition (target + duration)
|
||||
// and the description (whether the condition targets entities at all), which
|
||||
// can arrive in separate updates.
|
||||
if (
|
||||
changedProperties.has("condition") ||
|
||||
changedProperties.has("description")
|
||||
) {
|
||||
const previousCondition = changedProperties.get("condition") as
|
||||
| undefined
|
||||
| this["condition"];
|
||||
if (
|
||||
changedProperties.has("description") ||
|
||||
previousCondition?.target !== this.condition?.target ||
|
||||
previousCondition?.options?.for !== this.condition?.options?.for
|
||||
) {
|
||||
this._updateDurationPrimingInfo();
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedProperties.has("condition")) {
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +263,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
|
||||
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
|
||||
) || fieldName}</span
|
||||
>
|
||||
${description
|
||||
? html`<span
|
||||
@@ -515,118 +472,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Shows a small info icon beside the `for` duration field's label, with a
|
||||
// tooltip explaining when history priming can't fully cover the duration.
|
||||
private _renderForPrimingInfo(fieldName: string) {
|
||||
if (fieldName !== "for") {
|
||||
return nothing;
|
||||
}
|
||||
const text = this._durationPrimingInfoText();
|
||||
if (!text) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
id="for-priming-info"
|
||||
tabindex="0"
|
||||
class="priming-info-icon"
|
||||
.path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
|
||||
}
|
||||
|
||||
private _durationPrimingInfoText(): string | undefined {
|
||||
const forValue = this.condition.options?.for;
|
||||
|
||||
// Priming only happens for entity conditions that have a `for:` duration.
|
||||
if (
|
||||
forValue === undefined ||
|
||||
forValue === "" ||
|
||||
!this.description?.target
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this._targetHasUnrecordedEntity) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
|
||||
);
|
||||
}
|
||||
|
||||
if (this._durationExceedsLookback(forValue)) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
|
||||
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _durationExceedsLookback(forValue: unknown): boolean {
|
||||
const duration = createDurationData(
|
||||
forValue as string | number | ForDict | undefined
|
||||
);
|
||||
if (!duration) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
durationDataToSeconds(duration) >
|
||||
MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600
|
||||
);
|
||||
}
|
||||
|
||||
private async _updateDurationPrimingInfo(): Promise<void> {
|
||||
const forValue = this.condition.options?.for;
|
||||
const target = this.condition.target;
|
||||
|
||||
// Recording status only matters for an entity condition that has both a
|
||||
// target and a `for:` duration.
|
||||
const checkId = ++this._recordingCheckId;
|
||||
if (
|
||||
forValue === undefined ||
|
||||
forValue === "" ||
|
||||
!this.description?.target ||
|
||||
!target ||
|
||||
!this.hass.config.components.includes("recorder")
|
||||
) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { referenced_entities } = await extractFromTarget(
|
||||
this.hass.callWS,
|
||||
target
|
||||
);
|
||||
// Ignore if a newer check superseded this one.
|
||||
if (checkId !== this._recordingCheckId) {
|
||||
return;
|
||||
}
|
||||
if (!referenced_entities.length) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
return;
|
||||
}
|
||||
const recordingDisabled = await Promise.all(
|
||||
referenced_entities.map((entityId) =>
|
||||
getRecorderEntityOptions(this.hass, entityId)
|
||||
.then((options) => options.recording_disabled_by !== null)
|
||||
// Unknown entity or command unavailable on older cores: don't warn.
|
||||
.catch(() => false)
|
||||
)
|
||||
);
|
||||
if (checkId !== this._recordingCheckId) {
|
||||
return;
|
||||
}
|
||||
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
|
||||
} catch (_err) {
|
||||
// Target resolution failed; fall back to no warning rather than guessing.
|
||||
if (checkId === this._recordingCheckId) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -682,15 +527,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.priming-info-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--warning-color);
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export class HaZoneCondition extends LitElement {
|
||||
)}
|
||||
.value=${entity_id}
|
||||
@value-changed=${this._entityPicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.entityFilter=${zoneAndLocationFilter}
|
||||
></ha-entity-picker>
|
||||
@@ -47,6 +48,7 @@ export class HaZoneCondition extends LitElement {
|
||||
)}
|
||||
.value=${zone}
|
||||
@value-changed=${this._zonePicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.includeDomains=${includeDomains}
|
||||
></ha-entity-picker>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassServiceTarget,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -17,9 +19,10 @@ import {
|
||||
type Trigger,
|
||||
type TriggerList,
|
||||
} from "../../../../data/automation";
|
||||
import { triggerDescriptionsContext } from "../../../../data/context";
|
||||
import { subscribeLabFeature } from "../../../../data/labs";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
|
||||
import {
|
||||
getAddAutomationElementTargetFromQuery,
|
||||
@@ -33,7 +36,7 @@ import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
|
||||
|
||||
@customElement("ha-automation-trigger")
|
||||
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
|
||||
LitElement
|
||||
SubscribeMixin(LitElement)
|
||||
) {
|
||||
@property({ attribute: false }) public triggers!: Trigger[];
|
||||
|
||||
@@ -43,9 +46,12 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public editorDirty = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: triggerDescriptionsContext, subscribe: true })
|
||||
private _triggerDescriptions: TriggerDescriptions = {};
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
// @ts-ignore
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _openedAddDialogFromQuery = false;
|
||||
|
||||
@@ -61,6 +67,49 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
this.highlightedTriggers = items;
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeLabFeature(
|
||||
this.hass!.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersAndConditions = feature.enabled;
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _subscribeDescriptions() {
|
||||
this._unsubscribe();
|
||||
this._triggerDescriptions = {};
|
||||
this._unsub = subscribeTriggers(this.hass, (descriptions) => {
|
||||
this._triggerDescriptions = {
|
||||
...this._triggerDescriptions,
|
||||
...descriptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_newTriggersAndConditions")) {
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
|
||||
@@ -45,6 +45,7 @@ export class HaZoneTrigger extends LitElement {
|
||||
.value=${entity_id ? ensureArray(entity_id) : []}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._entityPicked}
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${zoneAndLocationFilter}
|
||||
></ha-entities-picker>
|
||||
<ha-entity-picker
|
||||
@@ -54,6 +55,7 @@ export class HaZoneTrigger extends LitElement {
|
||||
.value=${zone}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._zonePicked}
|
||||
.hass=${this.hass}
|
||||
.includeDomains=${includeDomains}
|
||||
></ha-entity-picker>
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export class AITaskPref extends LitElement {
|
||||
</span>
|
||||
<ha-entity-picker
|
||||
data-name="gen_data_entity_id"
|
||||
.hass=${this.hass}
|
||||
.disabled=${this._prefs === undefined &&
|
||||
isComponentLoaded(this.hass.config, "ai_task")}
|
||||
.value=${this._gen_data_entity_id ||
|
||||
@@ -118,6 +119,7 @@ export class AITaskPref extends LitElement {
|
||||
</span>
|
||||
<ha-entity-picker
|
||||
data-name="gen_image_entity_id"
|
||||
.hass=${this.hass}
|
||||
.disabled=${this._prefs === undefined &&
|
||||
isComponentLoaded(this.hass.config, "ai_task")}
|
||||
.value=${this._gen_image_entity_id ||
|
||||
|
||||
@@ -50,6 +50,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.description"
|
||||
)}
|
||||
|
||||
@@ -220,6 +220,7 @@ class HaPanelDevState extends LitElement {
|
||||
<div class="inputs">
|
||||
<ha-entity-picker
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.value=${this._entityId}
|
||||
@value-changed=${this._entityIdChanged}
|
||||
show-entity-id
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
@@ -12,10 +12,27 @@ import {
|
||||
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../components/ha-spinner";
|
||||
import type { AutomationConfig } from "../../../../data/automation";
|
||||
import { showAutomationEditor } from "../../../../data/automation";
|
||||
import {
|
||||
apiContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import type {
|
||||
DeviceAction,
|
||||
DeviceCondition,
|
||||
DeviceTrigger,
|
||||
} from "../../../../data/device/device_automation";
|
||||
import {
|
||||
fetchDeviceActions,
|
||||
fetchDeviceConditions,
|
||||
fetchDeviceTriggers,
|
||||
sortDeviceAutomations,
|
||||
} from "../../../../data/device/device_automation";
|
||||
import type { ScriptConfig } from "../../../../data/script";
|
||||
import { showScriptEditor } from "../../../../data/script";
|
||||
import { showSceneEditor } from "../../../../data/scene";
|
||||
import "../../../../dialogs/add-to/ha-add-to-action-list";
|
||||
import type {
|
||||
@@ -31,11 +48,21 @@ import {
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { DeviceAddToDialogParams } from "./show-dialog-device-add-to";
|
||||
|
||||
type DeviceLegacyAddToActionType =
|
||||
| "trigger"
|
||||
| "condition"
|
||||
| "automation_action"
|
||||
| "script_action";
|
||||
|
||||
type DeviceAddToAction =
|
||||
| (AddToActionListItem & {
|
||||
kind: "add-to";
|
||||
key: AddToAutomationScriptActionKey;
|
||||
})
|
||||
| (AddToActionListItem & {
|
||||
kind: "legacy";
|
||||
legacyType: DeviceLegacyAddToActionType;
|
||||
})
|
||||
| (AddToActionListItem & { kind: "scene" });
|
||||
|
||||
@customElement("dialog-device-add-to")
|
||||
@@ -48,25 +75,76 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state() private _params?: DeviceAddToDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _triggers?: DeviceTrigger[];
|
||||
|
||||
@state() private _conditions?: DeviceCondition[];
|
||||
|
||||
@state() private _actions?: DeviceAction[];
|
||||
|
||||
public showDialog(params: DeviceAddToDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
|
||||
if (!params.newTriggersConditions && this._api) {
|
||||
this._fetchDeviceAutomations(params);
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
|
||||
if (
|
||||
changedProps.has("_api") &&
|
||||
this._api &&
|
||||
this._params &&
|
||||
!this._params.newTriggersConditions &&
|
||||
!this._triggers
|
||||
) {
|
||||
this._fetchDeviceAutomations(this._params);
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._i18n.loadBackendTranslation("device_automation");
|
||||
}
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
|
||||
private async _fetchDeviceAutomations(
|
||||
params: DeviceAddToDialogParams
|
||||
): Promise<void> {
|
||||
const deviceId = params.device.id;
|
||||
|
||||
const [triggers, conditions, actions] = await Promise.all([
|
||||
fetchDeviceTriggers(this._api.callWS, deviceId),
|
||||
fetchDeviceConditions(this._api.callWS, deviceId),
|
||||
fetchDeviceActions(this._api.callWS, deviceId),
|
||||
]);
|
||||
|
||||
this._triggers = triggers.sort(sortDeviceAutomations);
|
||||
this._conditions = conditions.sort(sortDeviceAutomations);
|
||||
this._actions = actions.sort(sortDeviceAutomations);
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._triggers = undefined;
|
||||
this._conditions = undefined;
|
||||
this._actions = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -90,12 +168,14 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._renderOptions()}
|
||||
${this._params.newTriggersConditions
|
||||
? this._renderNewOptions()
|
||||
: this._renderLegacyOptions()}
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOptions() {
|
||||
private _renderNewOptions() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -158,6 +238,112 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
|
||||
private _renderLegacyOptions() {
|
||||
if (!this._triggers && !this._conditions && !this._actions) {
|
||||
return html`
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasTriggers = Boolean(this._triggers?.length);
|
||||
const hasConditions = Boolean(this._conditions?.length);
|
||||
const hasActions = Boolean(this._actions?.length);
|
||||
const hasScenes = Boolean(this._params.entityIds.length);
|
||||
|
||||
if (!hasTriggers && !hasConditions && !hasActions && !hasScenes) {
|
||||
return html`
|
||||
<div class="empty">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.no_device_automations"
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const automationActions: DeviceAddToAction[] = [];
|
||||
if (hasTriggers) {
|
||||
automationActions.push({
|
||||
kind: "legacy",
|
||||
legacyType: "trigger",
|
||||
iconPath: mdiRobotOutline,
|
||||
name: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (hasConditions) {
|
||||
automationActions.push({
|
||||
kind: "legacy",
|
||||
legacyType: "condition",
|
||||
iconPath: mdiPlaylistCheck,
|
||||
name: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (hasActions) {
|
||||
automationActions.push({
|
||||
kind: "legacy",
|
||||
legacyType: "automation_action",
|
||||
iconPath: mdiPlayCircleOutline,
|
||||
name: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const scriptActions: DeviceAddToAction[] = hasActions
|
||||
? [
|
||||
{
|
||||
kind: "legacy",
|
||||
legacyType: "script_action",
|
||||
iconPath: mdiScriptTextOutline,
|
||||
name: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.add_to.action_options.script_action"
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const sections: AddToActionListSection<DeviceAddToAction>[] = [
|
||||
{
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.automations_heading"
|
||||
),
|
||||
actions: automationActions,
|
||||
empty: automationActions.length
|
||||
? undefined
|
||||
: this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.no_automations"
|
||||
),
|
||||
},
|
||||
{
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.config.devices.script.scripts_heading"
|
||||
),
|
||||
actions: scriptActions,
|
||||
empty: scriptActions.length
|
||||
? undefined
|
||||
: this._i18n.localize("ui.panel.config.devices.script.no_scripts"),
|
||||
},
|
||||
];
|
||||
this._addSceneSection(sections);
|
||||
|
||||
return html`
|
||||
<ha-add-to-action-list
|
||||
.sections=${sections}
|
||||
@add-to-list-action-selected=${this._handleActionSelected}
|
||||
></ha-add-to-action-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private _addSceneSection(
|
||||
sections: AddToActionListSection<DeviceAddToAction>[]
|
||||
): void {
|
||||
@@ -194,7 +380,12 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._handleAddToAction(action.key);
|
||||
if (action.kind === "add-to") {
|
||||
this._handleAddToAction(action.key);
|
||||
return;
|
||||
}
|
||||
|
||||
this._handleLegacyAction(action.legacyType);
|
||||
}
|
||||
|
||||
private _handleAddToAction(key: AddToAutomationScriptActionKey) {
|
||||
@@ -206,6 +397,30 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
addToActionHandler(key, { device_id: this._params.device.id });
|
||||
}
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
|
||||
private _handleLegacyAction(type: DeviceLegacyAddToActionType) {
|
||||
this.closeDialog();
|
||||
|
||||
if (type === "script_action") {
|
||||
const newScript = {} as ScriptConfig;
|
||||
if (this._actions?.length) {
|
||||
newScript.sequence = [this._actions[0]];
|
||||
}
|
||||
showScriptEditor(newScript, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAutomation = {} as AutomationConfig;
|
||||
if (type === "trigger" && this._triggers?.length) {
|
||||
newAutomation.triggers = [this._triggers[0]];
|
||||
} else if (type === "condition" && this._conditions?.length) {
|
||||
newAutomation.conditions = [this._conditions[0]];
|
||||
} else if (type === "automation_action" && this._actions?.length) {
|
||||
newAutomation.actions = [this._actions[0]];
|
||||
}
|
||||
showAutomationEditor(newAutomation, true);
|
||||
}
|
||||
|
||||
private _handleCreateScene() {
|
||||
if (!this._params) {
|
||||
return;
|
||||
@@ -224,6 +439,12 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DeviceRegistryEntry } from "../../../../data/device/device_registr
|
||||
|
||||
export interface DeviceAddToDialogParams {
|
||||
device: DeviceRegistryEntry;
|
||||
newTriggersConditions: boolean;
|
||||
entityIds: string[];
|
||||
canCreateScene: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
removeConfigEntryFromDevice,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import { subscribeLabFeature } from "../../../data/labs";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import {
|
||||
fetchDiagnosticHandler,
|
||||
@@ -203,6 +204,10 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
private _deviceAlertsActionsTimeout?: number;
|
||||
|
||||
@state() private _newTriggersConditions = false;
|
||||
|
||||
private _unsubLabFeature?: (() => void) | undefined;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -372,6 +377,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
loadDeviceRegistryDetailDialog();
|
||||
this._subscribeLabFeature();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
@@ -388,6 +394,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._unsubLabFeature?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -914,7 +921,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
.time=${this._logbookTime}
|
||||
.entityIds=${this._entityIds(entities)}
|
||||
.deviceIds=${this._deviceIdInList(this.deviceId)}
|
||||
name-detail="entity"
|
||||
.scope=${"device"}
|
||||
virtualize
|
||||
narrow
|
||||
no-icon
|
||||
@@ -1397,6 +1404,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
);
|
||||
showDeviceAddToDialog(this, {
|
||||
device,
|
||||
newTriggersConditions: this._newTriggersConditions,
|
||||
entityIds: sceneEntityIds,
|
||||
canCreateScene:
|
||||
isComponentLoaded(this.hass.config, "scene") &&
|
||||
@@ -1404,6 +1412,23 @@ export class HaConfigDevicePage extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
|
||||
private _subscribeLabFeature() {
|
||||
if (!isComponentLoaded(this.hass.config, "automation")) {
|
||||
return;
|
||||
}
|
||||
subscribeLabFeature(
|
||||
this.hass.connection,
|
||||
"automation",
|
||||
"new_triggers_conditions",
|
||||
(feature) => {
|
||||
this._newTriggersConditions = feature.enabled;
|
||||
}
|
||||
).then((unsub) => {
|
||||
this._unsubLabFeature = unsub;
|
||||
});
|
||||
}
|
||||
|
||||
private _renderIntegrationInfo(
|
||||
device: DeviceRegistryEntry,
|
||||
integrations: ConfigEntry[],
|
||||
|
||||
@@ -269,6 +269,7 @@ export class DialogEnergyGasSettings
|
||||
: this._costs === "entity"
|
||||
? html`<ha-entity-picker
|
||||
class="price-options"
|
||||
.hass=${this.hass}
|
||||
include-domains='["sensor", "input_number"]'
|
||||
.value=${this._source.entity_energy_price}
|
||||
.label=${this.hass.localize(
|
||||
|
||||
@@ -328,6 +328,7 @@ export class DialogEnergyGridSettings
|
||||
${this._importCostType === "entity"
|
||||
? html`
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._source.entity_energy_price}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.dialog.cost_entity_label"
|
||||
@@ -415,6 +416,7 @@ export class DialogEnergyGridSettings
|
||||
${this._exportCostType === "entity"
|
||||
? html`
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._source.entity_energy_price_export}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.dialog.compensation_entity_label"
|
||||
|
||||
@@ -230,6 +230,7 @@ export class DialogEnergyWaterSettings
|
||||
: this._costs === "entity"
|
||||
? html`<ha-entity-picker
|
||||
class="price-options"
|
||||
.hass=${this.hass}
|
||||
include-domains='["sensor", "input_number"]'
|
||||
.value=${this._source.entity_energy_price}
|
||||
.label=${this.hass.localize(
|
||||
|
||||
@@ -747,6 +747,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
SCANNER_SOURCE_TYPES.includes(stateObj?.attributes?.source_type)
|
||||
? html`
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._associatedZone}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.associated_zone"
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { animationStyles } from "../../../../../resources/theme/animations.globals";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
@@ -23,7 +21,6 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
@@ -69,46 +66,20 @@ class ZHAConfigDashboard extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (!this.hass) {
|
||||
return;
|
||||
if (this.hass) {
|
||||
this.hass.loadBackendTranslation("config_panel", "zha", false);
|
||||
this._fetchConfigEntry();
|
||||
this._fetchConfiguration();
|
||||
this._fetchDevicesAndGroups();
|
||||
}
|
||||
if (!isComponentLoaded(this.hass.config, "zha")) {
|
||||
navigate("/config/integrations", { replace: true });
|
||||
return;
|
||||
}
|
||||
this.hass.loadBackendTranslation("config_panel", "zha", false);
|
||||
this._load();
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
await this._fetchConfigEntry();
|
||||
if (!this._configEntry) {
|
||||
return;
|
||||
}
|
||||
this._fetchConfiguration();
|
||||
this._fetchDevicesAndGroups();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._configEntry) {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
|
||||
back-path="/config"
|
||||
>
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
const configEntry = this._configEntry;
|
||||
const devices = Object.values(this.hass.devices).filter((device) =>
|
||||
device.config_entries.includes(configEntry.entry_id)
|
||||
);
|
||||
const devices = this._configEntry
|
||||
? Object.values(this.hass.devices).filter((device) =>
|
||||
device.config_entries.includes(this._configEntry!.entry_id)
|
||||
)
|
||||
: [];
|
||||
const deviceCount = devices.length;
|
||||
|
||||
let entityCount = 0;
|
||||
@@ -492,12 +463,6 @@ class ZHAConfigDashboard extends LitElement {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-12);
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -219,6 +219,7 @@ class DialogPersonDetail
|
||||
)}
|
||||
</p>
|
||||
<ha-entities-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._deviceTrackers}
|
||||
.includeDomains=${includeDomains}
|
||||
.pickedEntityLabel=${this.hass.localize(
|
||||
|
||||
@@ -584,6 +584,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
<ha-entity-picker
|
||||
@value-changed=${this._entityPicked}
|
||||
.excludeDomains=${SCENE_IGNORED_DOMAINS}
|
||||
.hass=${this.hass}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.scene.editor.entities.add"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
|
||||
export const DEFAULT_POWER_COLLECTION_KEY = "energy_dashboard_now";
|
||||
@@ -16,7 +16,7 @@ import "../lovelace/hui-root";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import { DEFAULT_POWER_COLLECTION_KEY } from "../../data/energy";
|
||||
import { DEFAULT_POWER_COLLECTION_KEY } from "./constants";
|
||||
|
||||
@customElement("ha-panel-energy")
|
||||
class PanelEnergy extends LitElement {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
DEFAULT_POWER_COLLECTION_KEY,
|
||||
EMPTY_PREFERENCES,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
@@ -13,6 +11,10 @@ import type { LovelaceStrategyViewConfig } from "../../../data/lovelace/config/v
|
||||
import type { LocalizeKeys } from "../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
DEFAULT_POWER_COLLECTION_KEY,
|
||||
} from "../constants";
|
||||
import type { EnergyViewPath } from "./energy-cards";
|
||||
import {
|
||||
hasDeviceConsumption,
|
||||
@@ -158,9 +160,6 @@ async function fetchEnergyPrefs(
|
||||
): Promise<EnergyPreferences> {
|
||||
const collection = getEnergyDataCollection(hass, {
|
||||
key: defaultCollection || DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
// When landing directly on the "Now" view this warms its real-time
|
||||
// collection, so it must be created with midnight rollover too.
|
||||
midnightRollover: defaultCollection === DEFAULT_POWER_COLLECTION_KEY,
|
||||
});
|
||||
|
||||
return await new Promise<EnergyPreferences>((resolve) => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import { hasWaterSource, isEnergyCardVisible } from "./energy-cards";
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import { isEnergyCardVisible } from "./energy-cards";
|
||||
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import { hasGasSource, isEnergyCardVisible } from "./energy-cards";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import {
|
||||
hasGasRateSource,
|
||||
@@ -34,8 +32,6 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
|
||||
const energyCollection = getEnergyDataCollection(hass, {
|
||||
key: collectionKey,
|
||||
// The "Now" view is real-time; roll its day period over at midnight.
|
||||
midnightRollover: true,
|
||||
});
|
||||
if (!energyCollection.prefs) {
|
||||
await energyCollection.refresh();
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import {
|
||||
DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
getEnergyDataCollection,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import {
|
||||
hasWaterDevices,
|
||||
|
||||
@@ -50,6 +50,7 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.hass=${this.hass}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
LogbookCauseType,
|
||||
LogbookGlyph,
|
||||
LogbookItem,
|
||||
LogbookNameDetail,
|
||||
LogbookScope,
|
||||
LogbookValue,
|
||||
} from "./logbook-entry-model";
|
||||
import {
|
||||
@@ -63,8 +63,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public graphColor = false;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
@property({ attribute: false }) public scope?: LogbookScope;
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public firstOfDay = false;
|
||||
|
||||
@@ -84,7 +83,7 @@ class HaLogbookEntry extends LitElement {
|
||||
const seenEntityIds: string[] = [];
|
||||
|
||||
const item = computeLogbookItem(this.hass, entry, {
|
||||
nameDetail: this.nameDetail,
|
||||
scope: this.scope,
|
||||
userIdToName: this.userIdToName,
|
||||
});
|
||||
|
||||
@@ -99,7 +98,7 @@ class HaLogbookEntry extends LitElement {
|
||||
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
|
||||
: undefined;
|
||||
|
||||
const hideName = this.nameDetail === "none";
|
||||
const hideName = this.scope === "entity";
|
||||
const layout: EntryLayout =
|
||||
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
|
||||
const node = layout === "timeline" ? "icon" : "dot";
|
||||
@@ -225,7 +224,7 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
private _renderTimeline(ctx: LogbookRenderItem) {
|
||||
const hideName = this.nameDetail === "none";
|
||||
const hideName = this.scope === "entity";
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
|
||||
@@ -13,7 +13,7 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-entry";
|
||||
import type { LogbookNameDetail } from "./logbook-entry-model";
|
||||
import type { LogbookScope } from "./logbook-entry-model";
|
||||
import { sameDay } from "./logbook-entry-model";
|
||||
|
||||
declare global {
|
||||
@@ -47,8 +47,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
@property({ attribute: false }) public scope?: LogbookScope;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".container") private _savedScrollPos?: number;
|
||||
@@ -138,7 +137,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.noIcon=${this.noIcon}
|
||||
.graphColor=${this.graphColor}
|
||||
.nameDetail=${this.nameDetail}
|
||||
.scope=${this.scope}
|
||||
.firstOfDay=${firstOfDay}
|
||||
.lastOfDay=${lastOfDay}
|
||||
.showRelative=${this._showRelative}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { loadTraceContexts } from "../../data/trace";
|
||||
import { fetchUsers } from "../../data/user";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-renderer";
|
||||
import type { LogbookNameDetail } from "./logbook-entry-model";
|
||||
import type { LogbookScope } from "./logbook-entry-model";
|
||||
|
||||
interface LogbookTimePeriod {
|
||||
now: Date;
|
||||
@@ -67,10 +67,9 @@ export class HaLogbook extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
// How much naming detail an entity row shows; `none` also hides the name when
|
||||
// the surface already implies the subject.
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
// Surface scope: removes the context (and, for "entity", the subject name)
|
||||
// the surface already implies.
|
||||
@property({ attribute: false }) public scope?: LogbookScope;
|
||||
|
||||
@property({ attribute: "show-more-link", type: Boolean })
|
||||
public showMoreLink = true;
|
||||
@@ -131,7 +130,7 @@ export class HaLogbook extends LitElement {
|
||||
.noIcon=${this.noIcon}
|
||||
.graphColor=${this.graphColor}
|
||||
.showCause=${this.showCause}
|
||||
.nameDetail=${this.nameDetail}
|
||||
.scope=${this.scope}
|
||||
.entries=${this._logbookEntries}
|
||||
.traceContexts=${this._traceContexts}
|
||||
.userIdToName=${this._userIdToName}
|
||||
|
||||
@@ -35,10 +35,8 @@ export const classifyLogbookEntry = (
|
||||
return "integration";
|
||||
};
|
||||
|
||||
// How much naming detail an entity row shows, from least to most. The value is
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
|
||||
// entity), `area` (area ▸ device ▸ entity).
|
||||
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
|
||||
// A device lives in exactly one area, so `device` (and `entity`) imply it too.
|
||||
export type LogbookScope = "entity" | "device" | "area";
|
||||
|
||||
export interface EntityDisplay {
|
||||
primary?: string;
|
||||
@@ -48,7 +46,7 @@ export interface EntityDisplay {
|
||||
export const entityDisplay = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
nameDetail?: LogbookNameDetail
|
||||
scope?: LogbookScope
|
||||
): EntityDisplay => {
|
||||
const stateObj = hass.states[entityId] as HassEntity | undefined;
|
||||
if (!stateObj) {
|
||||
@@ -71,15 +69,14 @@ export const entityDisplay = (
|
||||
const deviceQualifier = entityName ? deviceName : undefined;
|
||||
|
||||
let parts: (string | undefined)[];
|
||||
switch (nameDetail) {
|
||||
case "none":
|
||||
switch (scope) {
|
||||
case "entity":
|
||||
case "device":
|
||||
parts = [];
|
||||
break;
|
||||
case "device":
|
||||
case "area":
|
||||
parts = [deviceQualifier];
|
||||
break;
|
||||
case "area":
|
||||
default:
|
||||
parts = [areaName, deviceQualifier];
|
||||
}
|
||||
@@ -310,7 +307,7 @@ export interface LogbookItem {
|
||||
}
|
||||
|
||||
export interface BuildLogbookItemOptions {
|
||||
nameDetail?: LogbookNameDetail;
|
||||
scope?: LogbookScope;
|
||||
userIdToName?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -331,7 +328,7 @@ export const computeLogbookItem = (
|
||||
: undefined;
|
||||
|
||||
const display = entry.entity_id
|
||||
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
|
||||
? entityDisplay(hass, entry.entity_id, opts.scope)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { EntityBadgeConfig } from "../badges/types";
|
||||
import type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
|
||||
|
||||
export const entityBadgeSuggestions: BadgeSuggestionProvider<EntityBadgeConfig> =
|
||||
{
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
const suggestions: BadgeSuggestion<EntityBadgeConfig>[] = [
|
||||
{
|
||||
config: { type: "entity", entity: entityId },
|
||||
},
|
||||
{
|
||||
label: hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.with_name"
|
||||
),
|
||||
config: { type: "entity", entity: entityId, show_name: true },
|
||||
},
|
||||
];
|
||||
return suggestions;
|
||||
},
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { customBadges } from "../../../data/lovelace_custom_cards";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { BADGE_SUGGESTION_PROVIDERS } from "./registry";
|
||||
import type { BadgeSuggestion } from "./types";
|
||||
|
||||
export type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
|
||||
export { BADGE_SUGGESTION_PROVIDERS } from "./registry";
|
||||
|
||||
export interface BadgeSuggestions {
|
||||
core: BadgeSuggestion[];
|
||||
custom: BadgeSuggestion[];
|
||||
}
|
||||
|
||||
export const generateBadgeSuggestions = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string | undefined
|
||||
): BadgeSuggestions => {
|
||||
if (!entityId || hass.states[entityId] === undefined) {
|
||||
return { core: [], custom: [] };
|
||||
}
|
||||
const core = Object.values(BADGE_SUGGESTION_PROVIDERS).flatMap((provider) => {
|
||||
try {
|
||||
return ensureArray(provider.getEntitySuggestion(hass, entityId)) ?? [];
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Badge suggestion provider threw:", err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const custom = customBadges.flatMap((badge) => {
|
||||
if (!badge.getEntitySuggestion) return [];
|
||||
try {
|
||||
return ensureArray(badge.getEntitySuggestion(hass, entityId)) ?? [];
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Custom badge "${badge.type}" getEntitySuggestion threw:`,
|
||||
err
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return { core, custom };
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
|
||||
import type { BadgeSuggestionProvider } from "./types";
|
||||
|
||||
export const BADGE_SUGGESTION_PROVIDERS: Record<
|
||||
string,
|
||||
BadgeSuggestionProvider
|
||||
> = {
|
||||
entity: entityBadgeSuggestions,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
export interface BadgeSuggestion<
|
||||
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
|
||||
> {
|
||||
label?: string;
|
||||
config: T;
|
||||
}
|
||||
|
||||
export interface BadgeSuggestionProvider<
|
||||
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
|
||||
> {
|
||||
getEntitySuggestion(
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
): BadgeSuggestion<T> | BadgeSuggestion<T>[] | null;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import { getEntityEntryContext } from "../../../common/entity/context/get_entity_context";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-card";
|
||||
@@ -18,7 +17,6 @@ import { resolveEntityIDs } from "../../../data/selector";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../logbook/ha-logbook";
|
||||
import type { HaLogbook } from "../../logbook/ha-logbook";
|
||||
import type { LogbookNameDetail } from "../../logbook/logbook-entry-model";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import "../components/hui-warning";
|
||||
@@ -193,60 +191,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
|
||||
);
|
||||
|
||||
private _getNameDetail(): LogbookNameDetail | undefined {
|
||||
const nameDetail = this._config?.name_detail ?? "auto";
|
||||
if (nameDetail !== "auto") {
|
||||
return nameDetail;
|
||||
}
|
||||
const entityIds = this._getEntityIds();
|
||||
if (!entityIds) {
|
||||
return undefined;
|
||||
}
|
||||
return this._getAutoNameDetail(
|
||||
entityIds,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the least detail the targeted entities need to stay unambiguous: a
|
||||
// single entity needs no name, a shared device needs only the entity name, a
|
||||
// shared area needs the device, otherwise show the full context.
|
||||
private _getAutoNameDetail = memoizeOne(
|
||||
(
|
||||
entityIds: string[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): LogbookNameDetail => {
|
||||
if (entityIds.length <= 1) {
|
||||
return "none";
|
||||
}
|
||||
const deviceIds = new Set<string | undefined>();
|
||||
const areaIds = new Set<string | undefined>();
|
||||
for (const entityId of entityIds) {
|
||||
const entry = entities[entityId];
|
||||
const { device, area } = entry
|
||||
? getEntityEntryContext(entry, entities, devices, areas, floors)
|
||||
: { device: null, area: null };
|
||||
deviceIds.add(device?.id);
|
||||
areaIds.add(area?.area_id);
|
||||
}
|
||||
// An entity without a device or area counts as its own group: it does not
|
||||
// share the context, so it must not collapse the level.
|
||||
if (deviceIds.size === 1 && !deviceIds.has(undefined)) {
|
||||
return "entity";
|
||||
}
|
||||
if (areaIds.size === 1 && !areaIds.has(undefined)) {
|
||||
return "device";
|
||||
}
|
||||
return "area";
|
||||
}
|
||||
);
|
||||
|
||||
protected update(changedProperties: PropertyValues<this>) {
|
||||
super.update(changedProperties);
|
||||
if (changedProperties.has("layout")) {
|
||||
@@ -312,7 +256,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
.time=${this._time}
|
||||
.entityIds=${this._getEntityIds()}
|
||||
.stateFilter=${this._stateFilter}
|
||||
.nameDetail=${this._getNameDetail()}
|
||||
narrow
|
||||
no-icon
|
||||
virtualize
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
import type { LegacyStateFilter } from "../common/evaluate-filter";
|
||||
import type { Condition, LegacyCondition } from "../common/validate-condition";
|
||||
import type { HuiImage } from "../components/hui-image";
|
||||
import type { LogbookNameDetail } from "../../logbook/logbook-entry-model";
|
||||
import type { TimestampRenderingFormat } from "../components/types";
|
||||
import type { LovelaceElementConfig } from "../elements/types";
|
||||
import type {
|
||||
@@ -388,7 +387,6 @@ export interface LogbookCardConfig extends LovelaceCardConfig {
|
||||
hours_to_show?: number;
|
||||
theme?: string;
|
||||
state_filter?: string[];
|
||||
name_detail?: "auto" | LogbookNameDetail;
|
||||
}
|
||||
|
||||
export interface MapEntityConfig extends EntityConfig {
|
||||
|
||||
@@ -13,12 +13,14 @@ import type {
|
||||
GridSourceTypeEnergyPreference,
|
||||
} from "../../../data/energy";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import { computeUserInitials } from "../../../data/user";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { HELPER_DOMAINS } from "../../config/helpers/const";
|
||||
import type { EntityBadgeConfig } from "../badges/types";
|
||||
import type {
|
||||
AlarmPanelCardConfig,
|
||||
EntitiesCardConfig,
|
||||
@@ -313,6 +315,23 @@ export const computeCards = (
|
||||
];
|
||||
};
|
||||
|
||||
export const computeBadges = (
|
||||
_states: HassEntities,
|
||||
entityIds: string[]
|
||||
): LovelaceBadgeConfig[] => {
|
||||
const badges: LovelaceBadgeConfig[] = [];
|
||||
|
||||
for (const entityId of entityIds) {
|
||||
const config: EntityBadgeConfig = {
|
||||
type: "entity",
|
||||
entity: entityId,
|
||||
};
|
||||
|
||||
badges.push(config);
|
||||
}
|
||||
return badges;
|
||||
};
|
||||
|
||||
const computeDefaultViewStates = (
|
||||
entities: HassEntities,
|
||||
entityEntries: HomeAssistant["entities"]
|
||||
|
||||
@@ -168,6 +168,7 @@ export class HuiEntityEditor extends LitElement {
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${entityConf.entity}
|
||||
.index=${index}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@@ -179,6 +180,7 @@ export class HuiEntityEditor extends LitElement {
|
||||
</div>
|
||||
</ha-sortable>`}
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._addEntity}
|
||||
add-button
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../../../common/entity/compute_entity_name_display";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-ripple";
|
||||
import "../../../../components/ha-section-title";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
|
||||
import { haStyleScrollbar } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
generateBadgeSuggestions,
|
||||
type BadgeSuggestions,
|
||||
} from "../../badge-suggestions";
|
||||
import type { BadgeSuggestion } from "../../badge-suggestions/types";
|
||||
import "../card-editor/hui-suggestion-entity-tree";
|
||||
import type { HuiSuggestionEntityTree } from "../card-editor/hui-suggestion-entity-tree";
|
||||
import "./hui-suggestion-badge";
|
||||
|
||||
@customElement("hui-badge-suggestion-picker")
|
||||
export class HuiBadgeSuggestionPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
public prioritizedBadgeTypes?: string[];
|
||||
|
||||
@state() private _entityId?: string;
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
private _narrowMql?: MediaQueryList;
|
||||
|
||||
@query("hui-suggestion-entity-tree")
|
||||
private _entityTree?: HuiSuggestionEntityTree;
|
||||
|
||||
public async focus(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._entityTree?.focus();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._narrowMql = matchMedia("(max-width: 600px)");
|
||||
this._narrow = this._narrowMql.matches;
|
||||
this._narrowMql.addEventListener("change", this._handleNarrowChange);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._narrowMql?.removeEventListener("change", this._handleNarrowChange);
|
||||
this._narrowMql = undefined;
|
||||
}
|
||||
|
||||
private _handleNarrowChange = (ev: MediaQueryListEvent) => {
|
||||
this._narrow = ev.matches;
|
||||
};
|
||||
|
||||
// Memoize on scalars so the result stays stable when only hass changes.
|
||||
// Keeps hui-badge previews from re-rendering on every state tick.
|
||||
private _computeSuggestions = memoizeOne(
|
||||
(
|
||||
entityId: string | undefined,
|
||||
priorityTypesKey: string
|
||||
): BadgeSuggestions => {
|
||||
const { core, custom } = generateBadgeSuggestions(this.hass, entityId);
|
||||
const priorityTypes = priorityTypesKey
|
||||
? priorityTypesKey.split("|")
|
||||
: undefined;
|
||||
if (!priorityTypes?.length) return { core, custom };
|
||||
const isPrioritized = (s: BadgeSuggestion) =>
|
||||
priorityTypes.includes(s.config.type);
|
||||
return {
|
||||
core: [
|
||||
...core.filter(isPrioritized),
|
||||
...core.filter((s) => !isPrioritized(s)),
|
||||
],
|
||||
custom,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const hasEntity = !!this._entityId;
|
||||
// Tree is rendered unconditionally so its state (filter, expanded
|
||||
// branches, fuse index) survives the desktop/mobile and tree/suggestions
|
||||
// switches.
|
||||
const showTree = !this._narrow || !hasEntity;
|
||||
const showMain = !this._narrow || hasEntity;
|
||||
return html`
|
||||
<div class=${classMap({ sidebar: true, hidden: !showTree })}>
|
||||
<hui-suggestion-entity-tree
|
||||
class="tree"
|
||||
.hass=${this.hass}
|
||||
.selectedEntityId=${this._entityId}
|
||||
@entity-picked=${this._handleEntityPicked}
|
||||
></hui-suggestion-entity-tree>
|
||||
</div>
|
||||
<div class=${classMap({ main: true, hidden: !showMain })}>
|
||||
<div class="content ha-scrollbar">
|
||||
${this._renderMainContent(hasEntity)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainContent(
|
||||
hasEntity: boolean
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!hasEntity) return this._renderEmptyState();
|
||||
const { core, custom } = this._suggestions();
|
||||
return html`
|
||||
${this._narrow ? this._renderSelectedEntity() : nothing}
|
||||
<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.suggestions_title"
|
||||
)}
|
||||
</ha-section-title>
|
||||
${this._renderSuggestionsGrid(core)}
|
||||
${custom.length
|
||||
? html`
|
||||
<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.community_title"
|
||||
)}
|
||||
</ha-section-title>
|
||||
${this._renderSuggestionsGrid(custom)}
|
||||
`
|
||||
: nothing}
|
||||
${this._renderBrowseBadge()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderBrowseBadge(): TemplateResult {
|
||||
return html`
|
||||
<div class="browse-badge">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.not_found"
|
||||
)}
|
||||
</p>
|
||||
<ha-button appearance="plain" @click=${this._browseBadges}>
|
||||
<ha-svg-icon slot="start" .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.browse_badges"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSelectedEntity(): TemplateResult {
|
||||
const stateObj = this.hass.states[this._entityId!];
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
: { primary: this._entityId!, secondary: undefined };
|
||||
return html`
|
||||
<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.selected_entity"
|
||||
)}
|
||||
</ha-section-title>
|
||||
<ha-combo-box-item compact class="selected-entity">
|
||||
${stateObj
|
||||
? html`<state-badge
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></state-badge>`
|
||||
: nothing}
|
||||
<span slot="headline">${primary}</span>
|
||||
${secondary
|
||||
? html`<span slot="supporting-text">${secondary}</span>`
|
||||
: nothing}
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.label=${this.hass.localize("ui.common.clear")}
|
||||
.path=${mdiClose}
|
||||
@click=${this._clearEntity}
|
||||
></ha-icon-button>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEmptyState(): TemplateResult {
|
||||
return html`
|
||||
<div class="content-empty">
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.content_empty_title"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.content_empty_description"
|
||||
)}
|
||||
</p>
|
||||
<ha-button appearance="plain" @click=${this._browseBadges}>
|
||||
<ha-svg-icon slot="start" .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.browse_badges"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _suggestionKeys = new WeakMap<BadgeSuggestion, string>();
|
||||
|
||||
private _suggestionKey = (s: BadgeSuggestion): string => {
|
||||
let key = this._suggestionKeys.get(s);
|
||||
if (key === undefined) {
|
||||
key = JSON.stringify(s.config);
|
||||
this._suggestionKeys.set(s, key);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
private _renderSuggestionsGrid(
|
||||
suggestions: BadgeSuggestion[]
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="suggestions" @pick-badge-suggestion=${this._pickSuggestion}>
|
||||
${repeat(
|
||||
suggestions,
|
||||
this._suggestionKey,
|
||||
(s: BadgeSuggestion) => html`
|
||||
<hui-suggestion-badge
|
||||
.hass=${this.hass}
|
||||
.suggestion=${s}
|
||||
></hui-suggestion-badge>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _suggestions(): BadgeSuggestions {
|
||||
return this._computeSuggestions(
|
||||
this._entityId,
|
||||
(this.prioritizedBadgeTypes ?? []).join("|")
|
||||
);
|
||||
}
|
||||
|
||||
private _browseBadges(): void {
|
||||
fireEvent(this, "browse-badges", undefined);
|
||||
}
|
||||
|
||||
private _handleEntityPicked(ev: CustomEvent<{ entityId: string }>): void {
|
||||
this._entityId = ev.detail.entityId;
|
||||
}
|
||||
|
||||
private _clearEntity(): void {
|
||||
this._entityId = undefined;
|
||||
}
|
||||
|
||||
private _pickSuggestion(
|
||||
ev: CustomEvent<{ suggestion: BadgeSuggestion }>
|
||||
): void {
|
||||
fireEvent(this, "badge-suggestion-picked", {
|
||||
config: ev.detail.suggestion.config,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-inline-end: var(--ha-border-width-sm) solid
|
||||
var(--divider-color);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.suggestions {
|
||||
display: grid;
|
||||
gap: var(--ha-space-3);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.content-empty {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.content-empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.content-empty p {
|
||||
margin: 0;
|
||||
max-width: 480px;
|
||||
color: var(--ha-color-text-secondary);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
}
|
||||
.browse-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-6) var(--ha-space-4);
|
||||
}
|
||||
.browse-badge p {
|
||||
margin: 0;
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
/* Mobile master/detail: sidebar OR main is visible, never both. */
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
border-inline-end: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-badge-suggestion-picker": HuiBadgeSuggestionPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"browse-badges": undefined;
|
||||
"badge-suggestion-picked": { config: LovelaceBadgeConfig };
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,35 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { addBadge } from "../config-util";
|
||||
import { computeBadges } from "../../common/generate-lovelace-config";
|
||||
import "../card-editor/hui-entity-picker-table";
|
||||
import { findLovelaceContainer } from "../lovelace-path";
|
||||
import "./hui-badge-picker";
|
||||
import "./hui-badge-suggestion-picker";
|
||||
import type { CreateBadgeDialogParams } from "./show-create-badge-dialog";
|
||||
import { showEditBadgeDialog } from "./show-edit-badge-dialog";
|
||||
import { showSuggestBadgeDialog } from "./show-suggest-badge-dialog";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"selected-changed": SelectedChangedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectedChangedEvent {
|
||||
selectedEntities: string[];
|
||||
}
|
||||
|
||||
@customElement("hui-dialog-create-badge")
|
||||
export class HuiCreateDialogBadge
|
||||
@@ -35,17 +46,13 @@ export class HuiCreateDialogBadge
|
||||
|
||||
@state() private _containerConfig!: LovelaceViewConfig;
|
||||
|
||||
@state() private _currTab: "badge" | "entity" = "entity";
|
||||
@state() private _selectedEntities: string[] = [];
|
||||
|
||||
@state() private _narrow = false;
|
||||
@state() private _currTab: "badge" | "entity" = "badge";
|
||||
|
||||
public async showDialog(params: CreateBadgeDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
|
||||
this._narrow = matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
|
||||
const containerConfig = findLovelaceContainer(
|
||||
params.lovelaceConfig,
|
||||
params.path
|
||||
@@ -67,7 +74,8 @@ export class HuiCreateDialogBadge
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._currTab = "entity";
|
||||
this._currTab = "badge";
|
||||
this._selectedEntities = [];
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -90,6 +98,7 @@ export class HuiCreateDialogBadge
|
||||
width="large"
|
||||
@keydown=${this._ignoreKeydown}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${classMap({ table: this._currTab === "entity" })}
|
||||
>
|
||||
<ha-dialog-header show-border slot="header">
|
||||
<ha-icon-button
|
||||
@@ -100,15 +109,6 @@ export class HuiCreateDialogBadge
|
||||
></ha-icon-button>
|
||||
<span slot="title">${title}</span>
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
?autofocus=${this._narrow}
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "badge"}
|
||||
@@ -118,31 +118,35 @@ export class HuiCreateDialogBadge
|
||||
"ui.panel.lovelace.editor.badge_picker.by_badge"
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
</ha-tab-group>
|
||||
</ha-dialog-header>
|
||||
<div class="body">
|
||||
${cache(
|
||||
this._currTab === "entity"
|
||||
? html`
|
||||
<hui-badge-suggestion-picker
|
||||
?autofocus=${!this._narrow}
|
||||
.hass=${this.hass}
|
||||
.prioritizedBadgeTypes=${this._params.suggestedBadges}
|
||||
@badge-suggestion-picked=${this._handleSuggestionPicked}
|
||||
@browse-badges=${this._handleBrowseBadges}
|
||||
></hui-badge-suggestion-picker>
|
||||
`
|
||||
: html`
|
||||
<hui-badge-picker
|
||||
?autofocus=${!this._narrow}
|
||||
.suggestedBadges=${this._params.suggestedBadges}
|
||||
.lovelace=${this._params.lovelaceConfig}
|
||||
.hass=${this.hass}
|
||||
@config-changed=${this._handleBadgePicked}
|
||||
></hui-badge-picker>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
${cache(
|
||||
this._currTab === "badge"
|
||||
? html`
|
||||
<hui-badge-picker
|
||||
autofocus
|
||||
.suggestedBadges=${this._params.suggestedBadges}
|
||||
.lovelace=${this._params.lovelaceConfig}
|
||||
.hass=${this.hass}
|
||||
@config-changed=${this._handleBadgePicked}
|
||||
></hui-badge-picker>
|
||||
`
|
||||
: html`
|
||||
<hui-entity-picker-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${true}
|
||||
@selected-changed=${this._handleSelectedChanged}
|
||||
></hui-entity-picker-table>
|
||||
`
|
||||
)}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@@ -152,6 +156,13 @@ export class HuiCreateDialogBadge
|
||||
>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${this._selectedEntities.length
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._suggestBadges}>
|
||||
${this.hass!.localize("ui.common.continue")}
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -170,19 +181,13 @@ export class HuiCreateDialogBadge
|
||||
--dialog-z-index: 6;
|
||||
}
|
||||
|
||||
@media (min-width: 451px) and (min-height: 501px) {
|
||||
ha-dialog {
|
||||
--ha-dialog-min-height: min(900px, 80vh);
|
||||
--ha-dialog-max-height: var(--ha-dialog-min-height);
|
||||
}
|
||||
ha-dialog.table {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
ha-dialog::part(body) {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-dialog-footer {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
ha-tab-group-tab {
|
||||
flex: 1;
|
||||
@@ -191,41 +196,34 @@ export class HuiCreateDialogBadge
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
hui-badge-picker,
|
||||
hui-badge-suggestion-picker {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
hui-badge-picker {
|
||||
--badge-picker-search-shape: 0;
|
||||
--badge-picker-search-margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
hui-badge-picker,
|
||||
hui-entity-picker-table {
|
||||
height: calc(100vh - 198px);
|
||||
}
|
||||
|
||||
hui-entity-picker-table {
|
||||
display: block;
|
||||
--mdc-shape-small: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
hui-badge-picker,
|
||||
hui-entity-picker-table {
|
||||
height: calc(100vh - 158px);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _handleBrowseBadges(): void {
|
||||
this._currTab = "badge";
|
||||
}
|
||||
|
||||
private async _handleSuggestionPicked(
|
||||
ev: CustomEvent<{ config: LovelaceBadgeConfig }>
|
||||
): Promise<void> {
|
||||
const config = ev.detail.config;
|
||||
const lovelaceConfig = this._params!.lovelaceConfig;
|
||||
const containerPath = this._params!.path;
|
||||
const saveConfig = this._params!.saveConfig;
|
||||
const newConfig = addBadge(lovelaceConfig, containerPath, config);
|
||||
await saveConfig(newConfig);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _handleBadgePicked(ev) {
|
||||
const config = ev.detail.config;
|
||||
if (this._params!.entities && this._params!.entities.length) {
|
||||
@@ -251,7 +249,13 @@ export class HuiCreateDialogBadge
|
||||
if (newTab === this._currTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._currTab = newTab;
|
||||
this._selectedEntities = [];
|
||||
}
|
||||
|
||||
private _handleSelectedChanged(ev: CustomEvent): void {
|
||||
this._selectedEntities = ev.detail.selectedEntities;
|
||||
}
|
||||
|
||||
private _cancel(ev?: Event) {
|
||||
@@ -260,6 +264,20 @@ export class HuiCreateDialogBadge
|
||||
}
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _suggestBadges(): void {
|
||||
const badgeConfig = computeBadges(this.hass.states, this._selectedEntities);
|
||||
|
||||
showSuggestBadgeDialog(this, {
|
||||
lovelaceConfig: this._params!.lovelaceConfig,
|
||||
saveConfig: this._params!.saveConfig,
|
||||
path: this._params!.path as [number],
|
||||
entities: this._selectedEntities,
|
||||
badgeConfig,
|
||||
});
|
||||
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog";
|
||||
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
|
||||
import "../../badges/hui-badge";
|
||||
import { addBadges } from "../config-util";
|
||||
import type { LovelaceContainerPath } from "../lovelace-path";
|
||||
import { parseLovelaceContainerPath } from "../lovelace-path";
|
||||
import type { SuggestBadgeDialogParams } from "./show-suggest-badge-dialog";
|
||||
|
||||
@customElement("hui-dialog-suggest-badge")
|
||||
export class HuiDialogSuggestBadge extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: SuggestBadgeDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _badgeConfig?: LovelaceBadgeConfig[];
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
|
||||
|
||||
public showDialog(params: SuggestBadgeDialogParams): void {
|
||||
this._params = params;
|
||||
this._badgeConfig = params.badgeConfig;
|
||||
this._open = true;
|
||||
if (!Object.isFrozen(this._badgeConfig)) {
|
||||
this._badgeConfig = deepFreeze(this._badgeConfig);
|
||||
}
|
||||
if (this._yamlEditor) {
|
||||
this._yamlEditor.setValue(this._badgeConfig);
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._badgeConfig = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _renderPreview() {
|
||||
if (this._badgeConfig) {
|
||||
return html`
|
||||
<div class="element-preview">
|
||||
${this._badgeConfig.map(
|
||||
(badgeConfig) => html`
|
||||
<hui-badge
|
||||
.hass=${this.hass}
|
||||
.config=${badgeConfig}
|
||||
preview
|
||||
></hui-badge>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.suggest_badge.header"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div>
|
||||
${this._renderPreview()}
|
||||
${this._params.yaml && this._badgeConfig
|
||||
? html`
|
||||
<div class="editor">
|
||||
<ha-yaml-editor
|
||||
.defaultValue=${this._badgeConfig}
|
||||
in-dialog
|
||||
></ha-yaml-editor>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
autofocus
|
||||
>
|
||||
${this._params.yaml
|
||||
? this.hass!.localize("ui.common.close")
|
||||
: this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${!this._params.yaml
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.loading=${this._saving}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.suggest_badge.add"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-z-index: 6;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.element-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-2);
|
||||
margin: 0;
|
||||
}
|
||||
.editor {
|
||||
padding-top: 16px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _computeNewConfig(
|
||||
config: LovelaceConfig,
|
||||
path: LovelaceContainerPath
|
||||
): LovelaceConfig {
|
||||
const { viewIndex } = parseLovelaceContainerPath(path);
|
||||
|
||||
const newBadges = this._badgeConfig!;
|
||||
return addBadges(config, [viewIndex], newBadges);
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (
|
||||
!this._params?.lovelaceConfig ||
|
||||
!this._params?.path ||
|
||||
!this._params?.saveConfig ||
|
||||
!this._badgeConfig
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._saving = true;
|
||||
|
||||
const newConfig = this._computeNewConfig(
|
||||
this._params.lovelaceConfig,
|
||||
this._params.path
|
||||
);
|
||||
await this._params!.saveConfig(newConfig);
|
||||
this._saving = false;
|
||||
showSaveSuccessToast(this, this.hass);
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-suggest-badge": HuiDialogSuggestBadge;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-ripple";
|
||||
import {
|
||||
getCustomBadgeEntry,
|
||||
isCustomType,
|
||||
stripCustomPrefix,
|
||||
} from "../../../../data/lovelace_custom_cards";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { BadgeSuggestion } from "../../badge-suggestions/types";
|
||||
import "../../badges/hui-badge";
|
||||
|
||||
@customElement("hui-suggestion-badge")
|
||||
export class HuiSuggestionBadge extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public suggestion!: BadgeSuggestion;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const { suggestion } = this;
|
||||
const type = suggestion.config.type;
|
||||
let badgeName: string;
|
||||
if (isCustomType(type)) {
|
||||
const customType = stripCustomPrefix(type);
|
||||
badgeName = getCustomBadgeEntry(customType)?.name ?? customType;
|
||||
} else {
|
||||
badgeName =
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.badge.${type}.name` as any
|
||||
) || type;
|
||||
}
|
||||
const label = suggestion.label
|
||||
? `${badgeName} - ${suggestion.label}`
|
||||
: badgeName;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="badge"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${label}
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<div class="overlay" @click=${this._handleClick}></div>
|
||||
<div class="badge-header">${label}</div>
|
||||
<div class="preview">
|
||||
<hui-badge
|
||||
.hass=${this.hass}
|
||||
.config=${suggestion.config}
|
||||
preview
|
||||
></hui-badge>
|
||||
</div>
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick(): void {
|
||||
fireEvent(this, "pick-badge-suggestion", { suggestion: this.suggestion });
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleClick();
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.badge {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background: var(--primary-background-color);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: var(--ha-card-border-width, var(--ha-border-width-sm)) solid
|
||||
var(--ha-card-border-color, var(--divider-color));
|
||||
}
|
||||
.badge:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.badge-header {
|
||||
color: var(--ha-card-header-color, var(--primary-text-color));
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.preview {
|
||||
pointer-events: none;
|
||||
margin: var(--ha-space-4);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-suggestion-badge": HuiSuggestionBadge;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"pick-badge-suggestion": { suggestion: BadgeSuggestion };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user