diff --git a/.browserslistrc b/.browserslistrc index 516fec9cec..f500a93605 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,28 +1,25 @@ [modern] -# Support for dynamic import is the main litmus test for serving modern builds. -# Although officially a ES2020 feature, browsers implemented it early, so this -# enables all of ES2017 and some features in ES2018. -supports es6-module-dynamic-import - -# Exclude Safari 11-12 because of a bug in tagged template literals -# https://bugs.webkit.org/show_bug.cgi?id=190756 -# Note: Dropping version 11 also enables several more ES2018 features -not Safari < 13 -not iOS < 13 - -# Exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data -# Babel ignores these automatically, but we need here for Webpack to output ESM with dynamic imports +# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc. +# It is served to browsers meeting the following requirements: +# - released in the last year + current alpha/beta versions +# - Firefox extended support release (ESR) +# - with global utilization at or above 0.5% +# - must support dynamic import of ES modules +# - exclude browsers no longer being maintained +# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data +unreleased versions +last 1 year +Firefox ESR +>= 0.5% and supports es6-module-dynamic-import +not dead not KaiOS > 0 not QQAndroid > 0 not UCAndroid > 0 -# Exclude unsupported browsers -not dead - [legacy] # Legacy builds are served when modern requirements are not met and support browsers: # - released in the last 7 years + current alpha/beta versionss -# - with global utilization above 0.05% +# - with global utilization at or above 0.05% # The lattermost query ensures that support for popular old browsers is not dropped too early # (e.g. IE 11, Android 4.4, or Samsung 4). # @@ -36,4 +33,10 @@ not dead # As of May 2023, only web sockets must be added to the query. unreleased versions last 7 years -> 0.05% and supports websockets +>= 0.05% and supports websockets + +[legacy-sw] +# Same as legacy plus supports service workers +unreleased versions +last 7 years +>= 0.05% and supports websockets and supports serviceworkers diff --git a/.github/workflows/cast_deployment.yaml b/.github/workflows/cast_deployment.yaml index e665b15060..b0c6fbad01 100644 --- a/.github/workflows/cast_deployment.yaml +++ b/.github/workflows/cast_deployment.yaml @@ -26,7 +26,7 @@ jobs: ref: dev - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -62,7 +62,7 @@ jobs: ref: master - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 42d7d54e65..661824f1d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,7 +26,7 @@ jobs: - name: Check out files from GitHub uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -60,7 +60,7 @@ jobs: - name: Check out files from GitHub uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -78,7 +78,7 @@ jobs: - name: Check out files from GitHub uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -102,7 +102,7 @@ jobs: - name: Check out files from GitHub uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/demo_deployment.yaml b/.github/workflows/demo_deployment.yaml index 0e1bc73716..89ca805c29 100644 --- a/.github/workflows/demo_deployment.yaml +++ b/.github/workflows/demo_deployment.yaml @@ -27,7 +27,7 @@ jobs: ref: dev - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -63,7 +63,7 @@ jobs: ref: master - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/design_deployment.yaml b/.github/workflows/design_deployment.yaml index ac6297eb1f..c8728b6479 100644 --- a/.github/workflows/design_deployment.yaml +++ b/.github/workflows/design_deployment.yaml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/design_preview.yaml b/.github/workflows/design_preview.yaml index 607be985a1..2ed60915fa 100644 --- a/.github/workflows/design_preview.yaml +++ b/.github/workflows/design_preview.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index c095b89c24..a4a2a17ec9 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -28,7 +28,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3ba8422fff..5e19a255c7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Setup Node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: ".nvmrc" cache: yarn @@ -55,7 +55,7 @@ jobs: script/release - name: Upload release assets - uses: softprops/action-gh-release@v2.0.6 + uses: softprops/action-gh-release@v2.0.8 with: files: | dist/*.whl @@ -74,9 +74,9 @@ jobs: echo "home-assistant-frontend==$version" > ./requirements.txt - name: Build wheels - uses: home-assistant/wheels@2024.01.0 + uses: home-assistant/wheels@2024.07.1 with: - abi: cp311 + abi: cp312 tag: musllinux_1_2 arch: amd64 wheels-key: ${{ secrets.WHEELS_KEY }} diff --git a/.husky/pre-commit b/.husky/pre-commit index cf5c994491..f4ae2f354a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - yarn run lint-staged --relative --shell "/bin/bash" diff --git a/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch b/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch new file mode 100644 index 0000000000..81e93fcd10 --- /dev/null +++ b/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch @@ -0,0 +1,55 @@ +diff --git a/build/inject-manifest.js b/build/inject-manifest.js +index 60e3d2bb51c11a19fbbedbad65e101082ec41c36..fed6026630f43f86e25446383982cf6fb694313b 100644 +--- a/build/inject-manifest.js ++++ b/build/inject-manifest.js +@@ -104,7 +104,7 @@ async function injectManifest(config) { + replaceString: manifestString, + searchString: options.injectionPoint, + }); +- filesToWrite[options.swDest] = source; ++ filesToWrite[options.swDest] = source.replace(url, encodeURI(upath_1.default.basename(destPath))); + filesToWrite[destPath] = map; + } + else { +diff --git a/build/lib/translate-url-to-sourcemap-paths.js b/build/lib/translate-url-to-sourcemap-paths.js +index 3220c5474eeac6e8a56ca9b2ac2bd9be48529e43..5f003879a904d4840529a42dd056d288fd213771 100644 +--- a/build/lib/translate-url-to-sourcemap-paths.js ++++ b/build/lib/translate-url-to-sourcemap-paths.js +@@ -22,7 +22,7 @@ function translateURLToSourcemapPaths(url, swSrc, swDest) { + const possibleSrcPath = upath_1.default.resolve(upath_1.default.dirname(swSrc), url); + if (fs_extra_1.default.existsSync(possibleSrcPath)) { + srcPath = possibleSrcPath; +- destPath = upath_1.default.resolve(upath_1.default.dirname(swDest), url); ++ destPath = `${swDest}.map`; + } + else { + warning = `${errors_1.errors['cant-find-sourcemap']} ${possibleSrcPath}`; +diff --git a/src/inject-manifest.ts b/src/inject-manifest.ts +index 8795ddcaa77aea7b0356417e4bc4b19e2b3f860c..fcdc68342d9ac53936c9ed40a9ccfc2f5070cad3 100644 +--- a/src/inject-manifest.ts ++++ b/src/inject-manifest.ts +@@ -129,7 +129,10 @@ export async function injectManifest( + searchString: options.injectionPoint!, + }); + +- filesToWrite[options.swDest] = source; ++ filesToWrite[options.swDest] = source.replace( ++ url!, ++ encodeURI(upath.basename(destPath)), ++ ); + filesToWrite[destPath] = map; + } else { + // If there's no sourcemap associated with swSrc, a simple string +diff --git a/src/lib/translate-url-to-sourcemap-paths.ts b/src/lib/translate-url-to-sourcemap-paths.ts +index 072eac40d4ef5d095a01cb7f7e392a9e034853bd..f0bbe69e88ef3a415de18a7e9cb264daea273d71 100644 +--- a/src/lib/translate-url-to-sourcemap-paths.ts ++++ b/src/lib/translate-url-to-sourcemap-paths.ts +@@ -28,7 +28,7 @@ export function translateURLToSourcemapPaths( + const possibleSrcPath = upath.resolve(upath.dirname(swSrc), url); + if (fse.existsSync(possibleSrcPath)) { + srcPath = possibleSrcPath; +- destPath = upath.resolve(upath.dirname(swDest), url); ++ destPath = `${swDest}.map`; + } else { + warning = `${errors['cant-find-sourcemap']} ${possibleSrcPath}`; + } diff --git a/build-scripts/bundle.cjs b/build-scripts/bundle.cjs index 9c292c7921..84e1490f99 100644 --- a/build-scripts/bundle.cjs +++ b/build-scripts/bundle.cjs @@ -47,7 +47,7 @@ module.exports.emptyPackages = ({ latestBuild, isHassioBuild }) => module.exports.definedVars = ({ isProdBuild, latestBuild, defineOverlay }) => ({ __DEV__: !isProdBuild, - __BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"), + __BUILD__: JSON.stringify(latestBuild ? "modern" : "legacy"), __VERSION__: JSON.stringify(env.version()), __DEMO__: false, __SUPERVISOR__: false, @@ -79,7 +79,12 @@ module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({ sourceMap: !isTestBuild, }); -module.exports.babelOptions = ({ latestBuild, isProdBuild, isTestBuild }) => ({ +module.exports.babelOptions = ({ + latestBuild, + isProdBuild, + isTestBuild, + sw, +}) => ({ babelrc: false, compact: false, assumptions: { @@ -87,7 +92,7 @@ module.exports.babelOptions = ({ latestBuild, isProdBuild, isTestBuild }) => ({ setPublicClassFields: true, setSpreadProperties: true, }, - browserslistEnv: latestBuild ? "modern" : "legacy", + browserslistEnv: latestBuild ? "modern" : `legacy${sw ? "-sw" : ""}`, presets: [ [ "@babel/preset-env", @@ -135,8 +140,14 @@ module.exports.babelOptions = ({ latestBuild, isProdBuild, isTestBuild }) => ({ "@babel/plugin-transform-runtime", { version: dependencies["@babel/runtime"] }, ], - // Support some proposals still in TC39 process - ["@babel/plugin-proposal-decorators", { decoratorsBeforeExport: true }], + // Transpile decorators (still in TC39 process) + // Modern browsers support class fields and private methods, but transform is required with the older decorator version dictated by Lit + [ + "@babel/plugin-proposal-decorators", + { version: "2018-09", decoratorsBeforeExport: true }, + ], + "@babel/plugin-transform-class-properties", + "@babel/plugin-transform-private-methods", ].filter(Boolean), exclude: [ // \\ for Windows, / for Mac OS and Linux @@ -215,7 +226,13 @@ module.exports.config = { return { name: "frontend" + nameSuffix(latestBuild), entry: { - service_worker: "./src/entrypoints/service_worker.ts", + "service-worker": + !env.useRollup() && !latestBuild + ? { + import: "./src/entrypoints/service-worker.ts", + layer: "sw", + } + : "./src/entrypoints/service-worker.ts", app: "./src/entrypoints/app.ts", authorize: "./src/entrypoints/authorize.ts", onboarding: "./src/entrypoints/onboarding.ts", diff --git a/build-scripts/gulp/compress.js b/build-scripts/gulp/compress.js index aa1567b721..ce68c1a108 100644 --- a/build-scripts/gulp/compress.js +++ b/build-scripts/gulp/compress.js @@ -1,19 +1,54 @@ // Tasks to compress +import { constants } from "node:zlib"; import gulp from "gulp"; +import brotli from "gulp-brotli"; import zopfli from "gulp-zopfli-green"; import paths from "../paths.cjs"; +const filesGlob = "*.{js,json,css,svg,xml}"; +const brotliOptions = { + skipLarger: true, + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + }, +}; const zopfliOptions = { threshold: 150 }; -const compressDist = (rootDir) => +const compressDistBrotli = (rootDir, modernDir) => gulp - .src([ - `${rootDir}/**/*.{js,json,css,svg,xml}`, - `${rootDir}/{authorize,onboarding}.html`, - ]) + .src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], { + base: rootDir, + }) + .pipe(brotli(brotliOptions)) + .pipe(gulp.dest(rootDir)); + +const compressDistZopfli = (rootDir, modernDir) => + gulp + .src( + [ + `${rootDir}/**/${filesGlob}`, + `!${modernDir}/**/${filesGlob}`, + `!${rootDir}/sw-modern.js`, + `${rootDir}/{authorize,onboarding}.html`, + ], + { base: rootDir } + ) .pipe(zopfli(zopfliOptions)) .pipe(gulp.dest(rootDir)); -gulp.task("compress-app", () => compressDist(paths.app_output_root)); -gulp.task("compress-hassio", () => compressDist(paths.hassio_output_root)); +const compressAppBrotli = () => + compressDistBrotli(paths.app_output_root, paths.app_output_latest); +const compressHassioBrotli = () => + compressDistBrotli(paths.hassio_output_root, paths.hassio_output_latest); + +const compressAppZopfli = () => + compressDistZopfli(paths.app_output_root, paths.app_output_latest); +const compressHassioZopfli = () => + compressDistZopfli(paths.hassio_output_root, paths.hassio_output_latest); + +gulp.task("compress-app", gulp.parallel(compressAppBrotli, compressAppZopfli)); +gulp.task( + "compress-hassio", + gulp.parallel(compressHassioBrotli, compressHassioZopfli) +); diff --git a/build-scripts/gulp/entry-html.js b/build-scripts/gulp/entry-html.js index dd6285c310..3afb751039 100644 --- a/build-scripts/gulp/entry-html.js +++ b/build-scripts/gulp/entry-html.js @@ -1,5 +1,6 @@ // Tasks to generate entry HTML +import { getUserAgentRegex } from "browserslist-useragent-regexp"; import fs from "fs-extra"; import gulp from "gulp"; import { minify } from "html-minifier-terser"; @@ -17,6 +18,12 @@ const renderTemplate = (templateFile, data = {}) => { ...data, useRollup: env.useRollup(), useWDS: env.useWDS(), + modernRegex: getUserAgentRegex({ + env: "modern", + allowHigherVersions: true, + mobileToDesktop: true, + throwOnMissing: true, + }).toString(), // Resolve any child/nested templates relative to the parent and pass the same data renderTemplate: (childTemplate) => renderTemplate( diff --git a/build-scripts/gulp/service-worker.js b/build-scripts/gulp/service-worker.js index f9134da76c..ff4d5c2b7e 100644 --- a/build-scripts/gulp/service-worker.js +++ b/build-scripts/gulp/service-worker.js @@ -1,20 +1,19 @@ -// Generate service worker. -// Based on manifest, create a file with the content as service_worker.js +// Generate service workers -import fs from "fs-extra"; +import { deleteAsync } from "del"; import gulp from "gulp"; -import path from "path"; -import sourceMapUrl from "source-map-url"; -import workboxBuild from "workbox-build"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { injectManifest } from "workbox-build"; import paths from "../paths.cjs"; -const swDest = path.resolve(paths.app_output_root, "service_worker.js"); +const SW_MAP = { + [paths.app_output_latest]: "modern", + [paths.app_output_es5]: "legacy", +}; -const writeSW = (content) => fs.outputFileSync(swDest, content.trim() + "\n"); - -gulp.task("gen-service-worker-app-dev", (done) => { - writeSW( - ` +const SW_DEV = + ` console.debug('Service worker disabled in development'); self.addEventListener('install', (event) => { @@ -22,72 +21,61 @@ self.addEventListener('install', (event) => { // removing any prod service worker the dev might have running self.skipWaiting(); }); - ` + `.trim() + "\n"; + +gulp.task("gen-service-worker-app-dev", async () => { + await mkdir(paths.app_output_root, { recursive: true }); + await Promise.all( + Object.values(SW_MAP).map((build) => + writeFile(join(paths.app_output_root, `sw-${build}.js`), SW_DEV, { + encoding: "utf-8", + }) + ) ); - done(); }); -gulp.task("gen-service-worker-app-prod", async () => { - // Read bundled source file - const bundleManifestLatest = fs.readJsonSync( - path.resolve(paths.app_output_latest, "manifest.json") - ); - let serviceWorkerContent = fs.readFileSync( - paths.app_output_root + bundleManifestLatest["service_worker.js"], - "utf-8" - ); - - // Delete old file from frontend_latest so manifest won't pick it up - fs.removeSync( - paths.app_output_root + bundleManifestLatest["service_worker.js"] - ); - fs.removeSync( - paths.app_output_root + bundleManifestLatest["service_worker.js.map"] - ); - - // Remove ES5 - const bundleManifestES5 = fs.readJsonSync( - path.resolve(paths.app_output_es5, "manifest.json") - ); - fs.removeSync(paths.app_output_root + bundleManifestES5["service_worker.js"]); - fs.removeSync( - paths.app_output_root + bundleManifestES5["service_worker.js.map"] - ); - - const workboxManifest = await workboxBuild.getManifest({ - // Files that mach this pattern will be considered unique and skip revision check - // ignore JS files + translation files - dontCacheBustURLsMatching: /(frontend_latest\/.+|static\/translations\/.+)/, - - globDirectory: paths.app_output_root, - globPatterns: [ - "frontend_latest/*.js", - // Cache all English translations because we catch them as fallback - // Using pattern to match hash instead of * to avoid caching en-GB - // 'v' added as valid hash letter because in dev we hash with 'dev' - "static/translations/**/en-+([a-fv0-9]).json", - // Icon shown on splash screen - "static/icons/favicon-192x192.png", - "static/icons/favicon.ico", - // Common fonts - "static/fonts/roboto/Roboto-Light.woff2", - "static/fonts/roboto/Roboto-Medium.woff2", - "static/fonts/roboto/Roboto-Regular.woff2", - "static/fonts/roboto/Roboto-Bold.woff2", - ], - }); - - for (const warning of workboxManifest.warnings) { - console.warn(warning); - } - - // remove source map and add WB manifest - serviceWorkerContent = sourceMapUrl.removeFrom(serviceWorkerContent); - serviceWorkerContent = serviceWorkerContent.replace( - "WB_MANIFEST", - JSON.stringify(workboxManifest.manifestEntries) - ); - - // Write new file to root - fs.writeFileSync(swDest, serviceWorkerContent); -}); +gulp.task("gen-service-worker-app-prod", () => + Promise.all( + Object.entries(SW_MAP).map(async ([outPath, build]) => { + const manifest = JSON.parse( + await readFile(join(outPath, "manifest.json"), "utf-8") + ); + const swSrc = join(paths.app_output_root, manifest["service-worker.js"]); + const buildDir = relative(paths.app_output_root, outPath); + const { warnings } = await injectManifest({ + swSrc, + swDest: join(paths.app_output_root, `sw-${build}.js`), + injectionPoint: "__WB_MANIFEST__", + // Files that mach this pattern will be considered unique and skip revision check + // ignore JS files + translation files + dontCacheBustURLsMatching: new RegExp( + `(?:${buildDir}/.+|static/translations/.+)` + ), + globDirectory: paths.app_output_root, + globPatterns: [ + `${buildDir}/*.js`, + // Cache all English translations because we catch them as fallback + // Using pattern to match hash instead of * to avoid caching en-GB + // 'v' added as valid hash letter because in dev we hash with 'dev' + "static/translations/**/en-+([a-fv0-9]).json", + // Icon shown on splash screen + "static/icons/favicon-192x192.png", + "static/icons/favicon.ico", + // Common fonts + "static/fonts/roboto/Roboto-Light.woff2", + "static/fonts/roboto/Roboto-Medium.woff2", + "static/fonts/roboto/Roboto-Regular.woff2", + "static/fonts/roboto/Roboto-Bold.woff2", + ], + globIgnores: [`${buildDir}/service-worker*`], + }); + if (warnings.length > 0) { + console.warn( + `Problems while injecting ${build} service worker:\n`, + warnings.join("\n") + ); + } + await deleteAsync(`${swSrc}?(.map)`); + }) + ) +); diff --git a/build-scripts/webpack.cjs b/build-scripts/webpack.cjs index 5ba0f35d23..94ca35b8f5 100644 --- a/build-scripts/webpack.cjs +++ b/build-scripts/webpack.cjs @@ -63,14 +63,19 @@ const createWebpackConfig = ({ rules: [ { test: /\.m?js$|\.ts$/, - use: { + use: (info) => ({ loader: "babel-loader", options: { - ...bundle.babelOptions({ latestBuild, isProdBuild, isTestBuild }), + ...bundle.babelOptions({ + latestBuild, + isProdBuild, + isTestBuild, + sw: info.issuerLayer === "sw", + }), cacheDirectory: !isProdBuild, cacheCompression: false, }, - }, + }), resolve: { fullySpecified: false, }, @@ -235,6 +240,7 @@ const createWebpackConfig = ({ ), }, experiments: { + layers: true, outputModule: true, }, }; diff --git a/cast/src/html/faq.html.template b/cast/src/html/faq.html.template index 80fc487bef..ba22de7284 100644 --- a/cast/src/html/faq.html.template +++ b/cast/src/html/faq.html.template @@ -36,13 +36,7 @@ <%= renderTemplate("../../../src/html/_js_base.html.template") %> - - <%= renderTemplate("../../../src/html/_script_load_es5.html.template") %> + <%= renderTemplate("../../../src/html/_script_loader.html.template") %> - <%= renderTemplate("../../../src/html/_js_base.html.template") %> - - <%= renderTemplate("../../../src/html/_script_load_es5.html.template") %> + <%= renderTemplate("../../../src/html/_js_base.html.template") %> + <%= renderTemplate("../../../src/html/_script_loader.html.template") %> diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index c1b059ebb5..8d2fe2b0e4 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -82,6 +82,8 @@ export class HaDemo extends HomeAssistantAppEl { has_entity_name: false, unique_id: "co2_intensity", options: null, + created_at: 0, + modified_at: 0, }, { config_entry_id: "co2signal", @@ -100,6 +102,8 @@ export class HaDemo extends HomeAssistantAppEl { has_entity_name: false, unique_id: "grid_fossil_fuel_percentage", options: null, + created_at: 0, + modified_at: 0, }, ]); diff --git a/demo/src/html/index.html.template b/demo/src/html/index.html.template index 6b044e44a3..5faafef63d 100644 --- a/demo/src/html/index.html.template +++ b/demo/src/html/index.html.template @@ -69,6 +69,14 @@ #ha-launch-screen .ha-launch-screen-spacer { flex: 1; } + .ohf-logo { + color: grey; + font-size: 12px; + margin-bottom: 16px; + display: flex; + flex-direction: column; + align-items: center; + } @@ -79,19 +87,14 @@
+ <%= renderTemplate("../../../src/html/_js_base.html.template") %> <%= renderTemplate("../../../src/html/_preload_roboto.html.template") %> - - <%= renderTemplate("../../../src/html/_script_load_es5.html.template") %> + <%= renderTemplate("../../../src/html/_script_loader.html.template") %> diff --git a/gallery/public/images/paulus.jpg b/gallery/public/images/paulus.jpg new file mode 100644 index 0000000000..6ffa0fcdaa Binary files /dev/null and b/gallery/public/images/paulus.jpg differ diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index 0e4c9019f6..ccc287fd85 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -15,6 +15,7 @@ import { getEntity } from "../../../../src/fake_data/entity"; import { provideHass } from "../../../../src/fake_data/provide_hass"; import { HomeAssistant } from "../../../../src/types"; import "../../components/demo-black-white-row"; +import { DeviceRegistryEntry } from "../../../../src/data/device_registry"; const ENTITIES = [ getEntity("alarm_control_panel", "alarm", "disarmed", { @@ -41,7 +42,7 @@ const ENTITIES = [ }), ]; -const DEVICES = [ +const DEVICES: DeviceRegistryEntry[] = [ { area_id: "bedroom", configuration_url: null, @@ -53,6 +54,7 @@ const DEVICES = [ identifiers: [["demo", "volume1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: null, name: "Dishwasher", sw_version: null, @@ -60,6 +62,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "backyard", @@ -72,6 +76,7 @@ const DEVICES = [ identifiers: [["demo", "pwm1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: null, name: "Lamp", sw_version: null, @@ -79,6 +84,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, { area_id: null, @@ -91,6 +98,7 @@ const DEVICES = [ identifiers: [["demo", "pwm1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: "User name", name: "Technical name", sw_version: null, @@ -98,6 +106,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, ]; @@ -110,6 +120,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "bedroom", @@ -119,6 +131,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "livingroom", @@ -128,6 +142,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, ]; diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 0fb6d83263..cb3a1f2f49 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -21,6 +21,7 @@ import { FloorRegistryEntry } from "../../../../src/data/floor_registry"; import { LabelRegistryEntry } from "../../../../src/data/label_registry"; import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry"; import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; +import { DeviceRegistryEntry } from "../../../../src/data/device_registry"; const ENTITIES = [ getEntity("alarm_control_panel", "alarm", "disarmed", { @@ -41,7 +42,7 @@ const ENTITIES = [ }), ]; -const DEVICES = [ +const DEVICES: DeviceRegistryEntry[] = [ { area_id: "bedroom", configuration_url: null, @@ -53,6 +54,7 @@ const DEVICES = [ identifiers: [["demo", "volume1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: null, name: "Dishwasher", sw_version: null, @@ -60,6 +62,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "backyard", @@ -72,6 +76,7 @@ const DEVICES = [ identifiers: [["demo", "pwm1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: null, name: "Lamp", sw_version: null, @@ -79,6 +84,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, { area_id: null, @@ -91,6 +98,7 @@ const DEVICES = [ identifiers: [["demo", "pwm1"] as [string, string]], manufacturer: null, model: null, + model_id: null, name_by_user: "User name", name: "Technical name", sw_version: null, @@ -98,6 +106,8 @@ const DEVICES = [ via_device_id: null, serial_number: null, labels: [], + created_at: 0, + modified_at: 0, }, ]; @@ -110,6 +120,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "bedroom", @@ -119,6 +131,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, { area_id: "livingroom", @@ -128,6 +142,8 @@ const AREAS: AreaRegistryEntry[] = [ picture: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, ]; @@ -138,6 +154,8 @@ const FLOORS: FloorRegistryEntry[] = [ level: 0, icon: null, aliases: [], + created_at: 0, + modified_at: 0, }, { floor_id: "first", @@ -145,6 +163,8 @@ const FLOORS: FloorRegistryEntry[] = [ level: 1, icon: "mdi:numeric-1", aliases: [], + created_at: 0, + modified_at: 0, }, { floor_id: "second", @@ -152,6 +172,8 @@ const FLOORS: FloorRegistryEntry[] = [ level: 2, icon: "mdi:numeric-2", aliases: [], + created_at: 0, + modified_at: 0, }, ]; @@ -162,6 +184,8 @@ const LABELS: LabelRegistryEntry[] = [ icon: null, color: "yellow", description: null, + created_at: 0, + modified_at: 0, }, { label_id: "entertainment", @@ -169,6 +193,8 @@ const LABELS: LabelRegistryEntry[] = [ icon: "mdi:popcorn", color: "blue", description: null, + created_at: 0, + modified_at: 0, }, ]; diff --git a/gallery/src/pages/lovelace/entities-card.ts b/gallery/src/pages/lovelace/entities-card.ts index a7131557e6..45842de2a6 100644 --- a/gallery/src/pages/lovelace/entities-card.ts +++ b/gallery/src/pages/lovelace/entities-card.ts @@ -287,11 +287,11 @@ const CONFIGS = [ config: ` - type: entities entities: - - type: call-service + - type: perform-action icon: mdi:power name: Bed light action_name: Toggle light - service: light.toggle + action: light.toggle data: entity_id: light.bed_light - type: section diff --git a/gallery/src/pages/lovelace/picture-card.markdown b/gallery/src/pages/lovelace/picture-card.markdown new file mode 100644 index 0000000000..4c762c101b --- /dev/null +++ b/gallery/src/pages/lovelace/picture-card.markdown @@ -0,0 +1,3 @@ +--- +title: Picture Card +--- diff --git a/gallery/src/pages/lovelace/picture-card.ts b/gallery/src/pages/lovelace/picture-card.ts new file mode 100644 index 0000000000..5ad709e46f --- /dev/null +++ b/gallery/src/pages/lovelace/picture-card.ts @@ -0,0 +1,61 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, query } from "lit/decorators"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { provideHass } from "../../../../src/fake_data/provide_hass"; +import "../../components/demo-cards"; +import { mockIcons } from "../../../../demo/src/stubs/icons"; + +const ENTITIES = [ + getEntity("person", "paulus", "home", { + friendly_name: "Paulus", + entity_picture: "/images/paulus.jpg", + }), +]; + +const CONFIGS = [ + { + heading: "Image URL", + config: ` +- type: picture + image: /images/living_room.png + `, + }, + { + heading: "Person entity", + config: ` +- type: picture + image_entity: person.paulus + `, + }, + { + heading: "Error: Image required", + config: ` +- type: picture + entity: person.paulus + `, + }, +]; + +@customElement("demo-lovelace-picture-card") +class DemoPicture extends LitElement { + @query("#demos") private _demoRoot!: HTMLElement; + + protected render(): TemplateResult { + return html``; + } + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + const hass = provideHass(this._demoRoot); + hass.updateTranslations(null, "en"); + hass.updateTranslations("lovelace", "en"); + hass.addEntities(ENTITIES); + mockIcons(hass); + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-lovelace-picture-card": DemoPicture; + } +} diff --git a/gallery/src/pages/lovelace/picture-elements-card.ts b/gallery/src/pages/lovelace/picture-elements-card.ts index 7f6b0c99cb..117e6c6bee 100644 --- a/gallery/src/pages/lovelace/picture-elements-card.ts +++ b/gallery/src/pages/lovelace/picture-elements-card.ts @@ -25,6 +25,15 @@ const ENTITIES = [ friendly_name: "Movement Backyard", device_class: "motion", }), + getEntity("person", "paulus", "home", { + friendly_name: "Paulus", + entity_picture: "/images/paulus.jpg", + }), + getEntity("sensor", "battery", 35, { + device_class: "battery", + friendly_name: "Battery", + unit_of_measurement: "%", + }), ]; const CONFIGS = [ @@ -123,6 +132,19 @@ const CONFIGS = [ left: 35% `, }, + { + heading: "Person entity", + config: ` +- type: picture-elements + image_entity: person.paulus + elements: + - type: state-icon + entity: sensor.battery + style: + top: 8% + left: 8% + `, + }, ]; @customElement("demo-lovelace-picture-elements-card") diff --git a/gallery/src/pages/lovelace/picture-entity-card.ts b/gallery/src/pages/lovelace/picture-entity-card.ts index 1573f0dbec..d97f417746 100644 --- a/gallery/src/pages/lovelace/picture-entity-card.ts +++ b/gallery/src/pages/lovelace/picture-entity-card.ts @@ -12,6 +12,10 @@ const ENTITIES = [ getEntity("light", "bed_light", "off", { friendly_name: "Bed Light", }), + getEntity("person", "paulus", "home", { + friendly_name: "Paulus", + entity_picture: "/images/paulus.jpg", + }), ]; const CONFIGS = [ @@ -50,6 +54,13 @@ const CONFIGS = [ entity: camera.demo_camera `, }, + { + heading: "Person entity", + config: ` +- type: picture-entity + entity: person.paulus + `, + }, { heading: "Hidden name", config: ` diff --git a/gallery/src/pages/lovelace/picture-glance-card.ts b/gallery/src/pages/lovelace/picture-glance-card.ts index dccc05e09b..91f2e4dca5 100644 --- a/gallery/src/pages/lovelace/picture-glance-card.ts +++ b/gallery/src/pages/lovelace/picture-glance-card.ts @@ -20,6 +20,15 @@ const ENTITIES = [ friendly_name: "Basement Floor Wet", device_class: "moisture", }), + getEntity("person", "paulus", "home", { + friendly_name: "Paulus", + entity_picture: "/images/paulus.jpg", + }), + getEntity("sensor", "battery", 35, { + device_class: "battery", + friendly_name: "Battery", + unit_of_measurement: "%", + }), ]; const CONFIGS = [ @@ -90,6 +99,15 @@ const CONFIGS = [ - light.ceiling_lights `, }, + { + heading: "Person entity", + config: ` +- type: picture-glance + image_entity: person.paulus + entities: + - sensor.battery + `, + }, { heading: "Custom icon", config: ` diff --git a/gallery/src/pages/misc/entity-state.ts b/gallery/src/pages/misc/entity-state.ts index 8d38fe9413..68375c60d8 100644 --- a/gallery/src/pages/misc/entity-state.ts +++ b/gallery/src/pages/misc/entity-state.ts @@ -358,13 +358,11 @@ export class DemoEntityState extends LitElement { }, entity_id: { title: "Entity ID", - width: "30%", filterable: true, sortable: true, }, state: { title: "State", - width: "20%", sortable: true, template: (entry) => html`${computeStateDisplay( @@ -379,14 +377,12 @@ export class DemoEntityState extends LitElement { device_class: { title: "Device class", template: (entry) => html`${entry.device_class ?? "-"}`, - width: "20%", filterable: true, sortable: true, }, domain: { title: "Domain", template: (entry) => html`${computeDomain(entry.entity_id)}`, - width: "20%", filterable: true, sortable: true, }, diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index 72a99c7c28..cab686e210 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -203,6 +203,8 @@ const createEntityRegistryEntries = ( options: null, labels: [], categories: {}, + created_at: 0, + modified_at: 0, }, ]; @@ -215,6 +217,7 @@ const createDeviceRegistryEntries = ( connections: [], manufacturer: "ESPHome", model: "Mock Device", + model_id: "ABC-001", name: "Tag Reader", sw_version: null, hw_version: "1.0.0", @@ -227,6 +230,8 @@ const createDeviceRegistryEntries = ( disabled_by: null, configuration_url: null, labels: [], + created_at: 0, + modified_at: 0, }, ]; diff --git a/hassio/src/backups/hassio-backups.ts b/hassio/src/backups/hassio-backups.ts index 64c735d977..095bd922f4 100644 --- a/hassio/src/backups/hassio-backups.ts +++ b/hassio/src/backups/hassio-backups.ts @@ -127,14 +127,13 @@ export class HassioBackups extends LitElement { main: true, sortable: true, filterable: true, - grows: true, + flex: 2, template: (backup) => html`${backup.name || backup.slug}
${backup.secondary}
`, }, size: { title: this.supervisor.localize("backup.size"), - width: "15%", hidden: narrow, filterable: true, sortable: true, @@ -142,7 +141,6 @@ export class HassioBackups extends LitElement { }, location: { title: this.supervisor.localize("backup.location"), - width: "15%", hidden: narrow, filterable: true, sortable: true, @@ -151,7 +149,6 @@ export class HassioBackups extends LitElement { }, date: { title: this.supervisor.localize("backup.created"), - width: "15%", direction: "desc", hidden: narrow, filterable: true, diff --git a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts index 47f107a6c5..f61dcf8070 100644 --- a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts +++ b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts @@ -66,7 +66,8 @@ class HassioRepositoriesDialog extends LitElement { repo.slug !== "core" && // The core add-ons repository repo.slug !== "local" && // Locally managed add-ons repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons - repo.slug !== "5c53de3b" // The ESPHome repository + repo.slug !== "5c53de3b" && // The ESPHome repository + repo.slug !== "d5369777" // Music Assistant repository ) .sort((a, b) => caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language) diff --git a/hassio/src/entrypoint.js.template b/hassio/src/entrypoint.js.template index c1c4e5831c..8cc7ba82fd 100644 --- a/hassio/src/entrypoint.js.template +++ b/hassio/src/entrypoint.js.template @@ -4,11 +4,7 @@ el.src = src; document.body.appendChild(el); } - if (/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent)) { - <% for (const entry of es5EntryJS) { %> - loadES5("<%= entry %>"); - <% } %> - } else { + if (<%= modernRegex %>.test(navigator.userAgent)) { try { <% for (const entry of latestEntryJS) { %> new Function("import('<%= entry %>')")(); @@ -17,6 +13,10 @@ <% for (const entry of es5EntryJS) { %> loadES5("<%= entry %>"); <% } %> + } else { + <% for (const entry of es5EntryJS) { %> + loadES5("<%= entry %>"); + <% } %> } } })(); diff --git a/package.json b/package.json index 747e697699..905bac737b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"", "lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit", "format": "yarn run format:eslint && yarn run format:prettier", - "postinstall": "husky install", + "postinstall": "husky", "prepack": "pinst --disable", "postpack": "pinst --enable", "test": "instant-mocha --webpack-config ./test/webpack.config.js --require ./test/setup.cjs \"test/**/*.ts\"" @@ -25,15 +25,15 @@ "license": "Apache-2.0", "type": "module", "dependencies": { - "@babel/runtime": "7.24.7", - "@braintree/sanitize-url": "7.0.4", + "@babel/runtime": "7.25.0", + "@braintree/sanitize-url": "7.1.0", "@codemirror/autocomplete": "6.17.0", "@codemirror/commands": "6.6.0", "@codemirror/language": "6.10.2", "@codemirror/legacy-modes": "6.4.0", "@codemirror/search": "6.5.6", "@codemirror/state": "6.4.1", - "@codemirror/view": "6.28.4", + "@codemirror/view": "6.29.0", "@egjs/hammerjs": "2.0.17", "@formatjs/intl-datetimeformat": "6.12.5", "@formatjs/intl-displaynames": "6.6.8", @@ -43,12 +43,12 @@ "@formatjs/intl-numberformat": "8.10.3", "@formatjs/intl-pluralrules": "5.2.14", "@formatjs/intl-relativetimeformat": "11.2.14", - "@fullcalendar/core": "6.1.11", - "@fullcalendar/daygrid": "6.1.11", - "@fullcalendar/interaction": "6.1.11", - "@fullcalendar/list": "6.1.11", - "@fullcalendar/luxon3": "6.1.11", - "@fullcalendar/timegrid": "6.1.11", + "@fullcalendar/core": "6.1.15", + "@fullcalendar/daygrid": "6.1.15", + "@fullcalendar/interaction": "6.1.15", + "@fullcalendar/list": "6.1.15", + "@fullcalendar/luxon3": "6.1.15", + "@fullcalendar/timegrid": "6.1.15", "@lezer/highlight": "1.2.0", "@lit-labs/context": "0.4.1", "@lit-labs/motion": "1.0.7", @@ -80,7 +80,7 @@ "@material/mwc-top-app-bar": "0.27.0", "@material/mwc-top-app-bar-fixed": "0.27.0", "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0", - "@material/web": "1.5.1", + "@material/web": "2.0.0", "@mdi/js": "7.4.47", "@mdi/svg": "7.4.47", "@polymer/paper-item": "3.0.1", @@ -88,8 +88,8 @@ "@polymer/paper-tabs": "3.1.0", "@polymer/polymer": "3.5.1", "@thomasloven/round-slider": "0.6.0", - "@vaadin/combo-box": "24.4.1", - "@vaadin/vaadin-themable-mixin": "24.4.1", + "@vaadin/combo-box": "24.4.4", + "@vaadin/vaadin-themable-mixin": "24.4.4", "@vibrant/color": "3.2.1-alpha.1", "@vibrant/core": "3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "3.2.1-alpha.1", @@ -129,7 +129,7 @@ "rrule": "2.8.1", "sortablejs": "1.15.2", "stacktrace-js": "2.0.2", - "superstruct": "1.0.4", + "superstruct": "2.0.2", "tinykeys": "2.1.0", "tsparticles-engine": "2.12.0", "tsparticles-preset-links": "2.12.0", @@ -149,18 +149,18 @@ "xss": "1.0.15" }, "devDependencies": { - "@babel/core": "7.24.7", + "@babel/core": "7.24.9", "@babel/helper-define-polyfill-provider": "0.6.2", "@babel/plugin-proposal-decorators": "7.24.7", "@babel/plugin-transform-runtime": "7.24.7", - "@babel/preset-env": "7.24.7", + "@babel/preset-env": "7.25.0", "@babel/preset-typescript": "7.24.7", - "@bundle-stats/plugin-webpack-filter": "4.13.3", + "@bundle-stats/plugin-webpack-filter": "4.13.4", "@koa/cors": "5.0.0", - "@lokalise/node-api": "12.6.0", + "@lokalise/node-api": "12.7.0", "@octokit/auth-oauth-device": "7.1.1", "@octokit/plugin-retry": "7.1.1", - "@octokit/rest": "21.0.0", + "@octokit/rest": "21.0.1", "@open-wc/dev-server-hmr": "0.1.4", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "26.0.1", @@ -185,12 +185,13 @@ "@types/tar": "6.1.13", "@types/ua-parser-js": "0.7.39", "@types/webspeechapi": "0.0.29", - "@typescript-eslint/eslint-plugin": "7.15.0", - "@typescript-eslint/parser": "7.15.0", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", "@web/dev-server": "0.1.38", "@web/dev-server-rollup": "0.4.1", "babel-loader": "9.1.3", "babel-plugin-template-html-minifier": "4.1.0", + "browserslist-useragent-regexp": "4.1.3", "chai": "5.1.1", "del": "7.1.0", "eslint": "8.57.0", @@ -200,18 +201,19 @@ "eslint-import-resolver-webpack": "0.13.8", "eslint-plugin-import": "2.29.1", "eslint-plugin-lit": "1.14.0", - "eslint-plugin-lit-a11y": "4.1.3", - "eslint-plugin-unused-imports": "4.0.0", + "eslint-plugin-lit-a11y": "4.1.4", + "eslint-plugin-unused-imports": "4.0.1", "eslint-plugin-wc": "2.1.0", "fancy-log": "2.0.0", "fs-extra": "11.2.0", - "glob": "10.4.3", + "glob": "11.0.0", "gulp": "5.0.0", + "gulp-brotli": "3.0.0", "gulp-json-transform": "0.5.0", "gulp-rename": "2.0.0", - "gulp-zopfli-green": "6.0.1", + "gulp-zopfli-green": "6.0.2", "html-minifier-terser": "7.2.0", - "husky": "9.0.11", + "husky": "9.1.3", "instant-mocha": "1.5.2", "jszip": "3.10.1", "lint-staged": "15.2.7", @@ -224,27 +226,26 @@ "object-hash": "3.0.0", "open": "10.1.0", "pinst": "3.0.0", - "prettier": "3.3.2", + "prettier": "3.3.3", "rollup": "2.79.1", "rollup-plugin-string": "3.0.0", "rollup-plugin-terser": "7.0.2", "rollup-plugin-visualizer": "5.12.0", "serve-handler": "6.1.5", "sinon": "18.0.0", - "source-map-url": "0.4.1", "systemjs": "6.15.1", - "tar": "7.4.0", + "tar": "7.4.3", "terser-webpack-plugin": "5.3.10", "transform-async-modules-webpack-plugin": "1.1.1", "ts-lit-plugin": "2.0.2", - "typescript": "5.5.3", - "webpack": "5.92.1", + "typescript": "5.5.4", + "webpack": "5.93.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-manifest-plugin": "5.0.0", "webpack-stats-plugin": "1.1.3", "webpackbar": "6.0.1", - "workbox-build": "7.1.1" + "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" }, "_comment": "Polymer 3.2 contained a bug, fixed in https://github.com/Polymer/polymer/pull/5569, add as patch", "resolutions": { @@ -253,7 +254,7 @@ "lit": "2.8.0", "clean-css": "5.3.3", "@lit/reactive-element": "1.6.3", - "@fullcalendar/daygrid": "6.1.11", + "@fullcalendar/daygrid": "6.1.15", "sortablejs@1.15.2": "patch:sortablejs@npm%3A1.15.2#~/.yarn/patches/sortablejs-npm-1.15.2-73347ae85a.patch", "leaflet-draw@1.0.4": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch" }, diff --git a/public/static/icons/ohf.svg b/public/static/icons/ohf.svg new file mode 100644 index 0000000000..08d81d42ae --- /dev/null +++ b/public/static/icons/ohf.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 91ae7718f8..9f8d6c3fe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20240710.0" +version = "20240731.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/common/navigate.ts b/src/common/navigate.ts index 86c61173fb..53a9497bd6 100644 --- a/src/common/navigate.ts +++ b/src/common/navigate.ts @@ -25,7 +25,9 @@ export const navigate = (path: string, options?: NavigateOptions) => { if (__DEMO__) { if (replace) { mainWindow.history.replaceState( - mainWindow.history.state?.root ? { root: true } : options?.data ?? null, + mainWindow.history.state?.root + ? { root: true } + : (options?.data ?? null), "", `${mainWindow.location.pathname}#${path}` ); @@ -34,7 +36,7 @@ export const navigate = (path: string, options?: NavigateOptions) => { } } else if (replace) { mainWindow.history.replaceState( - mainWindow.history.state?.root ? { root: true } : options?.data ?? null, + mainWindow.history.state?.root ? { root: true } : (options?.data ?? null), "", path ); diff --git a/src/components/buttons/ha-call-service-button.ts b/src/components/buttons/ha-call-service-button.ts index 3cea93de0d..3fc835e14a 100644 --- a/src/components/buttons/ha-call-service-button.ts +++ b/src/components/buttons/ha-call-service-button.ts @@ -1,5 +1,6 @@ import { LitElement, TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators"; +import { HassServiceTarget } from "home-assistant-js-websocket"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import "./ha-progress-button"; import { HomeAssistant } from "../../types"; @@ -17,7 +18,9 @@ class HaCallServiceButton extends LitElement { @property() public service!: string; - @property({ type: Object }) public serviceData = {}; + @property({ type: Object }) public target!: HassServiceTarget; + + @property({ type: Object }) public data = {}; @property() public confirmation?; @@ -39,7 +42,8 @@ class HaCallServiceButton extends LitElement { const eventData = { domain: this.domain, service: this.service, - serviceData: this.serviceData, + data: this.data, + target: this.target, success: false, }; @@ -47,7 +51,12 @@ class HaCallServiceButton extends LitElement { this.shadowRoot!.querySelector("ha-progress-button")!; try { - await this.hass.callService(this.domain, this.service, this.serviceData); + await this.hass.callService( + this.domain, + this.service, + this.data, + this.target + ); this.progress = false; progressElement.actionSuccess(); eventData.success = true; @@ -85,7 +94,8 @@ declare global { "hass-service-called": { domain: string; service: string; - serviceData: object; + target: HassServiceTarget; + data: object; success: boolean; }; } diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index c70711ae64..0129ef5ca8 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -159,10 +159,10 @@ export class StateHistoryChartTimeline extends LitElement { }, afterUpdate: (y) => { const yWidth = this.showNames - ? y.width ?? 0 + ? (y.width ?? 0) : computeRTL(this.hass) ? 0 - : y.left ?? 0; + : (y.left ?? 0); if ( this._yWidth !== Math.floor(yWidth) && y.ticks.length === this.data.length diff --git a/src/components/data-table/dialog-data-table-settings.ts b/src/components/data-table/dialog-data-table-settings.ts index dd8a6f2888..ef751acc4a 100644 --- a/src/components/data-table/dialog-data-table-settings.ts +++ b/src/components/data-table/dialog-data-table-settings.ts @@ -109,7 +109,8 @@ export class DialogDataTableSettings extends LitElement { const canHide = !col.main && col.hideable !== false; const isVisible = !(this._columnOrder && this._columnOrder.includes(col.key) - ? this._hiddenColumns?.includes(col.key) ?? col.defaultHidden + ? (this._hiddenColumns?.includes(col.key) ?? + col.defaultHidden) : col.defaultHidden); return html` col.defaultHidden) .map(([key]) => key)), ]; + if (wasHidden && hidden.includes(column)) { hidden.splice(hidden.indexOf(column), 1); } else if (!wasHidden) { @@ -242,7 +244,11 @@ export class DialogDataTableSettings extends LitElement { newOrder.splice(lastMoveable + 1, 0, col.key); } - if (col.defaultHidden) { + if ( + col.key !== column && + col.defaultHidden && + !hidden.includes(col.key) + ) { hidden.push(col.key); } } diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index 1819256bab..60faa4dcaf 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -85,9 +85,9 @@ export interface DataTableColumnData extends DataTableSortColumnData { | "flex"; template?: (row: T) => TemplateResult | string | typeof nothing; extraTemplate?: (row: T) => TemplateResult | string | typeof nothing; - width?: string; + minWidth?: string; maxWidth?: string; - grows?: boolean; + flex?: number; forceLTR?: boolean; hidden?: boolean; } @@ -216,6 +216,18 @@ export class HaDataTable extends LitElement { this.updateComplete.then(() => this._calcTableHeight()); } + protected updated() { + const header = this.renderRoot.querySelector(".mdc-data-table__header-row"); + if (!header) { + return; + } + if (header.scrollWidth > header.clientWidth) { + this.style.setProperty("--table-row-width", `${header.scrollWidth}px`); + } else { + this.style.removeProperty("--table-row-width"); + } + } + public willUpdate(properties: PropertyValues) { super.willUpdate(properties); @@ -355,7 +367,12 @@ export class HaDataTable extends LitElement { : `calc(100% - ${this._headerHeight}px)`, })} > -
+
${this.selectable ? html` @@ -379,7 +396,8 @@ export class HaDataTable extends LitElement { if ( column.hidden || (this.columnOrder && this.columnOrder.includes(key) - ? this.hiddenColumns?.includes(key) ?? column.defaultHidden + ? (this.hiddenColumns?.includes(key) ?? + column.defaultHidden) : column.defaultHidden) ) { return nothing; @@ -397,18 +415,16 @@ export class HaDataTable extends LitElement { column.type === "overflow", sortable: Boolean(column.sortable), "not-sorted": Boolean(column.sortable && !sorted), - grows: Boolean(column.grows), }; return html`
${column.template ? column.template(row) @@ -560,8 +574,8 @@ export class HaDataTable extends LitElement { !column2.showNarrow && !(this.columnOrder && this.columnOrder.includes(key2) - ? this.hiddenColumns?.includes(key2) ?? - column2.defaultHidden + ? (this.hiddenColumns?.includes(key2) ?? + column2.defaultHidden) : column2.defaultHidden) ) .map( @@ -596,7 +610,7 @@ export class HaDataTable extends LitElement { filteredData = await this._memFilterData( this.data, this._sortColumns, - this._filter + this._filter.trim() ); } @@ -814,6 +828,17 @@ export class HaDataTable extends LitElement { @eventOptions({ passive: true }) private _saveScrollPos(e: Event) { this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; + + this.renderRoot.querySelector(".mdc-data-table__header-row")!.scrollLeft = ( + e.target as HTMLDivElement + ).scrollLeft; + } + + @eventOptions({ passive: true }) + private _scrollContent(e: Event) { + this.renderRoot.querySelector("lit-virtualizer")!.scrollLeft = ( + e.target as HTMLDivElement + ).scrollLeft; } private _collapseGroup = (ev: Event) => { @@ -888,8 +913,8 @@ export class HaDataTable extends LitElement { .mdc-data-table__row { display: flex; - width: 100%; height: var(--data-table-row-height, 52px); + width: var(--table-row-width, 100%); } .mdc-data-table__row ~ .mdc-data-table__row { @@ -913,18 +938,26 @@ export class HaDataTable extends LitElement { .mdc-data-table__header-row { height: 56px; display: flex; - width: 100%; border-bottom: 1px solid var(--divider-color); + overflow: auto; } + /* Hide scrollbar for Chrome, Safari and Opera */ .mdc-data-table__header-row::-webkit-scrollbar { display: none; } + /* Hide scrollbar for IE, Edge and Firefox */ + .mdc-data-table__header-row { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + .mdc-data-table__cell, .mdc-data-table__header-cell { padding-right: 16px; padding-left: 16px; + min-width: 150px; align-self: center; overflow: hidden; text-overflow: ellipsis; @@ -972,6 +1005,8 @@ export class HaDataTable extends LitElement { letter-spacing: 0.0178571429em; text-decoration: inherit; text-transform: inherit; + flex-grow: 0; + flex-shrink: 0; } .mdc-data-table__cell a { @@ -990,7 +1025,8 @@ export class HaDataTable extends LitElement { .mdc-data-table__header-cell--icon, .mdc-data-table__cell--icon { - width: 54px; + min-width: 64px; + flex: 0 0 64px !important; } .mdc-data-table__cell--icon img { @@ -1030,11 +1066,14 @@ export class HaDataTable extends LitElement { .mdc-data-table__header-cell--overflow-menu, .mdc-data-table__header-cell--icon-button, .mdc-data-table__cell--icon-button { + min-width: 64px; + flex: 0 0 64px !important; padding: 8px; } .mdc-data-table__header-cell--icon-button, .mdc-data-table__cell--icon-button { + min-width: 56px; width: 56px; } diff --git a/src/components/entity/ha-entity-state-content-picker.ts b/src/components/entity/ha-entity-state-content-picker.ts new file mode 100644 index 0000000000..b4e1001932 --- /dev/null +++ b/src/components/entity/ha-entity-state-content-picker.ts @@ -0,0 +1,315 @@ +import { mdiDrag } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { LitElement, PropertyValues, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { + STATE_DISPLAY_SPECIAL_CONTENT, + STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS, +} from "../../state-display/state-display"; +import { HomeAssistant, ValueChangedEvent } from "../../types"; +import "../ha-combo-box"; +import type { HaComboBox } from "../ha-combo-box"; + +const HIDDEN_ATTRIBUTES = [ + "access_token", + "available_modes", + "battery_icon", + "battery_level", + "code_arm_required", + "code_format", + "color_modes", + "device_class", + "editable", + "effect_list", + "entity_id", + "entity_picture", + "event_types", + "fan_modes", + "fan_speed_list", + "friendly_name", + "frontend_stream_type", + "has_date", + "has_time", + "hvac_modes", + "icon", + "id", + "max_color_temp_kelvin", + "max_mireds", + "max_temp", + "max", + "min_color_temp_kelvin", + "min_mireds", + "min_temp", + "min", + "mode", + "operation_list", + "options", + "percentage_step", + "precipitation_unit", + "preset_modes", + "pressure_unit", + "remaining", + "sound_mode_list", + "source_list", + "state_class", + "step", + "supported_color_modes", + "supported_features", + "swing_modes", + "target_temp_step", + "temperature_unit", + "token", + "unit_of_measurement", + "visibility_unit", + "wind_speed_unit", +]; + +@customElement("ha-entity-state-content-picker") +class HaEntityStatePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public entityId?: string; + + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property() public label?: string; + + @property() public value?: string[] | string; + + @property() public helper?: string; + + @state() private _opened = false; + + @query("ha-combo-box", true) private _comboBox!: HaComboBox; + + protected shouldUpdate(changedProps: PropertyValues) { + return !(!changedProps.has("_opened") && this._opened); + } + + private options = memoizeOne((entityId?: string, stateObj?: HassEntity) => { + const domain = entityId ? computeDomain(entityId) : undefined; + return [ + { + label: this.hass.localize("ui.components.state-content-picker.state"), + value: "state", + }, + { + label: this.hass.localize( + "ui.components.state-content-picker.last_changed" + ), + value: "last_changed", + }, + { + label: this.hass.localize( + "ui.components.state-content-picker.last_updated" + ), + value: "last_updated", + }, + ...(domain + ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => + STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) + ).map((content) => ({ + label: this.hass.localize( + `ui.components.state-content-picker.${content}` + ), + value: content, + })) + : []), + ...Object.keys(stateObj?.attributes ?? {}) + .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) + .map((attribute) => ({ + value: attribute, + label: this.hass.formatEntityAttributeName(stateObj!, attribute), + })), + ]; + }); + + private _filter = ""; + + protected render() { + if (!this.hass) { + return nothing; + } + + const value = this._value; + + const stateObj = this.entityId + ? this.hass.states[this.entityId] + : undefined; + + const options = this.options(this.entityId, stateObj); + const optionItems = options.filter( + (option) => !this._value.includes(option.value) + ); + + return html` + ${value?.length + ? html` + + + ${repeat( + this._value, + (item) => item, + (item, idx) => { + const label = + options.find((option) => option.value === item)?.label || + item; + return html` + + + + ${label} + + `; + } + )} + + + ` + : nothing} + + + `; + } + + private get _value() { + return !this.value ? [] : ensureArray(this.value); + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + this._comboBox.filteredItems = this._comboBox.items; + } + + private _filterChanged(ev?: CustomEvent): void { + this._filter = ev?.detail.value || ""; + + const filteredItems = this._comboBox.items?.filter((item) => { + const label = item.label || item.value; + return label.toLowerCase().includes(this._filter?.toLowerCase()); + }); + + if (this._filter) { + filteredItems?.unshift({ label: this._filter, value: this._filter }); + } + + this._comboBox.filteredItems = filteredItems; + } + + private async _moveItem(ev: CustomEvent) { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + const value = this._value; + const newValue = value.concat(); + const element = newValue.splice(oldIndex, 1)[0]; + newValue.splice(newIndex, 0, element); + this._setValue(newValue); + await this.updateComplete; + this._filterChanged(); + } + + private async _removeItem(ev) { + ev.stopPropagation(); + const value: string[] = [...this._value]; + value.splice(ev.target.idx, 1); + this._setValue(value); + await this.updateComplete; + this._filterChanged(); + } + + private _comboBoxValueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const newValue = ev.detail.value; + + if (this.disabled || newValue === "") { + return; + } + + const currentValue = this._value; + + if (currentValue.includes(newValue)) { + return; + } + + setTimeout(() => { + this._filterChanged(); + this._comboBox.setInputValue(""); + }, 0); + + this._setValue([...currentValue, newValue]); + } + + private _setValue(value: string[]) { + const newValue = + value.length === 0 ? undefined : value.length === 1 ? value[0] : value; + this.value = newValue; + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + static styles = css` + :host { + position: relative; + } + + ha-chip-set { + padding: 8px 0; + } + + .sortable-fallback { + display: none; + opacity: 0; + } + + .sortable-ghost { + opacity: 0.4; + } + + .sortable-drag { + cursor: grabbing; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-entity-state-content-picker": HaEntityStatePicker; + } +} diff --git a/src/components/entity/ha-state-label-badge.ts b/src/components/entity/ha-state-label-badge.ts index 91a90673e8..b4d1187522 100644 --- a/src/components/entity/ha-state-label-badge.ts +++ b/src/components/entity/ha-state-label-badge.ts @@ -134,7 +134,7 @@ export class HaStateLabelBadge extends LitElement { this._timerTimeRemaining )} .description=${this.showName - ? this.name ?? computeStateName(entityState) + ? (this.name ?? computeStateName(entityState)) : undefined} > ${!image && showIcon diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 5fe9b63d5e..9001e71eb2 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -279,6 +279,8 @@ export class HaAreaPicker extends LitElement { icon: null, aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, ]; } @@ -295,6 +297,8 @@ export class HaAreaPicker extends LitElement { icon: "mdi:plus", aliases: [], labels: [], + created_at: 0, + modified_at: 0, }, ]; } @@ -377,6 +381,8 @@ export class HaAreaPicker extends LitElement { picture: null, labels: [], aliases: [], + created_at: 0, + modified_at: 0, }, ] as AreaRegistryEntry[]; } else { @@ -393,6 +399,8 @@ export class HaAreaPicker extends LitElement { picture: null, labels: [], aliases: [], + created_at: 0, + modified_at: 0, }, ] as AreaRegistryEntry[]; } diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts index 54490e047b..71870d2749 100644 --- a/src/components/ha-base-time-input.ts +++ b/src/components/ha-base-time-input.ts @@ -1,10 +1,12 @@ import "@material/mwc-list/mwc-list-item"; -import { css, html, LitElement, TemplateResult } from "lit"; +import { css, html, LitElement, TemplateResult, nothing } from "lit"; import { customElement, property } from "lit/decorators"; +import { mdiClose } from "@mdi/js"; import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; import "./ha-select"; +import "./ha-icon-button"; import { HaTextField } from "./ha-textfield"; import "./ha-input-helper-text"; @@ -124,116 +126,128 @@ export class HaBaseTimeInput extends LitElement { */ @property() amPm: "AM" | "PM" = "AM"; + @property({ type: Boolean, reflect: true }) public clearable?: boolean; + protected render(): TemplateResult { return html` ${this.label ? html`` : ""} -
- ${this.enableDay - ? html` - +
+ ${this.enableDay + ? html` + + + ` + : ""} + + + + + + ${this.enableSecond + ? html` - - ` - : ""} + ` + : ""} + ${this.enableMillisecond + ? html` + ` + : ""} + ${this.clearable && !this.required && !this.disabled + ? html`` + : nothing} +
- - - - - ${this.enableSecond - ? html` - ` - : ""} - ${this.enableMillisecond - ? html` - ` - : ""} ${this.format === 24 ? "" : html`AM PM `} + ${this.helper + ? html`${this.helper}` + : ""}
- ${this.helper - ? html`${this.helper}` - : ""} `; } + private _clearValue(): void { + fireEvent(this, "value-changed"); + } + private _valueChanged(ev: InputEvent) { const textField = ev.currentTarget as HaTextField; this[textField.name] = @@ -302,18 +320,25 @@ export class HaBaseTimeInput extends LitElement { } static styles = css` + :host([clearable]) { + position: relative; + } :host { display: block; } + .time-input-wrap-wrap { + display: flex; + } .time-input-wrap { display: flex; border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0; overflow: hidden; position: relative; direction: ltr; + padding-right: 3px; } ha-textfield { - width: 40px; + width: 55px; text-align: center; --mdc-shape-small: 0; --text-field-appearance: none; @@ -335,6 +360,21 @@ export class HaBaseTimeInput extends LitElement { --mdc-shape-small: 0; width: 85px; } + :host([clearable]) .mdc-select__anchor { + padding-inline-end: var(--select-selected-text-padding-end, 12px); + } + ha-icon-button { + position: relative + --mdc-icon-button-size: 36px; + --mdc-icon-size: 20px; + color: var(--secondary-text-color); + direction: var(--direction); + display: flex; + align-items: center; + background-color:var(--mdc-text-field-fill-color, whitesmoke); + border-bottom-style: solid; + border-bottom-width: 1px; + } label { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index c810292677..d790ec1a46 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -295,6 +295,8 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { icon: null, level: null, aliases: [], + created_at: 0, + modified_at: 0, }, ]; } @@ -309,6 +311,8 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { icon: "mdi:plus", level: null, aliases: [], + created_at: 0, + modified_at: 0, }, ]; } @@ -391,6 +395,8 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { icon: null, level: null, aliases: [], + created_at: 0, + modified_at: 0, }, ] as FloorRegistryEntry[]; } else { @@ -405,6 +411,8 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { icon: "mdi:plus", level: null, aliases: [], + created_at: 0, + modified_at: 0, }, ] as FloorRegistryEntry[]; } diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 68f5f9cbad..3d9571caa7 100644 --- a/src/components/ha-form/compute-initial-ha-form-data.ts +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -94,6 +94,8 @@ export const computeInitialHaFormData = ( data[field.name] = selector.color_temp?.min_mireds ?? 153; } else if ( "action" in selector || + "trigger" in selector || + "condition" in selector || "media" in selector || "target" in selector ) { diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts index 6ae6b7b37d..f62fc5c01d 100644 --- a/src/components/ha-grid-size-picker.ts +++ b/src/components/ha-grid-size-picker.ts @@ -20,7 +20,7 @@ export class HaGridSizeEditor extends LitElement { @property({ attribute: false }) public value?: GridSizeValue; - @property({ attribute: false }) public rows = 6; + @property({ attribute: false }) public rows = 8; @property({ attribute: false }) public columns = 4; @@ -205,7 +205,7 @@ export class HaGridSizeEditor extends LitElement { .preview { position: relative; grid-area: preview; - aspect-ratio: 1 / 1; + aspect-ratio: 1 / 1.2; } .preview > div { position: absolute; diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index fd0239d176..dba720d94b 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -303,6 +303,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { icon: null, color: null, description: null, + created_at: 0, + modified_at: 0, }, ]; } @@ -317,6 +319,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { icon: "mdi:plus", color: null, description: null, + created_at: 0, + modified_at: 0, }, ]; } diff --git a/src/components/ha-related-items.ts b/src/components/ha-related-items.ts index be59a28b88..2b4014337a 100644 --- a/src/components/ha-related-items.ts +++ b/src/components/ha-related-items.ts @@ -6,12 +6,12 @@ import { mdiSofa, } from "@mdi/js"; import { - css, CSSResultGroup, - html, LitElement, - nothing, PropertyValues, + css, + html, + nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; @@ -20,7 +20,7 @@ import { fireEvent } from "../common/dom/fire_event"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { Blueprints, fetchBlueprints } from "../data/blueprint"; import { ConfigEntry, getConfigEntries } from "../data/config_entries"; -import { findRelated, ItemType, RelatedResult } from "../data/search"; +import { ItemType, RelatedResult, findRelated } from "../data/search"; import { haStyle } from "../resources/styles"; import { HomeAssistant } from "../types"; import { brandsUrl } from "../util/brands-url"; @@ -109,6 +109,26 @@ export class HaRelatedItems extends LitElement { ) ); + private _getConfigEntries = memoizeOne( + ( + relatedConfigEntries: string[] | undefined, + entries: ConfigEntry[] | undefined + ) => { + const configEntries = + relatedConfigEntries && entries + ? relatedConfigEntries.map((entryId) => + entries!.find((configEntry) => configEntry.entry_id === entryId) + ) + : undefined; + + const configEntryDomains = new Set( + configEntries?.map((entry) => entry?.domain) + ); + + return { configEntries, configEntryDomains }; + } + ); + protected render() { if (!this._related) { return nothing; @@ -128,22 +148,25 @@ export class HaRelatedItems extends LitElement { `; } + + const { configEntries, configEntryDomains } = this._getConfigEntries( + this._related.config_entry, + this._entries + ); + return html` - ${this._related.config_entry && this._entries + ${configEntries || this._related.integration ? html`

${this.hass.localize("ui.components.related-items.integration")}

${this._related.config_entry.map((relatedConfigEntryId) => { - const entry: ConfigEntry | undefined = this._entries!.find( - (configEntry) => configEntry.entry_id === relatedConfigEntryId - ); + >${configEntries?.map((entry) => { if (!entry) { return nothing; } return html` @@ -164,8 +187,34 @@ export class HaRelatedItems extends LitElement { `; - })}` + })} + ${this._related.integration + ?.filter((integration) => !configEntryDomains.has(integration)) + .map( + (integration) => + html` + + ${integration} + ${this.hass.localize(`component.${integration}.title`)} + + + ` + )} + ` : nothing} ${this._related.device ? html`

diff --git a/src/components/ha-selector/ha-selector-boolean.ts b/src/components/ha-selector/ha-selector-boolean.ts index a6e0584f07..18ce85b912 100644 --- a/src/components/ha-selector/ha-selector-boolean.ts +++ b/src/components/ha-selector/ha-selector-boolean.ts @@ -12,6 +12,8 @@ export class HaBooleanSelector extends LitElement { @property({ type: Boolean }) public value = false; + @property() public placeholder?: any; + @property() public label?: string; @property() public helper?: string; @@ -22,7 +24,7 @@ export class HaBooleanSelector extends LitElement { return html` diff --git a/src/components/ha-selector/ha-selector-duration.ts b/src/components/ha-selector/ha-selector-duration.ts index cbf60b4f66..66f2e8d50d 100644 --- a/src/components/ha-selector/ha-selector-duration.ts +++ b/src/components/ha-selector/ha-selector-duration.ts @@ -30,6 +30,7 @@ export class HaTimeDuration extends LitElement { .disabled=${this.disabled} .required=${this.required} ?enableDay=${this.selector.duration?.enable_day} + ?enableMillisecond=${this.selector.duration?.enable_millisecond} > `; } diff --git a/src/components/ha-selector/ha-selector-selector.ts b/src/components/ha-selector/ha-selector-selector.ts index 831fe05f73..2602fc675d 100644 --- a/src/components/ha-selector/ha-selector-selector.ts +++ b/src/components/ha-selector/ha-selector-selector.ts @@ -57,6 +57,10 @@ const SELECTOR_SCHEMAS = { name: "enable_day", selector: { boolean: {} }, }, + { + name: "enable_millisecond", + selector: { boolean: {} }, + }, ] as const, entity: [ { diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index ceb8d9fe93..bacba114fc 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -27,6 +27,7 @@ export class HaTimeSelector extends LitElement { .locale=${this.hass.locale} .disabled=${this.disabled} .required=${this.required} + clearable .helper=${this.helper} .label=${this.label} enable-second diff --git a/src/components/ha-selector/ha-selector-ui-state-content.ts b/src/components/ha-selector/ha-selector-ui-state-content.ts new file mode 100644 index 0000000000..c0d521b939 --- /dev/null +++ b/src/components/ha-selector/ha-selector-ui-state-content.ts @@ -0,0 +1,48 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { UiStateContentSelector } from "../../data/selector"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../types"; +import "../entity/ha-entity-state-content-picker"; + +@customElement("ha-selector-ui_state_content") +export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: UiStateContentSelector; + + @property() public value?: string | string[]; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @property({ attribute: false }) public context?: { + filter_entity?: string; + }; + + protected render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-ui_state_content": HaSelectorUiStateContent; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 8cab4393b8..11a9136abc 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -57,6 +57,7 @@ const LOAD_ELEMENTS = { color_temp: () => import("./ha-selector-color-temp"), ui_action: () => import("./ha-selector-ui-action"), ui_color: () => import("./ha-selector-ui-color"), + ui_state_content: () => import("./ha-selector-ui-state-content"), }; const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]); diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 47ee865393..744549b254 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -77,7 +77,7 @@ export class HaServiceControl extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public value?: { - service: string; + action: string; target?: HassServiceTarget; data?: Record; }; @@ -112,23 +112,23 @@ export class HaServiceControl extends LitElement { | undefined | this["value"]; - if (oldValue?.service !== this.value?.service) { + if (oldValue?.action !== this.value?.action) { this._checkedKeys = new Set(); } const serviceData = this._getServiceInfo( - this.value?.service, + this.value?.action, this.hass.services ); // Fetch the manifest if we have a service selected and the service domain changed. // If no service is selected, clear the manifest. - if (this.value?.service) { + if (this.value?.action) { if ( - !oldValue?.service || - computeDomain(this.value.service) !== computeDomain(oldValue.service) + !oldValue?.action || + computeDomain(this.value.action) !== computeDomain(oldValue.action) ) { - this._fetchManifest(computeDomain(this.value?.service)); + this._fetchManifest(computeDomain(this.value?.action)); } } else { this._manifest = undefined; @@ -168,7 +168,7 @@ export class HaServiceControl extends LitElement { this._value = this.value; } - if (oldValue?.service !== this.value?.service) { + if (oldValue?.action !== this.value?.action) { let updatedDefaultValue = false; if (this._value && serviceData) { const loadDefaults = this.value && !("data" in this.value); @@ -367,7 +367,7 @@ export class HaServiceControl extends LitElement { protected render() { const serviceData = this._getServiceInfo( - this._value?.service, + this._value?.action, this.hass.services ); @@ -392,11 +392,11 @@ export class HaServiceControl extends LitElement { this._value ); - const domain = this._value?.service - ? computeDomain(this._value.service) + const domain = this._value?.action + ? computeDomain(this._value.action) : undefined; - const serviceName = this._value?.service - ? computeObjectId(this._value.service) + const serviceName = this._value?.action + ? computeObjectId(this._value.action) : undefined; const description = @@ -410,7 +410,7 @@ export class HaServiceControl extends LitElement { ? nothing : html``} @@ -451,7 +451,7 @@ export class HaServiceControl extends LitElement { > ${this.hass.localize( - "ui.components.service-control.target_description" + "ui.components.service-control.target_secondary" )} { - if (!this._value?.service) { + if (!this._value?.action) { return ""; } return this.hass.localize( - `component.${computeDomain(this._value.service)}.selector.${key}` + `component.${computeDomain(this._value.action)}.selector.${key}` ); }; @@ -610,7 +612,7 @@ export class HaServiceControl extends LitElement { if (checked) { this._checkedKeys.add(key); const field = this._getServiceInfo( - this._value?.service, + this._value?.action, this.hass.services )?.fields.find((_field) => _field.key === key); @@ -656,7 +658,7 @@ export class HaServiceControl extends LitElement { private _serviceChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - if (ev.detail.value === this._value?.service) { + if (ev.detail.value === this._value?.action) { return; } @@ -715,7 +717,7 @@ export class HaServiceControl extends LitElement { } const value = { - service: newService, + action: newService, target, }; diff --git a/src/components/ha-service-picker.ts b/src/components/ha-service-picker.ts index c27c039a8e..72cd6c2d21 100644 --- a/src/components/ha-service-picker.ts +++ b/src/components/ha-service-picker.ts @@ -46,7 +46,7 @@ class HaServicePicker extends LitElement { return html` { - this._notifications = notifications; - }); + this.subscribePersistentNotifications(); + } + + private subscribePersistentNotifications(): void { + if (this._unsubPersistentNotifications) { + this._unsubPersistentNotifications(); + } + this._unsubPersistentNotifications = subscribeNotifications( + this.hass.connection, + (notifications) => { + this._notifications = notifications; + } + ); } protected updated(changedProps) { @@ -306,6 +319,14 @@ class HaSidebar extends SubscribeMixin(LitElement) { return; } + if ( + this.hass && + changedProps.get("hass")?.connected === false && + this.hass.connected === true + ) { + this.subscribePersistentNotifications(); + } + this._calculateCounts(); if (!SUPPORT_SCROLL_IF_NEEDED) { diff --git a/src/components/ha-slider.ts b/src/components/ha-slider.ts index 8bf5a098e5..33a1fbb16d 100644 --- a/src/components/ha-slider.ts +++ b/src/components/ha-slider.ts @@ -15,6 +15,7 @@ export class HaSlider extends MdSlider { css` :host { --md-sys-color-primary: var(--primary-color); + --md-sys-color-on-primary: var(--text-primary-color); --md-sys-color-outline: var(--outline-color); --md-sys-color-on-surface: var(--primary-text-color); --md-slider-handle-width: 14px; diff --git a/src/components/ha-time-input.ts b/src/components/ha-time-input.ts index 29893298dc..696dbef4aa 100644 --- a/src/components/ha-time-input.ts +++ b/src/components/ha-time-input.ts @@ -23,6 +23,8 @@ export class HaTimeInput extends LitElement { @property({ type: Boolean, attribute: "enable-second" }) public enableSecond = false; + @property({ type: Boolean, reflect: true }) public clearable?: boolean; + protected render() { const useAMPM = useAmPm(this.locale); @@ -48,22 +50,26 @@ export class HaTimeInput extends LitElement { @value-changed=${this._timeChanged} .enableSecond=${this.enableSecond} .required=${this.required} + .clearable=${this.clearable && this.value !== undefined} .helper=${this.helper} > `; } - private _timeChanged(ev: CustomEvent<{ value: TimeChangedEvent }>) { + private _timeChanged(ev: CustomEvent<{ value?: TimeChangedEvent }>) { ev.stopPropagation(); const eventValue = ev.detail.value; const useAMPM = useAmPm(this.locale); - let value; + let value: string | undefined; + // An undefined eventValue means the time selector is being cleared, + // the `value` variable will (intentionally) be left undefined. if ( - !isNaN(eventValue.hours) || - !isNaN(eventValue.minutes) || - !isNaN(eventValue.seconds) + eventValue !== undefined && + (!isNaN(eventValue.hours) || + !isNaN(eventValue.minutes) || + !isNaN(eventValue.seconds)) ) { let hours = eventValue.hours || 0; if (eventValue && useAMPM) { diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 882a12cd73..0d9d2c4b85 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -49,6 +49,8 @@ export class HaYamlEditor extends LitElement { @property({ type: Boolean }) public copyClipboard = false; + @property({ type: Boolean }) public hasExtraActions = false; + @state() private _yaml = ""; public setValue(value): void { @@ -100,13 +102,16 @@ export class HaYamlEditor extends LitElement { @value-changed=${this._onChange} dir="ltr" > - ${this.copyClipboard + ${this.copyClipboard || this.hasExtraActions ? html`
- - ${this.hass.localize( - "ui.components.yaml-editor.copy_to_clipboard" - )} - + ${this.copyClipboard + ? html` + ${this.hass.localize( + "ui.components.yaml-editor.copy_to_clipboard" + )} + ` + : nothing} +
` : nothing} `; diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index d9592abfd4..929114dee9 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -483,12 +483,12 @@ export class HaMap extends ReactiveElement { const entityName = typeof entity !== "string" && entity.label_mode === "state" ? this.hass.formatEntityState(stateObj) - : customTitle ?? + : (customTitle ?? title .split(" ") .map((part) => part[0]) .join("") - .substr(0, 3); + .substr(0, 3)); // create marker with the icon const marker = Leaflet.marker([latitude, longitude], { diff --git a/src/components/search-input-outlined.ts b/src/components/search-input-outlined.ts index 06fef1cd0a..693be1feac 100644 --- a/src/components/search-input-outlined.ts +++ b/src/components/search-input-outlined.ts @@ -79,7 +79,7 @@ class SearchInputOutlined extends LitElement { } private async _filterInputChanged(e) { - this._filterChanged(e.target.value?.trim()); + this._filterChanged(e.target.value); } private async _clearSearch() { diff --git a/src/components/search-input.ts b/src/components/search-input.ts index 2d5180a6e6..2f4298ecaa 100644 --- a/src/components/search-input.ts +++ b/src/components/search-input.ts @@ -67,7 +67,7 @@ class SearchInput extends LitElement { } private async _filterInputChanged(e) { - this._filterChanged(e.target.value?.trim()); + this._filterChanged(e.target.value); } private async _clearSearch() { diff --git a/src/components/tile/ha-tile-icon.ts b/src/components/tile/ha-tile-icon.ts index f5ebe8863c..f1cd7ec633 100644 --- a/src/components/tile/ha-tile-icon.ts +++ b/src/components/tile/ha-tile-icon.ts @@ -17,7 +17,7 @@ export class HaTileIcon extends LitElement { return css` :host { --tile-icon-color: var(--disabled-color); - --mdc-icon-size: 24px; + --mdc-icon-size: 22px; } .shape::before { content: ""; @@ -32,9 +32,9 @@ export class HaTileIcon extends LitElement { } .shape { position: relative; - width: 40px; - height: 40px; - border-radius: 20px; + width: 36px; + height: 36px; + border-radius: 18px; display: flex; align-items: center; justify-content: center; diff --git a/src/components/tile/ha-tile-image.ts b/src/components/tile/ha-tile-image.ts index fc29eb57d0..143f4c176b 100644 --- a/src/components/tile/ha-tile-image.ts +++ b/src/components/tile/ha-tile-image.ts @@ -25,9 +25,9 @@ export class HaTileImage extends LitElement { return css` .image { position: relative; - width: 40px; - height: 40px; - border-radius: 20px; + width: 36px; + height: 36px; + border-radius: 18px; display: flex; flex: none; align-items: center; diff --git a/src/components/tile/ha-tile-info.ts b/src/components/tile/ha-tile-info.ts index b28e373077..68123eee6f 100644 --- a/src/components/tile/ha-tile-info.ts +++ b/src/components/tile/ha-tile-info.ts @@ -33,7 +33,7 @@ export class HaTileInfo extends LitElement { flex-direction: column; align-items: flex-start; justify-content: center; - min-height: 40px; + height: 36px; } span { text-overflow: ellipsis; diff --git a/src/components/trace/hat-script-graph.ts b/src/components/trace/hat-script-graph.ts index f9aaf73050..804261d6ff 100644 --- a/src/components/trace/hat-script-graph.ts +++ b/src/components/trace/hat-script-graph.ts @@ -424,7 +424,7 @@ export class HatScriptGraph extends LitElement { return html` tr.error)} tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"} > - ${node.service + ${node.action ? html`` : nothing} diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 46e16b50cc..6220d60d62 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -2,10 +2,11 @@ import { stringCompare } from "../common/string/compare"; import { HomeAssistant } from "../types"; import { DeviceRegistryEntry } from "./device_registry"; import { EntityRegistryEntry } from "./entity_registry"; +import { RegistryEntry } from "./registry"; export { subscribeAreaRegistry } from "./ws-area_registry"; -export interface AreaRegistryEntry { +export interface AreaRegistryEntry extends RegistryEntry { area_id: string; floor_id: string | null; name: string; diff --git a/src/data/automation.ts b/src/data/automation.ts index ea20b874c8..59cf4bacfe 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -6,7 +6,7 @@ import { navigate } from "../common/navigate"; import { Context, HomeAssistant } from "../types"; import { BlueprintInput } from "./blueprint"; import { DeviceCondition, DeviceTrigger } from "./device_automation"; -import { Action, MODES } from "./script"; +import { Action, MODES, migrateAutomationAction } from "./script"; export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; export const AUTOMATION_DEFAULT_MAX = 10; @@ -28,7 +28,7 @@ export interface ManualAutomationConfig { description?: string; trigger: Trigger | Trigger[]; condition?: Condition | Condition[]; - action: Action | Action[]; + action?: Action | Action[]; mode?: (typeof MODES)[number]; max?: number; max_exceeded?: @@ -357,7 +357,7 @@ export const normalizeAutomationConfig = < >( config: T ): T => { - // Normalize data: ensure trigger, action and condition are lists + // Normalize data: ensure triggers, actions and conditions are lists // Happens when people copy paste their automations into the config for (const key of ["trigger", "condition", "action"]) { const value = config[key]; @@ -365,6 +365,9 @@ export const normalizeAutomationConfig = < config[key] = [value]; } } + + config.action = migrateAutomationAction(config.action || []); + return config; }; diff --git a/src/data/cover.ts b/src/data/cover.ts index 046da65aa3..4916792fee 100644 --- a/src/data/cover.ts +++ b/src/data/cover.ts @@ -115,8 +115,8 @@ export function computeCoverPositionStateDisplay( position?: number ) { const statePosition = stateActive(stateObj) - ? stateObj.attributes.current_position ?? - stateObj.attributes.current_tilt_position + ? (stateObj.attributes.current_position ?? + stateObj.attributes.current_tilt_position) : undefined; const currentPosition = position ?? statePosition; diff --git a/src/data/device_automation.ts b/src/data/device_automation.ts index eb0ec9ebdc..b984ac9001 100644 --- a/src/data/device_automation.ts +++ b/src/data/device_automation.ts @@ -178,7 +178,11 @@ const getEntityName = ( entityId: string | undefined ): string => { if (!entityId) { - return ""; + return ( + "<" + + hass.localize("ui.panel.config.automation.editor.unknown_entity") + + ">" + ); } if (entityId.includes(".")) { const state = hass.states[entityId]; @@ -191,7 +195,11 @@ const getEntityName = ( if (entityReg) { return computeEntityRegistryName(hass, entityReg) || entityId; } - return ""; + return ( + "<" + + hass.localize("ui.panel.config.automation.editor.unknown_entity") + + ">" + ); }; export const localizeDeviceAutomationAction = ( diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 90bd894e8a..f758f19b8d 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -1,25 +1,27 @@ import { computeStateName } from "../common/entity/compute_state_name"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import type { HomeAssistant } from "../types"; +import { ConfigEntry } from "./config_entries"; import type { EntityRegistryDisplayEntry, EntityRegistryEntry, } from "./entity_registry"; -import { ConfigEntry } from "./config_entries"; import type { EntitySources } from "./entity_sources"; +import { RegistryEntry } from "./registry"; export { fetchDeviceRegistry, subscribeDeviceRegistry, } from "./ws-device_registry"; -export interface DeviceRegistryEntry { +export interface DeviceRegistryEntry extends RegistryEntry { id: string; config_entries: string[]; connections: Array<[string, string]>; identifiers: Array<[string, string]>; manufacturer: string | null; model: string | null; + model_id: string | null; name: string | null; labels: string[]; sw_version: string | null; diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 02b72bf477..ae370892f1 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -7,6 +7,7 @@ import { debounce } from "../common/util/debounce"; import { HomeAssistant } from "../types"; import { LightColor } from "./light"; import { computeDomain } from "../common/entity/compute_domain"; +import { RegistryEntry } from "./registry"; export { subscribeEntityRegistryDisplay } from "./ws-entity_registry_display"; @@ -43,7 +44,7 @@ export interface EntityRegistryDisplayEntryResponse { entity_categories: Record; } -export interface EntityRegistryEntry { +export interface EntityRegistryEntry extends RegistryEntry { id: string; entity_id: string; name: string | null; diff --git a/src/data/floor_registry.ts b/src/data/floor_registry.ts index c69e31cc51..1a31cbf09b 100644 --- a/src/data/floor_registry.ts +++ b/src/data/floor_registry.ts @@ -4,10 +4,11 @@ import { stringCompare } from "../common/string/compare"; import { debounce } from "../common/util/debounce"; import { HomeAssistant } from "../types"; import { AreaRegistryEntry } from "./area_registry"; +import { RegistryEntry } from "./registry"; export { subscribeAreaRegistry } from "./ws-area_registry"; -export interface FloorRegistryEntry { +export interface FloorRegistryEntry extends RegistryEntry { floor_id: string; name: string; level: number | null; diff --git a/src/data/group.ts b/src/data/group.ts index fbaaab4faf..cfae3ed858 100644 --- a/src/data/group.ts +++ b/src/data/group.ts @@ -1,10 +1,8 @@ import { HassEntityAttributeBase, HassEntityBase, - UnsubscribeFunc, } from "home-assistant-js-websocket"; import { computeDomain } from "../common/entity/compute_domain"; -import { HomeAssistant } from "../types"; interface GroupEntityAttributes extends HassEntityAttributeBase { entity_id: string[]; @@ -17,11 +15,6 @@ export interface GroupEntity extends HassEntityBase { attributes: GroupEntityAttributes; } -export interface GroupPreview { - state: string; - attributes: Record; -} - export const computeGroupDomain = ( stateObj: GroupEntity ): string | undefined => { @@ -31,17 +24,3 @@ export const computeGroupDomain = ( ]; return uniqueDomains.length === 1 ? uniqueDomains[0] : undefined; }; - -export const subscribePreviewGroup = ( - hass: HomeAssistant, - flow_id: string, - flow_type: "config_flow" | "options_flow", - user_input: Record, - callback: (preview: GroupPreview) => void -): Promise => - hass.connection.subscribeMessage(callback, { - type: "group/start_preview", - flow_id, - flow_type, - user_input, - }); diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts index cc6b318354..8c8f481e57 100644 --- a/src/data/label_registry.ts +++ b/src/data/label_registry.ts @@ -1,10 +1,11 @@ import { Connection, createCollection } from "home-assistant-js-websocket"; import { Store } from "home-assistant-js-websocket/dist/store"; import { stringCompare } from "../common/string/compare"; -import { HomeAssistant } from "../types"; import { debounce } from "../common/util/debounce"; +import { HomeAssistant } from "../types"; +import { RegistryEntry } from "./registry"; -export interface LabelRegistryEntry { +export interface LabelRegistryEntry extends RegistryEntry { label_id: string; name: string; icon: string | null; diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index bfd794ef8d..1b4f47aa38 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -3,9 +3,10 @@ import { getCollection, HassEventBase, } from "home-assistant-js-websocket"; +import { HuiBadge } from "../panels/lovelace/badges/hui-badge"; import type { HuiCard } from "../panels/lovelace/cards/hui-card"; import type { HuiSection } from "../panels/lovelace/sections/hui-section"; -import { Lovelace, LovelaceBadge } from "../panels/lovelace/types"; +import { Lovelace } from "../panels/lovelace/types"; import { HomeAssistant } from "../types"; import { LovelaceSectionConfig } from "./lovelace/config/section"; import { fetchConfig, LegacyLovelaceConfig } from "./lovelace/config/types"; @@ -21,7 +22,7 @@ export interface LovelaceViewElement extends HTMLElement { narrow?: boolean; index?: number; cards?: HuiCard[]; - badges?: LovelaceBadge[]; + badges?: HuiBadge[]; sections?: HuiSection[]; isStrategy: boolean; setConfig(config: LovelaceViewConfig): void; diff --git a/src/data/lovelace/config/action.ts b/src/data/lovelace/config/action.ts index 94f781a209..9ed065bc8d 100644 --- a/src/data/lovelace/config/action.ts +++ b/src/data/lovelace/config/action.ts @@ -5,10 +5,12 @@ export interface ToggleActionConfig extends BaseActionConfig { } export interface CallServiceActionConfig extends BaseActionConfig { - action: "call-service"; - service: string; + action: "call-service" | "perform-action"; + /** @deprecated "service" is kept for backwards compatibility. Replaced by "perform_action". */ + service?: string; + perform_action: string; target?: HassServiceTarget; - // "service_data" is kept for backwards compatibility. Replaced by "data". + /** @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ service_data?: Record; data?: Record; } diff --git a/src/data/lovelace/config/badge.ts b/src/data/lovelace/config/badge.ts index 661464a935..b6b5d7c207 100644 --- a/src/data/lovelace/config/badge.ts +++ b/src/data/lovelace/config/badge.ts @@ -1,4 +1,25 @@ +import { Condition } from "../../../panels/lovelace/common/validate-condition"; + export interface LovelaceBadgeConfig { - type?: string; + type: string; [key: string]: any; + visibility?: Condition[]; } + +export const ensureBadgeConfig = ( + config: Partial | string +): LovelaceBadgeConfig => { + if (typeof config === "string") { + return { + type: "entity", + entity: config, + }; + } + if ("type" in config && config.type) { + return config as LovelaceBadgeConfig; + } + return { + type: "entity", + ...config, + }; +}; diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index db0385173d..4bbf67c5a6 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -27,7 +27,7 @@ export interface LovelaceBaseViewConfig { export interface LovelaceViewConfig extends LovelaceBaseViewConfig { type?: string; - badges?: Array; + badges?: (string | Partial)[]; // Badge can be just an entity_id or without type cards?: LovelaceCardConfig[]; sections?: LovelaceSectionRawConfig[]; } diff --git a/src/data/lovelace_custom_cards.ts b/src/data/lovelace_custom_cards.ts index 0e4a7d79ae..c79cfb4584 100644 --- a/src/data/lovelace_custom_cards.ts +++ b/src/data/lovelace_custom_cards.ts @@ -8,6 +8,14 @@ export interface CustomCardEntry { documentationURL?: string; } +export interface CustomBadgeEntry { + type: string; + name?: string; + description?: string; + preview?: boolean; + documentationURL?: string; +} + export interface CustomCardFeatureEntry { type: string; name?: string; @@ -18,6 +26,7 @@ export interface CustomCardFeatureEntry { export interface CustomCardsWindow { customCards?: CustomCardEntry[]; customCardFeatures?: CustomCardFeatureEntry[]; + customBadges?: CustomBadgeEntry[]; /** * @deprecated Use customCardFeatures */ @@ -34,6 +43,9 @@ if (!("customCards" in customCardsWindow)) { if (!("customCardFeatures" in customCardsWindow)) { customCardsWindow.customCardFeatures = []; } +if (!("customBadges" in customCardsWindow)) { + customCardsWindow.customBadges = []; +} if (!("customTileFeatures" in customCardsWindow)) { customCardsWindow.customTileFeatures = []; } @@ -43,10 +55,14 @@ export const getCustomCardFeatures = () => [ ...customCardsWindow.customCardFeatures!, ...customCardsWindow.customTileFeatures!, ]; +export const customBadges = customCardsWindow.customBadges!; export const getCustomCardEntry = (type: string) => customCards.find((card) => card.type === type); +export const getCustomBadgeEntry = (type: string) => + customBadges.find((badge) => badge.type === type); + export const isCustomType = (type: string) => type.startsWith(CUSTOM_TYPE_PREFIX); diff --git a/src/data/otbr.ts b/src/data/otbr.ts index 8af577efd3..ec831bf671 100644 --- a/src/data/otbr.ts +++ b/src/data/otbr.ts @@ -5,33 +5,44 @@ export interface OTBRInfo { border_agent_id: string; channel: number; extended_address: string; + extended_pan_id: string; url: string; } -export const getOTBRInfo = (hass: HomeAssistant): Promise => +export type OTBRInfoDict = Record; + +export const getOTBRInfo = (hass: HomeAssistant): Promise => hass.callWS({ type: "otbr/info", }); -export const OTBRCreateNetwork = (hass: HomeAssistant): Promise => +export const OTBRCreateNetwork = ( + hass: HomeAssistant, + extended_address: string +): Promise => hass.callWS({ type: "otbr/create_network", + extended_address, }); export const OTBRSetNetwork = ( hass: HomeAssistant, + extended_address: string, dataset_id: string ): Promise => hass.callWS({ type: "otbr/set_network", + extended_address, dataset_id, }); export const OTBRSetChannel = ( hass: HomeAssistant, + extended_address: string, channel: number ): Promise<{ delay: number }> => hass.callWS({ type: "otbr/set_channel", + extended_address, channel, }); diff --git a/src/data/person.ts b/src/data/person.ts index d1a0cbdd6a..e8ee0ba25c 100644 --- a/src/data/person.ts +++ b/src/data/person.ts @@ -1,3 +1,7 @@ +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; import { HomeAssistant } from "../types"; export interface BasePerson { @@ -18,6 +22,20 @@ export interface PersonMutableParams { picture: string | null; } +interface PersonEntityAttributes extends HassEntityAttributeBase { + id?: string; + user_id?: string; + device_trackers?: string[]; + editable?: boolean; + gps_accuracy?: number; + latitude?: number; + longitude?: number; +} + +export interface PersonEntity extends HassEntityBase { + attributes: PersonEntityAttributes; +} + export const fetchPersons = (hass: HomeAssistant) => hass.callWS<{ storage: Person[]; diff --git a/src/data/threshold.ts b/src/data/preview.ts similarity index 55% rename from src/data/threshold.ts rename to src/data/preview.ts index 886ebcf0d9..1c3a24c777 100644 --- a/src/data/threshold.ts +++ b/src/data/preview.ts @@ -1,21 +1,27 @@ import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { HomeAssistant } from "../types"; -export interface ThresholdPreview { +const HAS_CUSTOM_PREVIEW = ["template"]; + +export interface GenericPreview { state: string; attributes: Record; } -export const subscribePreviewThreshold = ( +export const subscribePreviewGeneric = ( hass: HomeAssistant, + domain: string, flow_id: string, flow_type: "config_flow" | "options_flow", user_input: Record, - callback: (preview: ThresholdPreview) => void + callback: (preview: GenericPreview) => void ): Promise => hass.connection.subscribeMessage(callback, { - type: "threshold/start_preview", + type: `${domain}/start_preview`, flow_id, flow_type, user_input, }); + +export const previewModule = (domain: string): string => + HAS_CUSTOM_PREVIEW.includes(domain) ? domain : "generic"; diff --git a/src/data/registry.ts b/src/data/registry.ts new file mode 100644 index 0000000000..985d66a0ec --- /dev/null +++ b/src/data/registry.ts @@ -0,0 +1,4 @@ +export interface RegistryEntry { + created_at: number; + modified_at: number; +} diff --git a/src/data/repairs.ts b/src/data/repairs.ts index f1e0cbb7f8..32213ca566 100644 --- a/src/data/repairs.ts +++ b/src/data/repairs.ts @@ -32,7 +32,11 @@ export const fetchRepairsIssues = (conn: Connection) => type: "repairs/list_issues", }); -export const fetchRepairsIssueData = (conn: Connection, domain, issue_id) => +export const fetchRepairsIssueData = ( + conn: Connection, + domain: string, + issue_id: string +) => conn.sendMessagePromise<{ issue_data: { string: any } }>({ type: "repairs/get_issue_data", domain, diff --git a/src/data/script.ts b/src/data/script.ts index f70ca2d338..5bc5786c62 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -49,7 +49,7 @@ const targetStruct = object({ export const serviceActionStruct: Describe = assign( baseActionStruct, object({ - service: optional(string()), + action: optional(string()), service_template: optional(string()), entity_id: optional(string()), target: optional(targetStruct), @@ -62,7 +62,7 @@ export const serviceActionStruct: Describe = assign( const playMediaActionStruct: Describe = assign( baseActionStruct, object({ - service: literal("media_player.play_media"), + action: literal("media_player.play_media"), target: optional(object({ entity_id: optional(string()) })), entity_id: optional(string()), data: object({ media_content_id: string(), media_content_type: string() }), @@ -73,7 +73,7 @@ const playMediaActionStruct: Describe = assign( const activateSceneActionStruct: Describe = assign( baseActionStruct, object({ - service: literal("scene.turn_on"), + action: literal("scene.turn_on"), target: optional(object({ entity_id: optional(string()) })), entity_id: optional(string()), metadata: object(), @@ -132,7 +132,7 @@ export interface EventAction extends BaseAction { } export interface ServiceAction extends BaseAction { - service?: string; + action?: string; service_template?: string; entity_id?: string; target?: HassServiceTarget; @@ -160,7 +160,7 @@ export interface DelayAction extends BaseAction { } export interface ServiceSceneAction extends BaseAction { - service: "scene.turn_on"; + action: "scene.turn_on"; target?: { entity_id?: string }; entity_id?: string; metadata: Record; @@ -191,7 +191,7 @@ export interface WaitForTriggerAction extends BaseAction { } export interface PlayMediaAction extends BaseAction { - service: "media_player.play_media"; + action: "media_player.play_media"; target?: { entity_id?: string }; entity_id?: string; data: { media_content_id: string; media_content_type: string }; @@ -404,7 +404,7 @@ export const getActionType = (action: Action): ActionType => { if ("set_conversation_response" in action) { return "set_conversation_response"; } - if ("service" in action) { + if ("action" in action) { if ("metadata" in action) { if (is(action, activateSceneActionStruct)) { return "activate_scene"; @@ -425,3 +425,60 @@ export const hasScriptFields = ( const fields = hass.services.script[computeObjectId(entityId)]?.fields; return fields !== undefined && Object.keys(fields).length > 0; }; + +export const migrateAutomationAction = ( + action: Action | Action[] +): Action | Action[] => { + if (Array.isArray(action)) { + return action.map(migrateAutomationAction) as Action[]; + } + + if ("service" in action) { + if (!("action" in action)) { + action.action = action.service; + } + delete action.service; + } + + if ("sequence" in action) { + for (const sequenceAction of (action as SequenceAction).sequence) { + migrateAutomationAction(sequenceAction); + } + } + + const actionType = getActionType(action); + + if (actionType === "parallel") { + const _action = action as ParallelAction; + migrateAutomationAction(_action.parallel); + } + + if (actionType === "choose") { + const _action = action as ChooseAction; + if (Array.isArray(_action.choose)) { + for (const choice of _action.choose) { + migrateAutomationAction(choice.sequence); + } + } else if (_action.choose) { + migrateAutomationAction(_action.choose.sequence); + } + if (_action.default) { + migrateAutomationAction(_action.default); + } + } + + if (actionType === "repeat") { + const _action = action as RepeatAction; + migrateAutomationAction(_action.repeat.sequence); + } + + if (actionType === "if") { + const _action = action as IfAction; + migrateAutomationAction(_action.then); + if (_action.else) { + migrateAutomationAction(_action.else); + } + } + + return action; +}; diff --git a/src/data/script_i18n.ts b/src/data/script_i18n.ts index 4d518bd57c..150afc4220 100644 --- a/src/data/script_i18n.ts +++ b/src/data/script_i18n.ts @@ -192,7 +192,7 @@ const tryDescribeAction = ( if ( config.service_template || - (config.service && isTemplate(config.service)) + (config.action && isTemplate(config.action)) ) { return hass.localize( targets.length @@ -204,8 +204,8 @@ const tryDescribeAction = ( ); } - if (config.service) { - const [domain, serviceName] = config.service.split(".", 2); + if (config.action) { + const [domain, serviceName] = config.action.split(".", 2); const service = hass.localize(`component.${domain}.services.${serviceName}.name`) || hass.services[domain][serviceName]?.name; @@ -217,7 +217,7 @@ const tryDescribeAction = ( : `${actionTranslationBaseKey}.service.description.service_name_no_targets`, { domain: domainToName(hass.localize, domain), - name: service || config.service, + name: service || config.action, targets: formatListWithAnds(hass.locale, targets), } ); @@ -230,7 +230,7 @@ const tryDescribeAction = ( { name: service ? `${domainToName(hass.localize, domain)}: ${service}` - : config.service, + : config.action, targets: formatListWithAnds(hass.locale, targets), } ); diff --git a/src/data/search.ts b/src/data/search.ts index 5011f9a4c1..e184f13e75 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -8,6 +8,7 @@ export interface RelatedResult { device?: string[]; entity?: string[]; group?: string[]; + integration?: string[]; scene?: string[]; script?: string[]; script_blueprint?: string[]; diff --git a/src/data/selector.ts b/src/data/selector.ts index 60b9e4973b..c4b385b136 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -2,6 +2,8 @@ import type { HassEntity } from "home-assistant-js-websocket"; import { ensureArray } from "../common/array/ensure-array"; import { computeStateDomain } from "../common/entity/compute_state_domain"; import { supportsFeature } from "../common/entity/supports-feature"; +import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; +import { isHelperDomain } from "../panels/config/helpers/const"; import { UiAction } from "../panels/lovelace/components/hui-action-editor"; import { HomeAssistant, ItemPath } from "../types"; import { @@ -13,8 +15,6 @@ import { EntityRegistryEntry, } from "./entity_registry"; import { EntitySources } from "./entity_sources"; -import { isHelperDomain } from "../panels/config/helpers/const"; -import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; export type Selector = | ActionSelector @@ -64,7 +64,8 @@ export type Selector = | TTSSelector | TTSVoiceSelector | UiActionSelector - | UiColorSelector; + | UiColorSelector + | UiStateContentSelector; export interface ActionSelector { action: { @@ -202,6 +203,7 @@ export interface LegacyDeviceSelector { export interface DurationSelector { duration: { enable_day?: boolean; + enable_millisecond?: boolean; } | null; } @@ -455,6 +457,13 @@ export interface UiColorSelector { ui_color: { default_color?: boolean } | null; } +export interface UiStateContentSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + ui_state_content: { + entity_id?: string; + } | null; +} + export const expandLabelTarget = ( hass: HomeAssistant, labelId: string, diff --git a/src/data/time_date.ts b/src/data/time_date.ts deleted file mode 100644 index 5f572cb658..0000000000 --- a/src/data/time_date.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { HomeAssistant } from "../types"; - -export interface TimeDatePreview { - state: string; - attributes: Record; -} - -export const subscribePreviewTimeDate = ( - hass: HomeAssistant, - flow_id: string, - flow_type: "config_flow" | "options_flow", - user_input: Record, - callback: (preview: TimeDatePreview) => void -): Promise => - hass.connection.subscribeMessage(callback, { - type: "time_date/start_preview", - flow_id, - flow_type, - user_input, - }); diff --git a/src/data/timer.ts b/src/data/timer.ts index 7dc4f8e41d..4b0ab1ce27 100644 --- a/src/data/timer.ts +++ b/src/data/timer.ts @@ -92,7 +92,7 @@ export const computeDisplayTimer = ( return hass.formatEntityState(stateObj); } - let display = secondsToDuration(timeRemaining || 0); + let display = secondsToDuration(timeRemaining || 0) || "0"; if (stateObj.state === "paused") { display = `${display} (${hass.formatEntityState(stateObj)})`; diff --git a/src/dialogs/config-flow/previews/flow-preview-threshold.ts b/src/dialogs/config-flow/previews/flow-preview-generic.ts similarity index 86% rename from src/dialogs/config-flow/previews/flow-preview-threshold.ts rename to src/dialogs/config-flow/previews/flow-preview-generic.ts index 74f2daa8e9..78209ce310 100644 --- a/src/dialogs/config-flow/previews/flow-preview-threshold.ts +++ b/src/dialogs/config-flow/previews/flow-preview-generic.ts @@ -2,23 +2,22 @@ import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators"; import { FlowType } from "../../../data/data_entry_flow"; -import { - ThresholdPreview, - subscribePreviewThreshold, -} from "../../../data/threshold"; +import { GenericPreview, subscribePreviewGeneric } from "../../../data/preview"; import { HomeAssistant } from "../../../types"; import "./entity-preview-row"; import { debounce } from "../../../common/util/debounce"; import { fireEvent } from "../../../common/dom/fire_event"; -@customElement("flow-preview-threshold") -class FlowPreviewThreshold extends LitElement { +@customElement("flow-preview-generic") +class FlowPreviewGeneric extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() public flowType!: FlowType; public handler!: string; + @property() public domain!: string; + @property() public stepId!: string; @property() public flowId!: string; @@ -55,7 +54,7 @@ class FlowPreviewThreshold extends LitElement { >`; } - private _setPreview = (preview: ThresholdPreview) => { + private _setPreview = (preview: GenericPreview) => { const now = new Date().toISOString(); this._preview = { entity_id: `${this.stepId}.___flow_preview___`, @@ -79,14 +78,14 @@ class FlowPreviewThreshold extends LitElement { return; } try { - this._unsub = subscribePreviewThreshold( + this._unsub = subscribePreviewGeneric( this.hass, + this.domain, this.flowId, this.flowType, this.stepData, this._setPreview ); - await this._unsub; fireEvent(this, "set-flow-errors", { errors: {} }); } catch (err: any) { if (typeof err.message === "string") { @@ -103,6 +102,6 @@ class FlowPreviewThreshold extends LitElement { declare global { interface HTMLElementTagNameMap { - "flow-preview-threshold": FlowPreviewThreshold; + "flow-preview-generic": FlowPreviewGeneric; } } diff --git a/src/dialogs/config-flow/previews/flow-preview-group.ts b/src/dialogs/config-flow/previews/flow-preview-group.ts deleted file mode 100644 index 5afc93a038..0000000000 --- a/src/dialogs/config-flow/previews/flow-preview-group.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { FlowType } from "../../../data/data_entry_flow"; -import { GroupPreview, subscribePreviewGroup } from "../../../data/group"; -import { HomeAssistant } from "../../../types"; -import "./entity-preview-row"; -import { debounce } from "../../../common/util/debounce"; - -@customElement("flow-preview-group") -class FlowPreviewGroup extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public flowType!: FlowType; - - public handler!: string; - - @property() public stepId!: string; - - @property() public flowId!: string; - - @property({ attribute: false }) public stepData!: Record; - - @state() private _preview?: HassEntity; - - private _unsub?: Promise; - - disconnectedCallback(): void { - super.disconnectedCallback(); - if (this._unsub) { - this._unsub.then((unsub) => unsub()); - this._unsub = undefined; - } - } - - willUpdate(changedProps) { - if (changedProps.has("stepData")) { - this._debouncedSubscribePreview(); - } - } - - protected render() { - return html``; - } - - private _setPreview = (preview: GroupPreview) => { - const now = new Date().toISOString(); - this._preview = { - entity_id: `${this.stepId}.___flow_preview___`, - last_changed: now, - last_updated: now, - context: { id: "", parent_id: null, user_id: null }, - ...preview, - }; - }; - - private _debouncedSubscribePreview = debounce(() => { - this._subscribePreview(); - }, 250); - - private async _subscribePreview() { - if (this._unsub) { - (await this._unsub)(); - this._unsub = undefined; - } - if (this.flowType === "repair_flow") { - return; - } - try { - this._unsub = subscribePreviewGroup( - this.hass, - this.flowId, - this.flowType, - this.stepData, - this._setPreview - ); - } catch (err) { - this._preview = undefined; - } - } -} - -declare global { - interface HTMLElementTagNameMap { - "flow-preview-group": FlowPreviewGroup; - } -} diff --git a/src/dialogs/config-flow/previews/flow-preview-time_date.ts b/src/dialogs/config-flow/previews/flow-preview-time_date.ts deleted file mode 100644 index fd4676ec43..0000000000 --- a/src/dialogs/config-flow/previews/flow-preview-time_date.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { FlowType } from "../../../data/data_entry_flow"; -import { - TimeDatePreview, - subscribePreviewTimeDate, -} from "../../../data/time_date"; -import { HomeAssistant } from "../../../types"; -import "./entity-preview-row"; -import { debounce } from "../../../common/util/debounce"; - -@customElement("flow-preview-time_date") -class FlowPreviewTimeDate extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public flowType!: FlowType; - - public handler!: string; - - @property() public stepId!: string; - - @property() public flowId!: string; - - @property() public stepData!: Record; - - @state() private _preview?: HassEntity; - - private _unsub?: Promise; - - disconnectedCallback(): void { - super.disconnectedCallback(); - if (this._unsub) { - this._unsub.then((unsub) => unsub()); - this._unsub = undefined; - } - } - - willUpdate(changedProps) { - if (changedProps.has("stepData")) { - this._debouncedSubscribePreview(); - } - } - - protected render() { - return html``; - } - - private _setPreview = (preview: TimeDatePreview) => { - const now = new Date().toISOString(); - this._preview = { - entity_id: `${this.stepId}.___flow_preview___`, - last_changed: now, - last_updated: now, - context: { id: "", parent_id: null, user_id: null }, - ...preview, - }; - }; - - private _debouncedSubscribePreview = debounce(() => { - this._subscribePreview(); - }, 250); - - private async _subscribePreview() { - if (this._unsub) { - (await this._unsub)(); - this._unsub = undefined; - } - if (this.flowType === "repair_flow") { - return; - } - try { - this._unsub = subscribePreviewTimeDate( - this.hass, - this.flowId, - this.flowType, - this.stepData, - this._setPreview - ); - await this._unsub; - } catch (err) { - this._preview = undefined; - } - } -} - -declare global { - interface HTMLElementTagNameMap { - "flow-preview-time_date": FlowPreviewTimeDate; - } -} diff --git a/src/dialogs/config-flow/step-flow-form.ts b/src/dialogs/config-flow/step-flow-form.ts index 1390c3f02e..20c665df90 100644 --- a/src/dialogs/config-flow/step-flow-form.ts +++ b/src/dialogs/config-flow/step-flow-form.ts @@ -25,6 +25,7 @@ import type { HomeAssistant } from "../../types"; import type { FlowConfig } from "./show-dialog-data-entry-flow"; import { configFlowContentStyles } from "./styles"; import { haStyle } from "../../resources/styles"; +import { previewModule } from "../../data/preview"; @customElement("step-flow-form") class StepFlowForm extends LitElement { @@ -76,8 +77,9 @@ class StepFlowForm extends LitElement { "ui.panel.config.integrations.config_flow.preview" )}:

- ${dynamicElement(`flow-preview-${this.step.preview}`, { + ${dynamicElement(`flow-preview-${previewModule(step.preview)}`, { hass: this.hass, + domain: step.preview, flowType: this.flowConfig.flowType, handler: step.handler, stepId: step.step_id, @@ -120,7 +122,7 @@ class StepFlowForm extends LitElement { protected willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); if (changedProps.has("step") && this.step?.preview) { - import(`./previews/flow-preview-${this.step.preview}`); + import(`./previews/flow-preview-${previewModule(this.step.preview)}`); } } diff --git a/src/dialogs/more-info/controls/more-info-climate.ts b/src/dialogs/more-info/controls/more-info-climate.ts index 086bb7fa78..7b5e4190c0 100644 --- a/src/dialogs/more-info/controls/more-info-climate.ts +++ b/src/dialogs/more-info/controls/more-info-climate.ts @@ -6,7 +6,14 @@ import { mdiTuneVariant, mdiWaterPercent, } from "@mdi/js"; -import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; import { property, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { supportsFeature } from "../../../common/entity/supports-feature"; @@ -39,6 +46,17 @@ class MoreInfoClimate extends LitElement { @state() private _mainControl: MainControl = "temperature"; + protected willUpdate(changedProps: PropertyValues): void { + if ( + changedProps.has("stateObj") && + this.stateObj && + this._mainControl === "humidity" && + !supportsFeature(this.stateObj, ClimateEntityFeature.TARGET_HUMIDITY) + ) { + this._mainControl = "temperature"; + } + } + protected render() { if (!this.stateObj) { return nothing; diff --git a/src/dialogs/more-info/controls/more-info-script.ts b/src/dialogs/more-info/controls/more-info-script.ts index c530f05cf5..83daa7dccf 100644 --- a/src/dialogs/more-info/controls/more-info-script.ts +++ b/src/dialogs/more-info/controls/more-info-script.ts @@ -148,7 +148,7 @@ class MoreInfoScript extends LitElement { const newState = this.stateObj; if (newState && (!oldState || oldState.entity_id !== newState.entity_id)) { - this._scriptData = { service: newState.entity_id, data: {} }; + this._scriptData = { action: newState.entity_id, data: {} }; } } diff --git a/src/entrypoints/custom-panel.ts b/src/entrypoints/custom-panel.ts index c12674a98e..767855459b 100644 --- a/src/entrypoints/custom-panel.ts +++ b/src/entrypoints/custom-panel.ts @@ -72,7 +72,7 @@ function initialize( ); } - if (__BUILD__ === "es5") { + if (__BUILD__ === "legacy") { start = start.then(() => window.loadES5Adapter()); } diff --git a/src/entrypoints/service_worker.ts b/src/entrypoints/service-worker.ts similarity index 95% rename from src/entrypoints/service_worker.ts rename to src/entrypoints/service-worker.ts index 4b7e262f3b..3b67fee982 100644 --- a/src/entrypoints/service_worker.ts +++ b/src/entrypoints/service-worker.ts @@ -13,18 +13,16 @@ import { StaleWhileRevalidate, } from "workbox-strategies"; +declare const __WB_MANIFEST__: Parameters[0]; + const noFallBackRegEx = /\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/; const initRouting = () => { - precacheAndRoute( - // @ts-ignore - WB_MANIFEST, - { - // Ignore all URL parameters. - ignoreURLParametersMatching: [/.*/], - } - ); + precacheAndRoute(__WB_MANIFEST__, { + // Ignore all URL parameters. + ignoreURLParametersMatching: [/.*/], + }); // Cache static content (including translations) on first access. registerRoute( @@ -56,11 +54,8 @@ const initRouting = () => { // Get api from network. registerRoute(/\/(api|auth)\/.*/, new NetworkOnly()); - // Get manifest, service worker, onboarding from network. - registerRoute( - /\/(service_worker.js|manifest.json|onboarding.html)/, - new NetworkOnly() - ); + // Get manifest and onboarding from network. + registerRoute(/\/(?:manifest\.json|onboarding\.html)/, new NetworkOnly()); // For the root "/" we ignore search registerRoute( diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts index c4d56f8aac..13a0663221 100644 --- a/src/external_app/external_messaging.ts +++ b/src/external_app/external_messaging.ts @@ -128,6 +128,9 @@ interface EMOutgoingMessageAssistShow extends EMMessage { start_listening: boolean; }; } +interface EMOutgoingMessageImprovScan extends EMMessage { + type: "improv/scan"; +} interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage { type: "thread/store_in_platform_keychain"; @@ -156,7 +159,8 @@ type EMOutgoingMessageWithoutAnswer = | EMOutgoingMessageSidebarShow | EMOutgoingMessageTagWrite | EMOutgoingMessageThemeUpdate - | EMOutgoingMessageThreadStoreInPlatformKeychain; + | EMOutgoingMessageThreadStoreInPlatformKeychain + | EMOutgoingMessageImprovScan; interface EMIncomingMessageRestart { id: number; @@ -252,6 +256,7 @@ export interface ExternalConfig { canTransferThreadCredentialsToKeychain: boolean; hasAssist: boolean; hasBarCodeScanner: number; + canSetupImprov: boolean; } export class ExternalMessaging { diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 62787c4fef..692d975cd3 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -354,7 +354,7 @@ export const provideHass = ( (state !== null ? state : stateObj.state) ?? "", formatEntityAttributeName: (_stateObj, attribute) => attribute, formatEntityAttributeValue: (stateObj, attribute, value) => - value !== null ? value : stateObj.attributes[attribute] ?? "", + value !== null ? value : (stateObj.attributes[attribute] ?? ""), ...overrideData, }; diff --git a/src/html/_js_base.html.template b/src/html/_js_base.html.template index 690cd0cd7a..aaa55cbfdc 100644 --- a/src/html/_js_base.html.template +++ b/src/html/_js_base.html.template @@ -16,8 +16,9 @@ ) { _ls("/static/polyfills/webcomponents-bundle.js", true); } - var isS11_12 = - /(?:.*(?:iPhone|iPad).*OS (?:11|12)_\d)|(?:.*Version\/(?:11|12)(?:\.\d+)*.*Safari\/)/.test( - navigator.userAgent - ); + // Modern browsers are detected primarily using the user agent string. + // A feature detection which roughly lines up with the modern targets is used + // as a fallback to guard against spoofs. It should be updated periodically. + var isModern = <%= modernRegex %>.test(navigator.userAgent) && + "findLast" in Array.prototype; diff --git a/src/html/_script_load_es5.html.template b/src/html/_script_loader.html.template similarity index 66% rename from src/html/_script_load_es5.html.template rename to src/html/_script_loader.html.template index cb72d05e5a..5ea3f23ac4 100644 --- a/src/html/_script_load_es5.html.template +++ b/src/html/_script_loader.html.template @@ -1,3 +1,11 @@ + - <%= renderTemplate("_script_load_es5.html.template") %> diff --git a/src/html/index.html.template b/src/html/index.html.template index 7cfa93ea97..5a6316452d 100644 --- a/src/html/index.html.template +++ b/src/html/index.html.template @@ -45,6 +45,14 @@ #ha-launch-screen .ha-launch-screen-spacer { flex: 1; } + .ohf-logo { + color: grey; + font-size: 12px; + margin-bottom: 16px; + display: flex; + flex-direction: column; + align-items: center; + } @@ -55,13 +63,16 @@
+
<%= renderTemplate("_js_base.html.template") %> <%= renderTemplate("_preload_roboto.html.template") %> - <%= renderTemplate("_script_load_es5.html.template") %> diff --git a/src/layouts/ha-init-page.ts b/src/layouts/ha-init-page.ts index 43b6eb77d5..21c7e50fdb 100644 --- a/src/layouts/ha-init-page.ts +++ b/src/layouts/ha-init-page.ts @@ -39,7 +39,13 @@ class HaInitPage extends LitElement {
${this.migration - ? "Database migration in progress, please wait this might take some time" + ? html` + Database upgrade is in progress, Home Assistant will not start + until the upgrade is completed. +

+ The upgrade may need a long time to complete, please be + patient. + ` : "Loading data"}
`; diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index 79db1674e5..8d88db443a 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -266,7 +266,8 @@ export class HaTabsSubpageDataTable extends LitElement { | undefined; - const preloadWindow = window as WindowWithPreloads; - // On first load, we speed up loading page by having recorderInfoProm ready - if (preloadWindow.recorderInfoProm) { - recorderInfoProm = preloadWindow.recorderInfoProm; - preloadWindow.recorderInfoProm = undefined; - } - const info = await (recorderInfoProm || - getRecorderInfo(this.hass.connection)); - this._databaseMigration = - info.migration_in_progress && !info.migration_is_live; - if (this._databaseMigration) { - // check every 5 seconds if the migration is done - setTimeout(() => this.checkDataBaseMigration(), 5000); - } - } else { - this._databaseMigration = false; + let recorderInfoProm: Promise | undefined; + const preloadWindow = window as WindowWithPreloads; + // On first load, we speed up loading page by having recorderInfoProm ready + if (preloadWindow.recorderInfoProm) { + recorderInfoProm = preloadWindow.recorderInfoProm; + preloadWindow.recorderInfoProm = undefined; + } + const info = await ( + recorderInfoProm || getRecorderInfo(this.hass!.connection) + ).catch((err) => { + // If the command failed with code unknown_command, recorder is not enabled, + // otherwise re-throw the error + if (err.code !== "unknown_command") throw err; + return { migration_in_progress: false, migration_is_live: false }; + }); + this._databaseMigration = + info.migration_in_progress && !info.migration_is_live; + if (this._databaseMigration) { + // check every 5 seconds if the migration is done + setTimeout(() => this.checkDataBaseMigration(), 5000); } } diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts index d31c8b274d..f18966c6f8 100644 --- a/src/panels/config/application_credentials/ha-config-application-credentials.ts +++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts @@ -87,14 +87,13 @@ export class HaConfigApplicationCredentials extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, client_id: { title: localize( "ui.panel.config.application_credentials.picker.headers.client_id" ), filterable: true, - width: "30%", }, localizedDomain: { title: localize( @@ -102,12 +101,10 @@ export class HaConfigApplicationCredentials extends LitElement { ), sortable: true, filterable: true, - width: "30%", direction: "asc", }, actions: { title: "", - width: "64px", type: "overflow-menu", showNarrow: true, hideable: false, diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index fc43b6a3ca..5da36548e4 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -30,6 +30,7 @@ import { classMap } from "lit/directives/class-map"; import { storage } from "../../../../common/decorators/storage"; import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { handleStructError } from "../../../../common/structs/handle-errors"; import "../../../../components/ha-alert"; @@ -86,8 +87,8 @@ export const getType = (action: Action | undefined) => { if (!action) { return undefined; } - if ("service" in action || "scene" in action) { - return getActionType(action) as "activate_scene" | "service" | "play_media"; + if ("action" in action || "scene" in action) { + return getActionType(action) as "activate_scene" | "action" | "play_media"; } if (["and", "or", "not"].some((key) => key in action)) { return "condition" as const; @@ -213,12 +214,12 @@ export default class HaAutomationActionRow extends LitElement {

${type === "service" && - "service" in this.action && - this.action.service + "action" in this.action && + this.action.action ? html`` : html` ) { ev.stopPropagation(); + this._config = ev.detail.value; if (this._readOnly) { return; diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index 7bef54309e..a5523eb3f4 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -288,7 +288,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, extraTemplate: (automation) => automation.labels.length ? html` { if (!automation.last_triggered) { @@ -337,7 +336,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { }, }, formatted_state: { - width: "82px", + minWidth: "82px", + maxWidth: "82px", sortable: true, groupable: true, hidden: narrow, @@ -353,7 +353,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { }, actions: { title: "", - width: "64px", type: "icon-button", showNarrow: true, moveable: false, diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index 09e4f9f2e7..7c51eada82 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -29,6 +29,7 @@ import { classMap } from "lit/directives/class-map"; import { storage } from "../../../../common/decorators/storage"; import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { handleStructError } from "../../../../common/structs/handle-errors"; import { debounce } from "../../../../common/util/debounce"; @@ -175,6 +176,7 @@ export default class HaAutomationTriggerRow extends LitElement { slot="icons" @action=${this._handleAction} @click=${preventDefault} + @closed=${stopPropagation} fixed > - + @@ -72,14 +72,12 @@ class HaConfigBackup extends LitElement { }, size: { title: localize("ui.panel.config.backup.size"), - width: "15%", filterable: true, sortable: true, template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB", }, date: { title: localize("ui.panel.config.backup.created"), - width: "15%", direction: "desc", filterable: true, sortable: true, @@ -89,7 +87,6 @@ class HaConfigBackup extends LitElement { actions: { title: "", - width: "15%", type: "overflow-menu", showNarrow: true, hideable: false, diff --git a/src/panels/config/blueprint/ha-blueprint-overview.ts b/src/panels/config/blueprint/ha-blueprint-overview.ts index 292a267bd7..a7b32ea051 100644 --- a/src/panels/config/blueprint/ha-blueprint-overview.ts +++ b/src/panels/config/blueprint/ha-blueprint-overview.ts @@ -176,7 +176,7 @@ class HaBlueprintOverview extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, translated_type: { title: localize("ui.panel.config.blueprint.overview.headers.type"), @@ -184,14 +184,13 @@ class HaBlueprintOverview extends LitElement { filterable: true, groupable: true, direction: "asc", - width: "10%", }, path: { title: localize("ui.panel.config.blueprint.overview.headers.file_name"), sortable: true, filterable: true, direction: "asc", - width: "25%", + flex: 2, }, fullpath: { title: "fullpath", @@ -199,7 +198,6 @@ class HaBlueprintOverview extends LitElement { }, actions: { title: "", - width: this.narrow ? undefined : "10%", type: "overflow-menu", showNarrow: true, moveable: false, diff --git a/src/panels/config/cloud/account/cloud-tts-pref.ts b/src/panels/config/cloud/account/cloud-tts-pref.ts index 4013d56472..6633737040 100644 --- a/src/panels/config/cloud/account/cloud-tts-pref.ts +++ b/src/panels/config/cloud/account/cloud-tts-pref.ts @@ -45,9 +45,12 @@ export class CloudTTSPref extends LitElement { header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")} >
- ${this.hass.localize("ui.panel.config.cloud.account.tts.info", { - service: '"tts.cloud_say"', - })} + ${this.hass.localize( + "ui.panel.config.cloud.account.tts.description", + { + service: '"tts.cloud_say"', + } + )}

${this.device.model - ? html`
${this.device.model}
` - : ""} + ? html`
+ ${this.device.model} + ${this.device.model_id ? html`(${this.device.model_id})` : ""} +
` + : this.device.model_id + ? html`
${this.device.model_id}
` + : ""} ${this.device.manufacturer ? html`
diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 1a9809b9b6..fb691cc7e0 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -999,11 +999,20 @@ export class HaConfigDevicePage extends LitElement { return; } - await removeConfigEntryFromDevice( - this.hass!, - this.deviceId, - entry.entry_id - ); + try { + await removeConfigEntryFromDevice( + this.hass!, + this.deviceId, + entry.entry_id + ); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.devices.error_delete" + ), + text: err.message, + }); + } }, classes: "warning", icon: mdiDelete, diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 6a5b857530..d7cfd5498b 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -22,6 +22,7 @@ import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { computeCssColor } from "../../../common/color/compute-color"; +import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { storage } from "../../../common/decorators/storage"; import { HASSDomEvent } from "../../../common/dom/fire_event"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; @@ -58,6 +59,11 @@ import "../../../components/ha-sub-menu"; import { createAreaRegistryEntry } from "../../../data/area_registry"; import { ConfigEntry, sortConfigEntries } from "../../../data/config_entries"; import { fullEntitiesContext } from "../../../data/context"; +import { + DataTableFilters, + deserializeFilters, + serializeFilters, +} from "../../../data/data_table_filters"; import { DeviceEntityLookup, DeviceRegistryEntry, @@ -75,6 +81,7 @@ import { createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; @@ -85,12 +92,6 @@ import { configSections } from "../ha-panel-config"; import "../integrations/ha-integration-overflow-menu"; import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; -import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; -import { - serializeFilters, - deserializeFilters, - DataTableFilters, -} from "../../../data/data_table_filters"; interface DeviceRowData extends DeviceRegistryEntry { device?: DeviceRowData; @@ -443,7 +444,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { } ); - private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => { + private _columns = memoizeOne((localize: LocalizeFunc) => { type DeviceItem = ReturnType< typeof this._devicesAndFilterDomains >["devicesOutput"][number]; @@ -476,6 +477,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { filterable: true, direction: "asc", grows: true, + flex: 2, + minWidth: "150px", extraTemplate: (device) => html` ${device.label_entries.length ? html` @@ -491,27 +494,27 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { sortable: true, filterable: true, groupable: true, - width: "15%", + minWidth: "120px", }, model: { title: localize("ui.panel.config.devices.data_table.model"), sortable: true, filterable: true, - width: "15%", + minWidth: "120px", }, area: { title: localize("ui.panel.config.devices.data_table.area"), sortable: true, filterable: true, groupable: true, - width: "15%", + minWidth: "120px", }, integration: { title: localize("ui.panel.config.devices.data_table.integration"), sortable: true, filterable: true, groupable: true, - width: "15%", + minWidth: "120px", }, battery_entity: { title: localize("ui.panel.config.devices.data_table.battery"), @@ -519,8 +522,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { sortable: true, filterable: true, type: "numeric", - width: narrow ? "105px" : "15%", - maxWidth: "105px", + maxWidth: "101px", + minWidth: "101px", valueColumn: "battery_level", template: (device) => { const batteryEntityPair = device.battery_entity; @@ -548,9 +551,39 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { .batteryChargingStateObj=${batteryCharging} > ` - : html`—`; + : "—"; }, }, + created_at: { + title: localize("ui.panel.config.generic.headers.created_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (entry) => + entry.created_at + ? formatShortDateTime( + new Date(entry.created_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", + }, + modified_at: { + title: localize("ui.panel.config.generic.headers.modified_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (entry) => + entry.modified_at + ? formatShortDateTime( + new Date(entry.modified_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", + }, disabled_by: { title: "", label: localize("ui.panel.config.devices.data_table.disabled_by"), @@ -675,7 +708,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { "ui.panel.config.devices.picker.search", { number: devicesOutput.length } )} - .columns=${this._columns(this.hass.localize, this.narrow)} + .columns=${this._columns(this.hass.localize)} .data=${devicesOutput} selectable .selected=${this._selected.length} diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index a63850941a..7f71e741fa 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -962,7 +962,7 @@ export class EntityRegistrySettingsEditor extends LitElement { > ${this.hass.localize( - "ui.dialogs.entity_registry.editor.hidden_description" + "ui.dialogs.entity_registry.editor.hidden_explanation" )} { @@ -294,7 +295,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, extraTemplate: (entry) => entry.label_entries.length ? html` @@ -308,14 +309,12 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { title: localize("ui.panel.config.entities.picker.headers.entity_id"), sortable: true, filterable: true, - width: "25%", }, localized_platform: { title: localize("ui.panel.config.entities.picker.headers.integration"), sortable: true, groupable: true, filterable: true, - width: "20%", }, domain: { title: localize("ui.panel.config.entities.picker.headers.domain"), @@ -329,7 +328,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { sortable: true, filterable: true, groupable: true, - width: "15%", }, disabled_by: { title: localize("ui.panel.config.entities.picker.headers.disabled_by"), @@ -348,7 +346,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { showNarrow: true, sortable: true, filterable: true, - width: "68px", template: (entry) => entry.unavailable || entry.disabled_by || @@ -398,6 +395,36 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { ` : "—", }, + created_at: { + title: localize("ui.panel.config.generic.headers.created_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (entry) => + entry.created_at + ? formatShortDateTime( + new Date(entry.created_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", + }, + modified_at: { + title: localize("ui.panel.config.generic.headers.modified_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (entry) => + entry.modified_at + ? formatShortDateTime( + new Date(entry.modified_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", + }, available: { title: localize("ui.panel.config.entities.picker.headers.availability"), sortable: true, @@ -507,8 +534,30 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { ) .map((entry) => entry.entry_id); + const filteredEntitiesByDomain = new Set(); + + const entitySources = this._entitySources || {}; + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); + } + + for (const val of filter.value) { + if (val in entitiesByDomain) { + entitiesByDomain[val].forEach((item) => + filteredEntitiesByDomain.add(item) + ); + } + } + filteredEntities = filteredEntities.filter( (entity) => + filteredEntitiesByDomain.has(entity.entity_id) || (filter.value as string[]).includes(entity.platform) || (entity.config_entry_id && entryIds.includes(entity.config_entry_id)) @@ -951,6 +1000,9 @@ ${ } protected firstUpdated() { + fetchEntitySourcesWithCache(this.hass).then((sources) => { + this._entitySources = sources; + }); this._setFiltersFromUrl(); if (Object.keys(this._filters).length) { return; @@ -961,9 +1013,6 @@ ${ items: undefined, }, }; - fetchEntitySourcesWithCache(this.hass).then((sources) => { - this._entitySources = sources; - }); } private _setFiltersFromUrl() { @@ -1059,6 +1108,8 @@ ${ options: null, labels: [], categories: {}, + created_at: 0, + modified_at: 0, }); } if (changed) { @@ -1177,7 +1228,7 @@ ${ { number: this._selected.length } ), text: this.hass.localize( - "ui.panel.config.entities.picker.hide_selected.confirm_text" + "ui.panel.config.entities.picker.hide_selected.confirm" ), confirmText: this.hass.localize("ui.common.hide"), dismissText: this.hass.localize("ui.common.cancel"), diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index 1046866244..eff9674f9b 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -71,7 +71,11 @@ import { subscribeEntityRegistry, updateEntityRegistryEntry, } from "../../../data/entity_registry"; -import { domainToName } from "../../../data/integration"; +import { + IntegrationManifest, + domainToName, + fetchIntegrationManifests, +} from "../../../data/integration"; import { LabelRegistryEntry, createLabelRegistryEntry, @@ -101,6 +105,7 @@ import { deserializeFilters, DataTableFilters, } from "../../../data/data_table_filters"; +import { fetchEntitySourcesWithCache } from "../../../data/entity_sources"; type HelperItem = { id: string; @@ -187,6 +192,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { @state() private _configEntries?: Record; + @state() private _entitySource: Record = {}; + @state() private _selected: string[] = []; @state() private _activeFilters?: string[]; @@ -280,7 +287,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { main: true, sortable: true, filterable: true, - grows: true, + flex: 2, direction: "asc", extraTemplate: (helper) => helper.label_entries.length @@ -295,7 +302,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { title: localize("ui.panel.config.helpers.picker.headers.entity_id"), sortable: true, filterable: true, - width: "25%", }, category: { title: localize("ui.panel.config.helpers.picker.headers.category"), @@ -314,7 +320,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { localized_type: { title: localize("ui.panel.config.helpers.picker.headers.type"), sortable: true, - width: "25%", filterable: true, groupable: true, }, @@ -344,7 +349,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { actions: { title: "", label: "Actions", - width: "64px", type: "overflow-menu", hideable: false, moveable: false, @@ -412,7 +416,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { configEntry !== undefined || entityState.attributes.editable, type: configEntry ? configEntry.domain - : computeStateDomain(entityState), + : this._entitySource[entityState.entity_id] || + computeStateDomain(entityState), configEntry, entity: entityState, }; @@ -444,11 +449,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { const category = entityRegEntry?.categories.helpers; return { ...item, - localized_type: item.configEntry - ? domainToName(localize, item.type) - : localize( - `ui.panel.config.helpers.types.${item.type}` as LocalizeKeys - ) || item.type, + localized_type: + domainToName(localize, item.type) || + localize( + `ui.panel.config.helpers.types.${item.type}` as LocalizeKeys + ) || + item.type, label_entries: (labels || []).map( (lbl) => labelReg!.find((label) => label.label_id === lbl)! ), @@ -467,7 +473,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { this._entityEntries === undefined || this._configEntries === undefined ) { - return html` `; + return html``; } const categoryItems = html`${this._categories?.map( @@ -926,11 +932,48 @@ ${rejected protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); + + this._fetchEntitySources(); + if (this.route.path === "/add") { this._handleAdd(); } } + private async _fetchEntitySources() { + const [entitySources, fetchedManifests] = await Promise.all([ + fetchEntitySourcesWithCache(this.hass), + fetchIntegrationManifests(this.hass), + ]); + + const manifests: { [domain: string]: IntegrationManifest } = {}; + + for (const manifest of fetchedManifests) { + manifests[manifest.domain] = manifest; + } + + const entityDomains = {}; + const domains = new Set(); + + for (const [entity, source] of Object.entries(entitySources)) { + const domain = source.domain; + if ( + !(domain in manifests) || + manifests[domain].integration_type !== "helper" + ) { + continue; + } + entityDomains[entity] = domain; + domains.add(domain); + } + + if (domains.size) { + this.hass.loadBackendTranslation("title", [...domains]); + } + + this._entitySource = entityDomains; + } + private async _handleAdd() { const domain = extractSearchParam("domain"); navigate("/config/helpers", { replace: true }); @@ -997,7 +1040,8 @@ ${rejected let changed = !this._stateItems || changedProps.has("_entityEntries") || - changedProps.has("_configEntries"); + changedProps.has("_configEntries") || + changedProps.has("_entitySource"); if (!changed && changedProps.has("hass")) { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; @@ -1007,20 +1051,11 @@ ${rejected return; } - const extraEntities = new Set(); - - for (const entityEntry of Object.values(this._entityEntries)) { - if ( - entityEntry.config_entry_id && - entityEntry.config_entry_id in this._configEntries - ) { - extraEntities.add(entityEntry.entity_id); - } - } + const entityIds = Object.keys(this._entitySource); const newStates = Object.values(this.hass!.states).filter( (entity) => - extraEntities.has(entity.entity_id) || + entityIds.includes(entity.entity_id) || isHelperDomain(computeStateDomain(entity)) ); diff --git a/src/panels/config/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index 37402301fb..c4c49f3197 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -157,11 +157,22 @@ class HaConfigInfo extends LitElement { )} - ${JS_VERSION}${JS_TYPE !== "latest" ? ` ⸱ ${JS_TYPE}` : ""} + ${JS_VERSION}${JS_TYPE !== "modern" ? ` ⸱ ${JS_TYPE}` : ""} + +
Proud part of
+ + Open Home Foundation + +
+ ${PAGES.map( @@ -272,6 +283,16 @@ class HaConfigInfo extends LitElement { margin: 24px; } + .ohf { + text-align: center; + padding-bottom: 0; + } + + .ohf img { + width: 100%; + max-width: 250px; + } + .versions { display: flex; flex-direction: column; diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index 41c2bf18a7..8945c62659 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -108,6 +108,7 @@ import { documentationUrl } from "../../../util/documentation-url"; import { fileDownload } from "../../../util/file_download"; import { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import { showAddIntegrationDialog } from "./show-add-integration-dialog"; +import { fetchEntitySourcesWithCache } from "../../../data/entity_sources"; @customElement("ha-config-integration-page") class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { @@ -140,6 +141,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { window.location.hash.substring(1) ); + @state() private _domainEntities: Record = {}; + private _configPanel = memoizeOne( (domain: string, panels: HomeAssistant["panels"]): string | undefined => Object.values(panels).find( @@ -185,9 +188,25 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._extraConfigEntries = undefined; this._fetchManifest(); this._fetchDiagnostics(); + this._fetchEntitySources(); } } + private async _fetchEntitySources() { + const entitySources = await fetchEntitySourcesWithCache(this.hass); + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); + } + + this._domainEntities = entitiesByDomain; + } + protected updated(changed: PropertyValues) { super.updated(changed); if ( @@ -245,6 +264,22 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { const devices = this._getDevices(configEntries, this.hass.devices); const entities = this._getEntities(configEntries, this._entities); + let numberOfEntities = entities.length; + + if ( + this.domain in this._domainEntities && + numberOfEntities !== this._domainEntities[this.domain].length + ) { + if (!numberOfEntities) { + numberOfEntities = this._domainEntities[this.domain].length; + } else { + const entityIds = new Set(entities.map((entity) => entity.entity_id)); + for (const entityId of this._domainEntities[this.domain]) { + entityIds.add(entityId); + } + numberOfEntities = entityIds.size; + } + } const services = !devices.some((device) => device.entry_type !== "service"); @@ -320,7 +355,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ` : ""} - ${entities.length > 0 + ${numberOfEntities > 0 ? html` @@ -331,7 +366,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { > ${this.hass.localize( `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } + { count: numberOfEntities } )} @@ -503,9 +538,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {

${normalEntries.length === 0 ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.integration_page.no_entries" - )} + ${this._manifest && + !this._manifest.config_flow && + this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) + ? this.hass.localize( + "ui.panel.config.integrations.integration_page.yaml_entry" + ) + : this.hass.localize( + "ui.panel.config.integrations.integration_page.no_entries" + )}
` : nothing} @@ -683,7 +726,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { const configPanel = this._configPanel(item.domain, this.hass.panels); - return html` { + entry_id?: string; localized_domain_name?: string; } @@ -114,6 +116,8 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { @state() private _manifests: Record = {}; + @state() private _domainEntities: Record = {}; + private _extraFetchedManifests?: Set; @state() private _showIgnored = false; @@ -149,13 +153,57 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { private _filterConfigEntries = memoizeOne( ( + components: string[], + manifests: Record, configEntries: ConfigEntryExtended[], + localize: HomeAssistant["localize"], filter?: string ): [ [string, ConfigEntryExtended[]][], ConfigEntryExtended[], ConfigEntryExtended[], ] => { + const entryDomains = new Set(configEntries.map((entry) => entry.domain)); + + const domains = new Set(); + + for (const component of components) { + const componentDomain = component.split(".")[0]; + if ( + !entryDomains.has(componentDomain) && + manifests[componentDomain] && + !manifests[componentDomain].config_flow && + (!manifests[componentDomain].integration_type || + ["device", "hub", "service", "integration"].includes( + manifests[componentDomain].integration_type! + )) + ) { + domains.add(componentDomain); + } + } + + const nonConfigEntry: ConfigEntryExtended[] = [...domains].map( + (domain) => ({ + domain, + localized_domain_name: domainToName(localize, domain), + title: domain, + source: "yaml", + state: "loaded", + supports_options: false, + supports_remove_device: false, + supports_unload: false, + supports_reconfigure: false, + pref_disable_new_entities: false, + pref_disable_polling: false, + disabled_by: null, + reason: null, + error_reason_translation_key: null, + error_reason_translation_placeholders: null, + }) + ); + + const allEntries = [...configEntries, ...nonConfigEntry]; + let filteredConfigEntries: ConfigEntryExtended[]; const ignored: ConfigEntryExtended[] = []; const disabled: ConfigEntryExtended[] = []; @@ -167,12 +215,12 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { minMatchCharLength: Math.min(filter.length, 2), threshold: 0.2, }; - const fuse = new Fuse(configEntries, options); + const fuse = new Fuse(allEntries, options); filteredConfigEntries = fuse .search(filter) .map((result) => result.item); } else { - filteredConfigEntries = configEntries; + filteredConfigEntries = allEntries; } for (const entry of filteredConfigEntries) { @@ -232,6 +280,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { protected firstUpdated(changed: PropertyValues) { super.firstUpdated(changed); this._fetchManifests(); + this._fetchEntitySources(); if (this.route.path === "/add") { this._handleAdd(); } @@ -266,6 +315,11 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { this.configEntriesInProgress.map((flow) => flow.handler) ); } + if (changed.has("configEntries") && this.configEntries) { + this._fetchIntegrationManifests( + this.configEntries.map((entry) => entry.domain) + ); + } } protected render() { @@ -276,7 +330,13 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { >`; } const [integrations, ignoredConfigEntries, disabledConfigEntries] = - this._filterConfigEntries(this.configEntries, this._filter); + this._filterConfigEntries( + this.hass.config.components, + this._manifests, + this.configEntries, + this.hass.localize, + this._filter + ); const configEntriesInProgress = this._filterConfigEntriesInProgress( this.configEntriesInProgress, this._filter @@ -463,6 +523,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { .items=${items} .manifest=${this._manifests[domain]} .entityRegistryEntries=${this._entityRegistryEntries} + .domainEntities=${this._domainEntities[domain] || []} .supportsDiagnostics=${this._diagnosticHandlers ? this._diagnosticHandlers[domain] : false} @@ -552,6 +613,21 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { await scanUSBDevices(this.hass); } + private async _fetchEntitySources() { + const entitySources = await fetchEntitySourcesWithCache(this.hass); + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); + } + + this._domainEntities = entitiesByDomain; + } + private async _fetchManifests(integrations?: string[]) { const fetched = await fetchIntegrationManifests(this.hass, integrations); // Make a copy so we can keep track of previously loaded manifests @@ -593,6 +669,11 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { showAddIntegrationDialog(this, { initialFilter: this._filter, }); + if (this.hass.auth.external?.config.canSetupImprov) { + this.hass.auth.external!.fireMessage({ + type: "improv/scan", + }); + } } private _handleMenuAction(ev: CustomEvent) { diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index aa218486ff..4a80dbb807 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -151,7 +151,10 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) { if (this.hasUpdated) { return; } - this._loadTranslationsPromise = this.hass.loadBackendTranslation("title"); + this._loadTranslationsPromise = this.hass.loadBackendTranslation( + "title", + this.hass.config.components.map((comp) => comp.split(".")[0]) + ); } protected updatePageEl(pageEl) { diff --git a/src/panels/config/integrations/ha-integration-card.ts b/src/panels/config/integrations/ha-integration-card.ts index 240e82ae2b..3e7cd7568f 100644 --- a/src/panels/config/integrations/ha-integration-card.ts +++ b/src/panels/config/integrations/ha-integration-card.ts @@ -1,5 +1,5 @@ import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { mdiCloud, mdiPackageVariant } from "@mdi/js"; +import { mdiCloud, mdiCodeBraces, mdiPackageVariant } from "@mdi/js"; import { CSSResultGroup, LitElement, @@ -46,6 +46,8 @@ export class HaIntegrationCard extends LitElement { @property({ attribute: false }) public logInfo?: IntegrationLogInfo; + @property({ attribute: false }) public domainEntities: string[] = []; + protected render(): TemplateResult { const entryState = this._getState(this.items); @@ -100,9 +102,13 @@ export class HaIntegrationCard extends LitElement { private _renderSingleEntry(): TemplateResult { const devices = this._getDevices(this.items, this.hass.devices); - const entities = devices.length - ? [] - : this._getEntities(this.items, this.entityRegistryEntries); + const entitiesCount = devices.length + ? 0 + : this._getEntityCount( + this.items, + this.entityRegistryEntries, + this.domainEntities + ); const services = !devices.some((device) => device.entry_type !== "service"); @@ -123,25 +129,32 @@ export class HaIntegrationCard extends LitElement { )}
` - : entities.length > 0 + : entitiesCount > 0 ? html` ${this.hass.localize( `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } + { count: entitiesCount } )} ` - : html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entries`, - { count: this.items.length } - )} - - `} + : this.items.find((itm) => itm.source !== "yaml") + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entries`, + { + count: this.items.filter((itm) => itm.source !== "yaml") + .length, + } + )} + + ` + : html`
`}
${this.manifest && !this.manifest.is_built_in ? html` @@ -169,6 +182,19 @@ export class HaIntegrationCard extends LitElement { >
` : nothing} + ${this.manifest && !this.manifest?.config_flow + ? html`
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.no_config_flow" + )} +
` + : nothing}
`; @@ -190,19 +216,42 @@ export class HaIntegrationCard extends LitElement { } ); - private _getEntities = memoizeOne( + private _getEntityCount = memoizeOne( ( configEntry: ConfigEntry[], - entityRegistryEntries: EntityRegistryEntry[] - ): EntityRegistryEntry[] => { + entityRegistryEntries: EntityRegistryEntry[], + domainEntities: string[] + ): number => { if (!entityRegistryEntries) { - return []; + return domainEntities.length; } - const entryIds = configEntry.map((entry) => entry.entry_id); - return entityRegistryEntries.filter( + + const entryIds = configEntry + .map((entry) => entry.entry_id) + .filter(Boolean); + + if (!entryIds.length) { + return domainEntities.length; + } + + const entityRegEntities = entityRegistryEntries.filter( (entity) => entity.config_entry_id && entryIds.includes(entity.config_entry_id) ); + + if (entityRegEntities.length === domainEntities.length) { + return domainEntities.length; + } + + const entityIds = new Set( + entityRegEntities.map((reg) => reg.entity_id) + ); + + for (const entity of domainEntities) { + entityIds.add(entity); + } + + return entityIds.size; } ); @@ -308,6 +357,9 @@ export class HaIntegrationCard extends LitElement { .icon.custom { background: var(--warning-color); } + .icon.yaml { + background: var(--label-badge-grey); + } .icon ha-svg-icon { width: 16px; height: 16px; @@ -316,6 +368,9 @@ export class HaIntegrationCard extends LitElement { simple-tooltip { white-space: nowrap; } + .spacer { + height: 36px; + } `, ]; } diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 3d99a4f9e3..aa4a98a867 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -28,6 +28,7 @@ import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagno import { OTBRCreateNetwork, OTBRInfo, + OTBRInfoDict, OTBRSetChannel, OTBRSetNetwork, getOTBRInfo, @@ -75,7 +76,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @state() private _datasets: ThreadDataSet[] = []; - @state() private _otbrInfo?: OTBRInfo; + @state() private _otbrInfo?: OTBRInfoDict; protected render(): TemplateResult { const networks = this._groupRoutersByNetwork(this._routers, this._datasets); @@ -160,25 +161,36 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } private _renderNetwork(network: ThreadNetwork) { + const otbrForNetwork = + this._otbrInfo && + network.dataset && + ((network.dataset.preferred_extended_address && + this._otbrInfo[network.dataset.preferred_extended_address]) || + Object.values(this._otbrInfo).find( + (otbr) => otbr.extended_pan_id === network.dataset!.extended_pan_id + )); const canImportKeychain = this.hass.auth.external?.config.canTransferThreadCredentialsToKeychain && - network.dataset?.extended_pan_id && - this._otbrInfo && - this._otbrInfo?.active_dataset_tlvs?.includes( - network.dataset.extended_pan_id - ); + otbrForNetwork; return html`
${network.name}${network.dataset ? html`
${!network.dataset.preferred && !network.routers?.length ? html`
${network.routers.map((router) => { + const otbr = + this._otbrInfo && this._otbrInfo[router.extended_address]; const showOverflow = - ("dataset" in network && router.border_agent_id) || - router.extended_address === this._otbrInfo?.extended_address; + ("dataset" in network && router.border_agent_id) || otbr; return html` ` : ""} - ${router.extended_address === - this._otbrInfo?.extended_address + ${otbr ? html` ${this.hass.localize( "ui.panel.config.thread.reset_border_router" @@ -288,14 +301,13 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { })}` : html`
- ${network.dataset?.extended_pan_id && - this._otbrInfo?.active_dataset_tlvs?.includes( - network.dataset.extended_pan_id - ) + ${otbrForNetwork ? html`${this.hass.localize( "ui.panel.config.thread.no_routers_otbr_network" )} - ${this.hass.localize( "ui.panel.config.thread.reset_border_router" )} - Send credentials to phone
` @@ -321,23 +333,25 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { `; } - private _sendCredentials() { - if (!this._otbrInfo) { + private _sendCredentials(ev) { + const otbr = (ev.currentTarget as any).otbr as OTBRInfo; + if (!otbr) { return; } this.hass.auth.external!.fireMessage({ type: "thread/store_in_platform_keychain", payload: { - mac_extended_address: this._otbrInfo.extended_address, - border_agent_id: this._otbrInfo.border_agent_id ?? "", - active_operational_dataset: this._otbrInfo.active_dataset_tlvs ?? "", + mac_extended_address: otbr.extended_address, + border_agent_id: otbr.border_agent_id ?? "", + active_operational_dataset: otbr.active_dataset_tlvs ?? "", }, }); } private async _showDatasetInfo(ev: Event) { const network = (ev.currentTarget as any).network as ThreadNetwork; - showThreadDatasetDialog(this, { network, otbrInfo: this._otbrInfo }); + const otbr = (ev.currentTarget as any).otbr as OTBRInfo; + showThreadDatasetDialog(this, { network, otbrInfo: otbr }); } private _importExternalThreadCredentials() { @@ -454,6 +468,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { private _handleRouterAction(ev: CustomEvent) { const network = (ev.currentTarget as any).network as ThreadNetwork; const router = (ev.currentTarget as any).router as ThreadRouter; + const otbr = (ev.currentTarget as any).otbr as OTBRInfo; const index = network.dataset && router.border_agent_id ? Number(ev.detail.index) @@ -463,18 +478,23 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { this._setPreferredBorderAgent(network.dataset!, router); break; case 1: - this._resetBorderRouter(); + this._resetBorderRouter(otbr); break; case 2: - this._changeChannel(); + this._changeChannel(otbr); break; case 3: - this._setDataset(); + this._setDataset(otbr); break; } } - private async _resetBorderRouter() { + private _resetBorderRouterEvent(ev) { + const otbr = (ev.currentTarget as any).otbr as OTBRInfo; + this._resetBorderRouter(otbr); + } + + private async _resetBorderRouter(otbr: OTBRInfo) { const confirm = await showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.thread.confirm_reset_border_router" @@ -487,7 +507,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { return; } try { - await OTBRCreateNetwork(this.hass); + await OTBRCreateNetwork(this.hass, otbr.extended_address); } catch (err: any) { showAlertDialog(this, { title: this.hass.localize("ui.panel.config.thread.otbr_config_failed"), @@ -497,7 +517,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { this._refresh(); } - private async _setDataset() { + private async _setDataset(otbr: OTBRInfo) { const networks = this._groupRoutersByNetwork(this._routers, this._datasets); const preferedDatasetId = networks.preferred?.dataset?.dataset_id; if (!preferedDatasetId) { @@ -515,7 +535,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { return; } try { - await OTBRSetNetwork(this.hass, preferedDatasetId); + await OTBRSetNetwork(this.hass, otbr.extended_address, preferedDatasetId); } catch (err: any) { showAlertDialog(this, { title: this.hass.localize("ui.panel.config.thread.otbr_config_failed"), @@ -595,8 +615,8 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { this._refresh(); } - private async _changeChannel() { - const currentChannel = this._otbrInfo?.channel; + private async _changeChannel(otbr: OTBRInfo) { + const currentChannel = otbr.channel; const channelStr = await showPromptDialog(this, { title: this.hass.localize("ui.panel.config.thread.change_channel"), text: this.hass.localize("ui.panel.config.thread.change_channel_text"), @@ -623,7 +643,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { return; } try { - const result = await OTBRSetChannel(this.hass, channel); + const result = await OTBRSetChannel( + this.hass, + otbr.extended_address, + channel + ); showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.thread.change_channel_initiated_title" diff --git a/src/panels/config/integrations/integration-panels/zha/zha-cluster-attributes.ts b/src/panels/config/integrations/integration-panels/zha/zha-cluster-attributes.ts index e3462fd69e..9b628d4699 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-cluster-attributes.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-cluster-attributes.ts @@ -134,7 +134,7 @@ export class ZHAClusterAttributes extends LitElement { .hass=${this.hass} domain="zha" service="set_zigbee_cluster_attribute" - .serviceData=${this._setAttributeServiceData} + .data=${this._setAttributeServiceData} > ${this.hass!.localize( "ui.panel.config.zha.cluster_attributes.write_zigbee_attribute" diff --git a/src/panels/config/integrations/integration-panels/zha/zha-cluster-commands.ts b/src/panels/config/integrations/integration-panels/zha/zha-cluster-commands.ts index d6932b10ff..4ea3417261 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-cluster-commands.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-cluster-commands.ts @@ -115,7 +115,7 @@ export class ZHAClusterCommands extends LitElement { .hass=${this.hass} domain="zha" service="issue_zigbee_cluster_command" - .serviceData=${this._issueClusterCommandServiceData} + .data=${this._issueClusterCommandServiceData} .disabled=${!this._canIssueCommand} > ${this.hass!.localize( diff --git a/src/panels/config/integrations/integration-panels/zha/zha-clusters-data-table.ts b/src/panels/config/integrations/integration-panels/zha/zha-clusters-data-table.ts index 1b14b676a8..2d97719771 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-clusters-data-table.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-clusters-data-table.ts @@ -44,7 +44,7 @@ export class ZHAClustersDataTable extends LitElement { title: "Name", sortable: true, direction: "asc", - grows: true, + flex: 2, }, } : { @@ -52,18 +52,16 @@ export class ZHAClustersDataTable extends LitElement { title: "Name", sortable: true, direction: "asc", - grows: true, + flex: 2, }, id: { title: "ID", template: (cluster) => html` ${formatAsPaddedHex(cluster.id)} `, sortable: true, - width: "25%", }, endpoint_id: { title: "Endpoint ID", sortable: true, - width: "15%", }, } ); diff --git a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts index 695a9bd9dc..78a0ae9ef4 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts @@ -6,7 +6,6 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; import { computeStateName } from "../../../../../common/entity/compute_state_name"; import { stringCompare } from "../../../../../common/string/compare"; import { slugify } from "../../../../../common/string/slugify"; -import "../../../../../components/buttons/ha-call-service-button"; import "../../../../../components/entity/state-badge"; import "../../../../../components/ha-area-picker"; import "../../../../../components/ha-card"; diff --git a/src/panels/config/integrations/integration-panels/zha/zha-device-endpoint-data-table.ts b/src/panels/config/integrations/integration-panels/zha/zha-device-endpoint-data-table.ts index d68fdb0b1e..18a265c16c 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-device-endpoint-data-table.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-device-endpoint-data-table.ts @@ -65,7 +65,7 @@ export class ZHADeviceEndpointDataTable extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, template: (device) => html` ${device.name} @@ -84,7 +84,7 @@ export class ZHADeviceEndpointDataTable extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, template: (device) => html` ${device.name} @@ -100,7 +100,7 @@ export class ZHADeviceEndpointDataTable extends LitElement { title: "Associated Entities", sortable: false, filterable: false, - width: "50%", + flex: 2, template: (device) => html` ${device.entities.length ? device.entities.length > 3 diff --git a/src/panels/config/integrations/integration-panels/zha/zha-device-neighbors.ts b/src/panels/config/integrations/integration-panels/zha/zha-device-neighbors.ts index 0774f8b343..98435bae27 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-device-neighbors.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-device-neighbors.ts @@ -69,14 +69,13 @@ class ZHADeviceNeighbors extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, lqi: { title: this.hass.localize("ui.panel.config.zha.neighbors.lqi"), sortable: true, filterable: true, type: "numeric", - width: "75px", }, } : { @@ -85,14 +84,13 @@ class ZHADeviceNeighbors extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, lqi: { title: this.hass.localize("ui.panel.config.zha.neighbors.lqi"), sortable: true, filterable: true, type: "numeric", - width: "75px", }, relationship: { title: this.hass.localize( @@ -100,14 +98,12 @@ class ZHADeviceNeighbors extends LitElement { ), sortable: true, filterable: true, - width: "150px", }, depth: { title: this.hass.localize("ui.panel.config.zha.neighbors.depth"), sortable: true, filterable: true, type: "numeric", - width: "75px", }, } ); diff --git a/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts b/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts index d21ae02aea..e6559f63b0 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts @@ -79,7 +79,7 @@ export class ZHAGroupsDashboard extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, } : { @@ -88,19 +88,17 @@ export class ZHAGroupsDashboard extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, }, group_id: { title: this.hass.localize("ui.panel.config.zha.groups.group_id"), type: "numeric", - width: "15%", template: (group) => html` ${formatAsPaddedHex(group.group_id)} `, sortable: true, }, members: { title: this.hass.localize("ui.panel.config.zha.groups.members"), type: "numeric", - width: "15%", template: (group) => html` ${group.members.length} `, sortable: true, }, diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts index 93539a507e..df0600d788 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts @@ -47,7 +47,6 @@ class ZWaveJSProvisioned extends LitElement { "ui.panel.config.zwave_js.provisioned.included" ), type: "icon", - width: "100px", template: (entry) => entry.nodeId ? html` @@ -71,13 +70,12 @@ class ZWaveJSProvisioned extends LitElement { title: this.hass.localize("ui.panel.config.zwave_js.provisioned.dsk"), sortable: true, filterable: true, - grows: true, + flex: 2, }, security_classes: { title: this.hass.localize( "ui.panel.config.zwave_js.provisioned.security_classes" ), - width: "30%", hidden: narrow, filterable: true, sortable: true, @@ -97,7 +95,6 @@ class ZWaveJSProvisioned extends LitElement { "ui.panel.config.zwave_js.provisioned.unprovison" ), type: "icon-button", - width: "100px", template: (entry) => html` { + private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => { const columns: DataTableColumnContainer = { icon: { title: "", @@ -110,22 +111,60 @@ export class HaConfigLabels extends LitElement { name: { title: localize("ui.panel.config.labels.headers.name"), main: true, + flex: 2, sortable: true, filterable: true, - grows: true, - template: (label) => html` -
${label.name}
- ${label.description - ? html`
${label.description}
` - : nothing} - `, + template: narrow + ? undefined + : (label) => html` +
${label.name}
+ ${label.description + ? html`
${label.description}
` + : nothing} + `, + }, + description: { + title: localize("ui.panel.config.labels.headers.description"), + hidden: !narrow, + filterable: true, + hideable: true, + }, + created_at: { + title: localize("ui.panel.config.generic.headers.created_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (label) => + label.created_at + ? formatShortDateTime( + new Date(label.created_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", + }, + modified_at: { + title: localize("ui.panel.config.generic.headers.modified_at"), + defaultHidden: true, + sortable: true, + filterable: true, + minWidth: "128px", + template: (label) => + label.modified_at + ? formatShortDateTime( + new Date(label.modified_at * 1000), + this.hass.locale, + this.hass.config + ) + : "—", }, actions: { title: "", + label: localize("ui.panel.config.generic.headers.actions"), showNarrow: true, moveable: false, hideable: false, - width: "64px", type: "overflow-menu", template: (label) => html` html` @@ -171,7 +171,6 @@ export class HaConfigLovelaceDashboards extends LitElement { ), sortable: true, filterable: true, - width: "20%", template: (dashboard) => html` ${this.hass.localize( `ui.panel.config.lovelace.dashboards.conf_mode.${dashboard.mode}` @@ -183,7 +182,6 @@ export class HaConfigLovelaceDashboards extends LitElement { title: localize( "ui.panel.config.lovelace.dashboards.picker.headers.filename" ), - width: "15%", sortable: true, filterable: true, }; @@ -195,7 +193,6 @@ export class HaConfigLovelaceDashboards extends LitElement { sortable: true, type: "icon", hidden: narrow, - width: "100px", template: (dashboard) => dashboard.require_admin ? html`` @@ -207,7 +204,6 @@ export class HaConfigLovelaceDashboards extends LitElement { ), type: "icon", hidden: narrow, - width: "121px", template: (dashboard) => dashboard.show_in_sidebar ? html`` @@ -221,7 +217,6 @@ export class HaConfigLovelaceDashboards extends LitElement { ), filterable: true, showNarrow: true, - width: "100px", template: (dashboard) => narrow ? html` diff --git a/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts b/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts index 2c91a30a88..d43d572cfd 100644 --- a/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts +++ b/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts @@ -94,7 +94,7 @@ export class HaConfigLovelaceRescources extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, forceLTR: true, }, type: { @@ -103,7 +103,6 @@ export class HaConfigLovelaceRescources extends LitElement { ), sortable: true, filterable: true, - width: "30%", template: (resource) => html` ${this.hass.localize( `ui.panel.config.lovelace.resources.types.${resource.type}` diff --git a/src/panels/config/repairs/dialog-repairs-issue.ts b/src/panels/config/repairs/dialog-repairs-issue.ts index 7baf58f32d..ece7a219f7 100644 --- a/src/panels/config/repairs/dialog-repairs-issue.ts +++ b/src/panels/config/repairs/dialog-repairs-issue.ts @@ -79,7 +79,8 @@ class DialogRepairsIssue extends LitElement { this._issue.translation_key || this._issue.issue_id }.description`, this._issue.translation_placeholders - )} + ) || + `${this._issue.domain}: ${this._issue.translation_key || this._issue.issue_id}`} > ${this._issue.dismissed_version ? html` diff --git a/src/panels/config/repairs/ha-config-repairs.ts b/src/panels/config/repairs/ha-config-repairs.ts index db112f0d0b..4e46d47741 100644 --- a/src/panels/config/repairs/ha-config-repairs.ts +++ b/src/panels/config/repairs/ha-config-repairs.ts @@ -74,7 +74,8 @@ class HaConfigRepairs extends LitElement { issue.translation_key || issue.issue_id }.title`, issue.translation_placeholders || {} - )} ${issue.severity === "critical" || issue.severity === "error" diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index b889dc1094..179ee15fac 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -260,7 +260,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, extraTemplate: (scene) => scene.labels.length ? html` { const lastActivated = scene.state; if (!lastActivated || isUnavailableState(lastActivated)) { @@ -312,7 +311,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { }, only_editable: { title: "", - width: "56px", + type: "icon", showNarrow: true, template: (scene) => !scene.attributes.id @@ -331,7 +330,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { }, actions: { title: "", - width: "64px", type: "overflow-menu", showNarrow: true, moveable: false, diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index d35aed5f90..8e1767a2c9 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -270,7 +270,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, extraTemplate: (script) => script.labels.length ? html` { const date = new Date(script.last_triggered); @@ -322,7 +321,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { }, actions: { title: "", - width: "64px", type: "overflow-menu", showNarrow: true, moveable: false, diff --git a/src/panels/config/tags/ha-config-tags.ts b/src/panels/config/tags/ha-config-tags.ts index 933490adfe..66eded944c 100644 --- a/src/panels/config/tags/ha-config-tags.ts +++ b/src/panels/config/tags/ha-config-tags.ts @@ -81,13 +81,12 @@ export class HaConfigTags extends SubscribeMixin(LitElement) { main: true, sortable: true, filterable: true, - grows: true, + flex: 2, }, last_scanned_datetime: { title: localize("ui.panel.config.tag.headers.last_scanned"), sortable: true, direction: "desc", - width: "20%", template: (tag) => html` ${tag.last_scanned_datetime ? html` html`${user.username || "—"}`, }, @@ -100,7 +98,6 @@ export class HaConfigUsers extends LitElement { sortable: true, filterable: true, groupable: true, - width: "20%", direction: "asc", }, is_active: { @@ -110,7 +107,6 @@ export class HaConfigUsers extends LitElement { type: "icon", sortable: true, filterable: true, - width: "80px", hidden: narrow, template: (user) => user.is_active @@ -124,7 +120,6 @@ export class HaConfigUsers extends LitElement { type: "icon", sortable: true, filterable: true, - width: "80px", hidden: narrow, template: (user) => user.system_generated @@ -138,7 +133,6 @@ export class HaConfigUsers extends LitElement { type: "icon", sortable: true, filterable: true, - width: "80px", hidden: narrow, template: (user) => user.local_only @@ -153,7 +147,7 @@ export class HaConfigUsers extends LitElement { type: "icon", sortable: false, filterable: false, - width: "104px", + minWidth: "104px", hidden: !narrow, showNarrow: true, template: (user) => { diff --git a/src/panels/config/voice-assistants/assist/ha-config-voice-assistants-assist-devices.ts b/src/panels/config/voice-assistants/assist/ha-config-voice-assistants-assist-devices.ts index 9c9e32d2e0..5e3f250d90 100644 --- a/src/panels/config/voice-assistants/assist/ha-config-voice-assistants-assist-devices.ts +++ b/src/panels/config/voice-assistants/assist/ha-config-voice-assistants-assist-devices.ts @@ -42,13 +42,12 @@ class AssistDevicesPage extends LitElement { ), filterable: true, sortable: true, - grows: true, + flex: 2, }, pipeline: { title: localize( "ui.panel.config.voice_assistants.assistants.pipeline.devices.pipeline" ), - width: "30%", filterable: true, sortable: true, }, @@ -58,7 +57,6 @@ class AssistDevicesPage extends LitElement { ), filterable: true, sortable: true, - width: "30%", }, }; diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts index 7a2b5e9c49..dfa40a6f83 100644 --- a/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts @@ -167,7 +167,7 @@ export class VoiceAssistantsExpose extends LitElement { sortable: true, filterable: true, direction: "asc", - grows: true, + flex: 2, template: narrow ? undefined : (entry) => html` @@ -197,7 +197,6 @@ export class VoiceAssistantsExpose extends LitElement { sortable: true, groupable: true, filterable: true, - width: "15%", }, assistants: { title: localize( @@ -206,7 +205,8 @@ export class VoiceAssistantsExpose extends LitElement { showNarrow: true, sortable: true, filterable: true, - width: "160px", + minWidth: "160px", + maxWidth: "160px", type: "flex", template: (entry) => html`${availableAssistants.map((key) => { @@ -233,7 +233,6 @@ export class VoiceAssistantsExpose extends LitElement { ), sortable: true, filterable: true, - width: "15%", template: (entry) => entry.aliases.length === 0 ? "-" diff --git a/src/panels/developer-tools/service/developer-tools-service.ts b/src/panels/developer-tools/action/developer-tools-action.ts similarity index 80% rename from src/panels/developer-tools/service/developer-tools-service.ts rename to src/panels/developer-tools/action/developer-tools-action.ts index fb5f374f21..ccd8b947dd 100644 --- a/src/panels/developer-tools/service/developer-tools-service.ts +++ b/src/panels/developer-tools/action/developer-tools-action.ts @@ -2,7 +2,7 @@ import { mdiHelpCircle } from "@mdi/js"; import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket"; import { load } from "js-yaml"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; -import { property, query, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { storage } from "../../../common/decorators/storage"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -11,10 +11,13 @@ import { hasTemplate } from "../../../common/string/has-template"; import { extractSearchParam } from "../../../common/url/search-params"; import { HaProgressButton } from "../../../components/buttons/ha-progress-button"; import { LocalizeFunc } from "../../../common/translations/localize"; +import { showToast } from "../../../util/toast"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; import "../../../components/entity/ha-entity-picker"; import "../../../components/ha-card"; import "../../../components/ha-alert"; +import "../../../components/ha-button"; import "../../../components/ha-expansion-panel"; import "../../../components/ha-icon-button"; import "../../../components/ha-service-control"; @@ -22,7 +25,11 @@ import "../../../components/ha-service-picker"; import "../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../components/ha-yaml-editor"; import { forwardHaptic } from "../../../data/haptics"; -import { Action, ServiceAction } from "../../../data/script"; +import { + Action, + migrateAutomationAction, + ServiceAction, +} from "../../../data/script"; import { callExecuteScript, serviceCallWillDisconnect, @@ -31,7 +38,8 @@ import { haStyle } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; -class HaPanelDevService extends LitElement { +@customElement("developer-tools-action") +class HaPanelDevAction extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public narrow = false; @@ -45,14 +53,14 @@ class HaPanelDevService extends LitElement { private _yamlValid = true; @storage({ - key: "panel-dev-service-state-service-data", + key: "panel-dev-action-state-service-data", state: true, subscribe: false, }) - private _serviceData?: ServiceAction = { service: "", target: {}, data: {} }; + private _serviceData?: ServiceAction = { action: "", target: {}, data: {} }; @storage({ - key: "panel-dev-service-state-yaml-mode", + key: "panel-dev-action-state-yaml-mode", state: true, subscribe: false, }) @@ -68,7 +76,7 @@ class HaPanelDevService extends LitElement { const serviceParam = extractSearchParam("service"); if (serviceParam) { this._serviceData = { - service: serviceParam, + action: serviceParam, target: {}, data: {}, }; @@ -77,11 +85,11 @@ class HaPanelDevService extends LitElement { this._yamlEditor?.setValue(this._serviceData) ); } - } else if (!this._serviceData?.service) { + } else if (!this._serviceData?.action) { const domain = Object.keys(this.hass.services).sort()[0]; const service = Object.keys(this.hass.services[domain]).sort()[0]; this._serviceData = { - service: `${domain}.${service}`, + action: `${domain}.${service}`, target: {}, data: {}, }; @@ -97,22 +105,22 @@ class HaPanelDevService extends LitElement { protected render() { const { target, fields } = this._fields( this.hass.services, - this._serviceData?.service + this._serviceData?.action ); - const domain = this._serviceData?.service - ? computeDomain(this._serviceData?.service) + const domain = this._serviceData?.action + ? computeDomain(this._serviceData?.action) : undefined; - const serviceName = this._serviceData?.service - ? computeObjectId(this._serviceData?.service) + const serviceName = this._serviceData?.action + ? computeObjectId(this._serviceData?.action) : undefined; return html`

${this.hass.localize( - "ui.panel.developer-tools.tabs.services.description" + "ui.panel.developer-tools.tabs.actions.description" )}

@@ -120,7 +128,7 @@ class HaPanelDevService extends LitElement { ? html`
${this._yamlMode ? this.hass.localize( - "ui.panel.developer-tools.tabs.services.ui_mode" + "ui.panel.developer-tools.tabs.actions.ui_mode" ) : this.hass.localize( - "ui.panel.developer-tools.tabs.services.yaml_mode" + "ui.panel.developer-tools.tabs.actions.yaml_mode" )} ${!this._uiAvailable ? html`${this.hass.localize( - "ui.panel.developer-tools.tabs.services.no_template_ui_support" + "ui.panel.developer-tools.tabs.actions.no_template_ui_support" )}` : ""}
${this.hass.localize( - "ui.panel.developer-tools.tabs.services.call_service" + "ui.panel.developer-tools.tabs.actions.call_service" )}
@@ -179,7 +187,7 @@ class HaPanelDevService extends LitElement { ? html`
@@ -188,8 +196,15 @@ class HaPanelDevService extends LitElement { copyClipboard readOnly autoUpdate + hasExtraActions .value=${this._response} - > + > + ${this.hass.localize( + "ui.panel.developer-tools.tabs.actions.copy_clipboard_template" + )} +
` @@ -199,10 +214,10 @@ class HaPanelDevService extends LitElement { - ${this._serviceData?.service + ${this._serviceData?.action ? html`
${this.hass.localize( - "ui.panel.developer-tools.tabs.services.column_parameter" + "ui.panel.developer-tools.tabs.actions.column_parameter" )} ${this.hass.localize( - "ui.panel.developer-tools.tabs.services.column_description" + "ui.panel.developer-tools.tabs.actions.column_description" )} ${this.hass.localize( - "ui.panel.developer-tools.tabs.services.column_example" + "ui.panel.developer-tools.tabs.actions.column_example" )} @@ -281,7 +296,7 @@ class HaPanelDevService extends LitElement { ${this._yamlMode ? html`${this.hass.localize( - "ui.panel.developer-tools.tabs.services.fill_example_data" + "ui.panel.developer-tools.tabs.actions.fill_example_data" )}` : ""} @@ -291,28 +306,37 @@ class HaPanelDevService extends LitElement { `; } + private async _copyTemplate(): Promise { + await copyToClipboard( + `{% set action_response = ${JSON.stringify(this._response)} %}` + ); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + private _filterSelectorFields = memoizeOne((fields) => fields.filter((field) => !field.selector) ); private _validateServiceData = ( - serviceData, + serviceData: ServiceAction | undefined, fields, target, yamlMode: boolean, localize: LocalizeFunc ): string | undefined => { const errorCategory = yamlMode ? "yaml" : "ui"; - if (!serviceData?.service) { + if (!serviceData?.action) { return localize( - `ui.panel.developer-tools.tabs.services.errors.${errorCategory}.no_service` + `ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.no_action` ); } - const domain = computeDomain(serviceData.service); - const service = computeObjectId(serviceData.service); + const domain = computeDomain(serviceData.action); + const service = computeObjectId(serviceData.action); if (!domain || !service) { return localize( - `ui.panel.developer-tools.tabs.services.errors.${errorCategory}.invalid_service` + `ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.invalid_action` ); } if ( @@ -323,7 +347,7 @@ class HaPanelDevService extends LitElement { !serviceData.data?.area_id ) { return localize( - `ui.panel.developer-tools.tabs.services.errors.${errorCategory}.no_target` + `ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.no_target` ); } for (const field of fields) { @@ -332,7 +356,7 @@ class HaPanelDevService extends LitElement { (!serviceData.data || serviceData.data[field.key] === undefined) ) { return localize( - `ui.panel.developer-tools.tabs.services.errors.${errorCategory}.missing_required_field`, + `ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.missing_required_field`, { key: field.key } ); } @@ -377,14 +401,14 @@ class HaPanelDevService extends LitElement { forwardHaptic("failure"); button.actionError(); this._error = this.hass.localize( - "ui.panel.developer-tools.tabs.services.errors.yaml.invalid_yaml" + "ui.panel.developer-tools.tabs.actions.errors.yaml.invalid_yaml" ); return; } const { target, fields } = this._fields( this.hass.services, - this._serviceData?.service + this._serviceData?.action ); this._error = this._validateServiceData( @@ -400,7 +424,7 @@ class HaPanelDevService extends LitElement { button.actionError(); return; } - const [domain, service] = this._serviceData!.service!.split(".", 2); + const [domain, service] = this._serviceData!.action!.split(".", 2); const script: Action[] = []; if ( this.hass.services?.[domain]?.[service] && @@ -439,8 +463,8 @@ class HaPanelDevService extends LitElement { } this._error = localizedErrorMessage || - this.hass.localize("ui.notification_toast.service_call_failed", { - service: this._serviceData!.service!, + this.hass.localize("ui.notification_toast.action_failed", { + service: this._serviceData!.action!, }) + ` ${err.message}`; return; } @@ -465,7 +489,7 @@ class HaPanelDevService extends LitElement { private _checkUiSupported() { const fields = this._fields( this.hass.services, - this._serviceData?.service + this._serviceData?.action ).fields; if ( this._serviceData && @@ -492,16 +516,18 @@ class HaPanelDevService extends LitElement { } private _serviceDataChanged(ev) { - if (this._serviceData?.service !== ev.detail.value.service) { + if (this._serviceData?.action !== ev.detail.value.action) { this._error = undefined; } - this._serviceData = ev.detail.value; + this._serviceData = migrateAutomationAction( + ev.detail.value + ) as ServiceAction; this._checkUiSupported(); } private _serviceChanged(ev) { ev.stopPropagation(); - this._serviceData = { service: ev.detail.value || "", data: {} }; + this._serviceData = { action: ev.detail.value || "", data: {} }; this._response = undefined; this._error = undefined; this._yamlEditor?.setValue(this._serviceData); @@ -511,14 +537,14 @@ class HaPanelDevService extends LitElement { private _fillExampleData() { const { fields } = this._fields( this.hass.services, - this._serviceData?.service + this._serviceData?.action ); - const domain = this._serviceData?.service - ? computeDomain(this._serviceData?.service) + const domain = this._serviceData?.action + ? computeDomain(this._serviceData?.action) : undefined; - const serviceName = this._serviceData?.service - ? computeObjectId(this._serviceData?.service) + const serviceName = this._serviceData?.action + ? computeObjectId(this._serviceData?.action) : undefined; const example = {}; @@ -630,10 +656,8 @@ class HaPanelDevService extends LitElement { } } -customElements.define("developer-tools-service", HaPanelDevService); - declare global { interface HTMLElementTagNameMap { - "developer-tools-service": HaPanelDevService; + "developer-tools-action": HaPanelDevAction; } } diff --git a/src/panels/developer-tools/developer-tools-router.ts b/src/panels/developer-tools/developer-tools-router.ts index 6fdf6aa010..6018861ecb 100644 --- a/src/panels/developer-tools/developer-tools-router.ts +++ b/src/panels/developer-tools/developer-tools-router.ts @@ -24,9 +24,10 @@ class DeveloperToolsRouter extends HassRouterPage { tag: "developer-tools-event", load: () => import("./event/developer-tools-event"), }, - service: { - tag: "developer-tools-service", - load: () => import("./service/developer-tools-service"), + service: "action", + action: { + tag: "developer-tools-action", + load: () => import("./action/developer-tools-action"), }, state: { tag: "developer-tools-state", diff --git a/src/panels/developer-tools/event/developer-tools-event.ts b/src/panels/developer-tools/event/developer-tools-event.ts index cd29d3176d..a72b570e3a 100644 --- a/src/panels/developer-tools/event/developer-tools-event.ts +++ b/src/panels/developer-tools/event/developer-tools-event.ts @@ -1,8 +1,9 @@ import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; import { customElement, property, state } from "lit/decorators"; -import "@material/mwc-button"; import "../../../components/ha-yaml-editor"; import "../../../components/ha-textfield"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { documentationUrl } from "../../../util/documentation-url"; import "./event-subscribe-card"; @@ -31,49 +32,61 @@ class HaPanelDevEvent extends LitElement { : "content layout horizontal"} >
-

- ${this.hass.localize( - "ui.panel.developer-tools.tabs.events.description" - )} - - ${this.hass.localize( - "ui.panel.developer-tools.tabs.events.documentation" - )} - -

-
- -

- ${this.hass.localize("ui.panel.developer-tools.tabs.events.data")} -

-
-
- -
- ${this.hass.localize( - "ui.panel.developer-tools.tabs.events.fire_event" - )} + +
+

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.events.description" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.events.documentation" + )} + +

+
+ +

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.events.data" + )} +

+
+
+ +
+
+
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.events.fire_event" + )} +
+
+
diff --git a/src/panels/developer-tools/event/event-subscribe-card.ts b/src/panels/developer-tools/event/event-subscribe-card.ts index ada37484ce..564af64c4b 100644 --- a/src/panels/developer-tools/event/event-subscribe-card.ts +++ b/src/panels/developer-tools/event/event-subscribe-card.ts @@ -1,4 +1,3 @@ -import "@material/mwc-button"; import { HassEvent } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -7,6 +6,7 @@ import { formatTime } from "../../../common/datetime/format_time"; import "../../../components/ha-card"; import "../../../components/ha-textfield"; import "../../../components/ha-yaml-editor"; +import "../../../components/ha-button"; import { HomeAssistant } from "../../../types"; @customElement("event-subscribe-card") @@ -40,33 +40,46 @@ class EventSubscribeCard extends LitElement { )} >
-
- - - ${this._subscribed - ? this.hass!.localize( - "ui.panel.developer-tools.tabs.events.stop_listening" - ) - : this.hass!.localize( - "ui.panel.developer-tools.tabs.events.start_listening" - )} - -
+ +
+
+ + ${this._subscribed + ? this.hass!.localize( + "ui.panel.developer-tools.tabs.events.stop_listening" + ) + : this.hass!.localize( + "ui.panel.developer-tools.tabs.events.start_listening" + )} + + + ${this.hass!.localize( + "ui.panel.developer-tools.tabs.events.clear_events" + )} + +
+ + +
${repeat( this._events, @@ -99,7 +112,7 @@ class EventSubscribeCard extends LitElement { this._eventType = ev.target.value; } - private async _handleSubmit(): Promise { + private async _startOrStopListening(): Promise { if (this._subscribed) { this._subscribed(); this._subscribed = undefined; @@ -121,6 +134,11 @@ class EventSubscribeCard extends LitElement { } } + private _clearEvents(): void { + this._events = []; + this._eventCount = 0; + } + static get styles(): CSSResultGroup { return css` ha-textfield { @@ -140,6 +158,9 @@ class EventSubscribeCard extends LitElement { pre { font-family: var(--code-font-family, monospace); } + ha-card { + margin-bottom: 5px; + } `; } } diff --git a/src/panels/developer-tools/ha-panel-developer-tools.ts b/src/panels/developer-tools/ha-panel-developer-tools.ts index 667f016ea5..237e082724 100644 --- a/src/panels/developer-tools/ha-panel-developer-tools.ts +++ b/src/panels/developer-tools/ha-panel-developer-tools.ts @@ -62,10 +62,8 @@ class PanelDeveloperTools extends LitElement { ${this.hass.localize("ui.panel.developer-tools.tabs.states.title")} - - ${this.hass.localize( - "ui.panel.developer-tools.tabs.services.title" - )} + + ${this.hass.localize("ui.panel.developer-tools.tabs.actions.title")} ${this.hass.localize( diff --git a/src/panels/developer-tools/statistics/developer-tools-statistics.ts b/src/panels/developer-tools/statistics/developer-tools-statistics.ts index 518dce24b9..8d40dc0b42 100644 --- a/src/panels/developer-tools/statistics/developer-tools-statistics.ts +++ b/src/panels/developer-tools/statistics/developer-tools-statistics.ts @@ -89,9 +89,10 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { title: localize( "ui.panel.developer-tools.tabs.statistics.data_table.name" ), + main: true, sortable: true, filterable: true, - grows: true, + flex: 2, }, statistic_id: { title: localize( @@ -100,7 +101,6 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { sortable: true, filterable: true, hidden: this.narrow, - width: "20%", }, statistics_unit_of_measurement: { title: localize( @@ -108,7 +108,6 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { ), sortable: true, filterable: true, - width: "10%", forceLTR: true, }, source: { @@ -117,7 +116,6 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { ), sortable: true, filterable: true, - width: "10%", }, issues_string: { title: localize( @@ -126,7 +124,7 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { sortable: true, filterable: true, direction: "asc", - width: "30%", + flex: 2, template: (statistic) => html`${statistic.issues_string ?? localize("ui.panel.developer-tools.tabs.statistics.no_issue")}`, @@ -147,12 +145,15 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { )} ` : "—"}`, - width: "113px", + minWidth: "113px", + maxWidth: "113px", + showNarrow: true, }, actions: { title: "", label: localize("ui.panel.developer-tools.tabs.statistics.adjust_sum"), type: "icon-button", + showNarrow: true, template: (statistic) => statistic.has_sum ? html` @@ -179,6 +180,7 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { .noDataText=${this.hass.localize( "ui.panel.developer-tools.tabs.statistics.data_table.no_statistics" )} + .narrow=${this.narrow} id="statistic_id" clickable @row-click=${this._rowClicked} diff --git a/src/panels/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts new file mode 100644 index 0000000000..a6eee87ec2 --- /dev/null +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -0,0 +1,208 @@ +import { PropertyValues, ReactiveElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { MediaQueriesListener } from "../../../common/dom/media_query"; +import "../../../components/ha-svg-icon"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; +import type { HomeAssistant } from "../../../types"; +import { + attachConditionMediaQueriesListeners, + checkConditionsMet, +} from "../common/validate-condition"; +import { createBadgeElement } from "../create-element/create-badge-element"; +import { createErrorBadgeConfig } from "../create-element/create-element-base"; +import type { LovelaceBadge } from "../types"; + +declare global { + interface HASSDomEvents { + "badge-visibility-changed": { value: boolean }; + "badge-updated": undefined; + } +} + +@customElement("hui-badge") +export class HuiBadge extends ReactiveElement { + @property({ type: Boolean }) public preview = false; + + @property({ attribute: false }) public config?: LovelaceBadgeConfig; + + @property({ attribute: false }) public hass?: HomeAssistant; + + private _elementConfig?: LovelaceBadgeConfig; + + public load() { + if (!this.config) { + throw new Error("Cannot build badge without config"); + } + this._loadElement(this.config); + } + + private _element?: LovelaceBadge; + + private _listeners: MediaQueriesListener[] = []; + + protected createRenderRoot() { + return this; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._clearMediaQueries(); + } + + public connectedCallback() { + super.connectedCallback(); + this._listenMediaQueries(); + this._updateVisibility(); + } + + private _updateElement(config: LovelaceBadgeConfig) { + if (config.type === "state-label") { + config = { ...config, type: "entity" }; + } + if (!this._element) { + return; + } + this._element.setConfig(config); + this._elementConfig = config; + fireEvent(this, "badge-updated"); + } + + private _loadElement(config: LovelaceBadgeConfig) { + if (config.type === "state-label") { + config = { ...config, type: "entity" }; + } + this._element = createBadgeElement(config); + this._elementConfig = config; + if (this.hass) { + this._element.hass = this.hass; + } + this._element.addEventListener( + "ll-upgrade", + (ev: Event) => { + ev.stopPropagation(); + if (this.hass) { + this._element!.hass = this.hass; + } + fireEvent(this, "badge-updated"); + }, + { once: true } + ); + this._element.addEventListener( + "ll-rebuild", + (ev: Event) => { + ev.stopPropagation(); + this._loadElement(config); + fireEvent(this, "badge-updated"); + }, + { once: true } + ); + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this._updateVisibility(); + } + + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (!this._element) { + this.load(); + } + } + + protected update(changedProps: PropertyValues) { + super.update(changedProps); + + if (this._element) { + if (changedProps.has("config")) { + const elementConfig = this._elementConfig; + if (this.config !== elementConfig && this.config) { + const typeChanged = this.config?.type !== elementConfig?.type; + if (typeChanged) { + this._loadElement(this.config); + } else { + this._updateElement(this.config); + } + } + } + if (changedProps.has("hass")) { + try { + if (this.hass) { + this._element.hass = this.hass; + } + } catch (e: any) { + this._loadElement(createErrorBadgeConfig(e.message, null)); + } + } + } + + if (changedProps.has("hass") || changedProps.has("preview")) { + this._updateVisibility(); + } + } + + private _clearMediaQueries() { + this._listeners.forEach((unsub) => unsub()); + this._listeners = []; + } + + private _listenMediaQueries() { + this._clearMediaQueries(); + if (!this.config?.visibility) { + return; + } + const conditions = this.config.visibility; + const hasOnlyMediaQuery = + conditions.length === 1 && + conditions[0].condition === "screen" && + !!conditions[0].media_query; + + this._listeners = attachConditionMediaQueriesListeners( + this.config.visibility, + (matches) => { + this._updateVisibility(hasOnlyMediaQuery && matches); + } + ); + } + + private _updateVisibility(forceVisible?: boolean) { + if (!this._element || !this.hass) { + return; + } + + if (this._element.hidden) { + this._setElementVisibility(false); + return; + } + + const visible = + forceVisible || + this.preview || + !this.config?.visibility || + checkConditionsMet(this.config.visibility, this.hass); + this._setElementVisibility(visible); + } + + private _setElementVisibility(visible: boolean) { + if (!this._element) return; + + if (this.hidden !== !visible) { + this.style.setProperty("display", visible ? "" : "none"); + this.toggleAttribute("hidden", !visible); + fireEvent(this, "badge-visibility-changed", { value: visible }); + } + + if (!visible && this._element.parentElement) { + this.removeChild(this._element); + } else if (visible && !this._element.parentElement) { + this.appendChild(this._element); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge": HuiBadge; + } +} diff --git a/src/panels/lovelace/badges/hui-entity-badge.ts b/src/panels/lovelace/badges/hui-entity-badge.ts new file mode 100644 index 0000000000..05a6839da0 --- /dev/null +++ b/src/panels/lovelace/badges/hui-entity-badge.ts @@ -0,0 +1,306 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { stateActive } from "../../../common/entity/state_active"; +import { stateColorCss } from "../../../common/entity/state_color"; +import "../../../components/ha-ripple"; +import "../../../components/ha-state-icon"; +import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { findEntities } from "../common/find-entities"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import { LovelaceBadge, LovelaceBadgeEditor } from "../types"; +import { EntityBadgeConfig } from "./types"; +import { computeStateDomain } from "../../../common/entity/compute_state_domain"; +import { cameraUrlWithWidthHeight } from "../../../data/camera"; + +export const DISPLAY_TYPES = ["minimal", "standard", "complete"] as const; + +export type DisplayType = (typeof DISPLAY_TYPES)[number]; + +export const DEFAULT_DISPLAY_TYPE: DisplayType = "standard"; + +@customElement("hui-entity-badge") +export class HuiEntityBadge extends LitElement implements LovelaceBadge { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-entity-badge-editor"); + return document.createElement("hui-entity-badge-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ): EntityBadgeConfig { + const includeDomains = ["sensor", "light", "switch"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFallback, + includeDomains + ); + + return { + type: "entity", + entity: foundEntities[0] || "", + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() protected _config?: EntityBadgeConfig; + + public setConfig(config: EntityBadgeConfig): void { + this._config = config; + } + + get hasAction() { + return ( + !this._config?.tap_action || + hasAction(this._config?.tap_action) || + hasAction(this._config?.hold_action) || + hasAction(this._config?.double_tap_action) + ); + } + + private _computeStateColor = memoizeOne( + (stateObj: HassEntity, color?: string) => { + // Use custom color if active + if (color) { + return stateActive(stateObj) ? computeCssColor(color) : undefined; + } + + // Use light color if the light support rgb + if ( + computeDomain(stateObj.entity_id) === "light" && + stateObj.attributes.rgb_color + ) { + const hsvColor = rgb2hsv(stateObj.attributes.rgb_color); + + // Modify the real rgb color for better contrast + if (hsvColor[1] < 0.4) { + // Special case for very light color (e.g: white) + if (hsvColor[1] < 0.1) { + hsvColor[2] = 225; + } else { + hsvColor[1] = 0.4; + } + } + return rgb2hex(hsv2rgb(hsvColor)); + } + + // Fallback to state color + return stateColorCss(stateObj); + } + ); + + private _getImageUrl(stateObj: HassEntity): string | undefined { + const entityPicture = + stateObj.attributes.entity_picture_local || + stateObj.attributes.entity_picture; + + if (!entityPicture) return undefined; + + let imageUrl = this.hass!.hassUrl(entityPicture); + if (computeStateDomain(stateObj) === "camera") { + imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32); + } + + return imageUrl; + } + + protected render() { + if (!this._config || !this.hass) { + return nothing; + } + + const entityId = this._config.entity; + const stateObj = entityId ? this.hass.states[entityId] : undefined; + + if (!stateObj) { + return nothing; + } + + const active = stateActive(stateObj); + const color = this._computeStateColor(stateObj, this._config.color); + + const style = { + "--badge-color": color, + }; + + const stateDisplay = html` + + + `; + + const name = this._config.name || stateObj.attributes.friendly_name; + + const displayType = this._config.display_type || DEFAULT_DISPLAY_TYPE; + + const imageUrl = this._config.show_entity_picture + ? this._getImageUrl(stateObj) + : undefined; + + return html` +
+ + ${imageUrl + ? html`` + : html` + + `} + ${displayType !== "minimal" + ? html` + + ${displayType === "complete" + ? html`${name}` + : nothing} + ${stateDisplay} + + ` + : nothing} +
+ `; + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + static get styles(): CSSResultGroup { + return css` + :host { + --badge-color: var(--state-inactive-color); + -webkit-tap-highlight-color: transparent; + } + .badge { + position: relative; + --ha-ripple-color: var(--badge-color); + --ha-ripple-hover-opacity: 0.04; + --ha-ripple-pressed-opacity: 0.12; + transition: + box-shadow 180ms ease-in-out, + border-color 180ms ease-in-out; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + height: 36px; + min-width: 36px; + padding: 0px 8px; + box-sizing: border-box; + width: auto; + border-radius: 18px; + background-color: var(--card-background-color, white); + border-width: var(--ha-card-border-width, 1px); + border-style: solid; + border-color: var( + --ha-card-border-color, + var(--divider-color, #e0e0e0) + ); + --mdc-icon-size: 18px; + text-align: center; + font-family: Roboto; + } + .badge:focus-visible { + --shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent); + --shadow-focus: 0 0 0 1px var(--badge-color); + border-color: var(--badge-color); + box-shadow: var(--shadow-default), var(--shadow-focus); + } + button, + [role="button"] { + cursor: pointer; + } + button:focus, + [role="button"]:focus { + outline: none; + } + .badge.active { + --badge-color: var(--primary-color); + } + .content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-right: 4px; + padding-inline-end: 4px; + padding-inline-start: initial; + } + .name { + font-size: 10px; + font-style: normal; + font-weight: 500; + line-height: 10px; + letter-spacing: 0.1px; + color: var(--secondary-text-color); + } + .state { + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.1px; + color: var(--primary-text-color); + } + ha-state-icon { + color: var(--badge-color); + line-height: 0; + } + img { + width: 30px; + height: 30px; + border-radius: 50%; + object-fit: cover; + overflow: hidden; + } + .badge.minimal { + padding: 0; + } + .badge:not(.minimal) img { + margin-left: -6px; + margin-inline-start: -6px; + margin-inline-end: initial; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entity-badge": HuiEntityBadge; + } +} diff --git a/src/panels/lovelace/badges/hui-entity-filter-badge.ts b/src/panels/lovelace/badges/hui-entity-filter-badge.ts index e6a889febe..3b5de69b38 100644 --- a/src/panels/lovelace/badges/hui-entity-filter-badge.ts +++ b/src/panels/lovelace/badges/hui-entity-filter-badge.ts @@ -8,9 +8,10 @@ import { checkConditionsMet, extractConditionEntityIds, } from "../common/validate-condition"; -import { createBadgeElement } from "../create-element/create-badge-element"; import { EntityFilterEntityConfig } from "../entity-rows/types"; import { LovelaceBadge } from "../types"; +import "./hui-badge"; +import type { HuiBadge } from "./hui-badge"; import { EntityFilterBadgeConfig } from "./types"; @customElement("hui-entity-filter-badge") @@ -18,11 +19,13 @@ export class HuiEntityFilterBadge extends ReactiveElement implements LovelaceBadge { + @property({ attribute: false }) public preview = false; + @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config?: EntityFilterBadgeConfig; - private _elements?: LovelaceBadge[]; + private _elements?: HuiBadge[]; private _configEntities?: EntityFilterEntityConfig[]; @@ -121,8 +124,14 @@ export class HuiEntityFilterBadge if (!isSame) { this._elements = []; for (const badgeConfig of entitiesList) { - const element = createBadgeElement(badgeConfig); + const element = document.createElement("hui-badge"); element.hass = this.hass; + element.preview = this.preview; + element.config = { + type: "entity", + ...badgeConfig, + }; + element.load(); this._elements.push(element); } this._oldEntities = entitiesList; @@ -140,7 +149,10 @@ export class HuiEntityFilterBadge this.appendChild(element); } - this.style.display = "inline"; + this.style.display = "flex"; + this.style.flexWrap = "wrap"; + this.style.justifyContent = "center"; + this.style.gap = "8px"; } private haveEntitiesChanged(oldHass?: HomeAssistant): boolean { diff --git a/src/panels/lovelace/badges/hui-error-badge.ts b/src/panels/lovelace/badges/hui-error-badge.ts index aa02366f26..19c6e95826 100644 --- a/src/panels/lovelace/badges/hui-error-badge.ts +++ b/src/panels/lovelace/badges/hui-error-badge.ts @@ -1,10 +1,13 @@ -import { mdiAlert } from "@mdi/js"; +import { mdiAlertCircle } from "@mdi/js"; +import { dump } from "js-yaml"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, state } from "lit/decorators"; import "../../../components/ha-label-badge"; import "../../../components/ha-svg-icon"; import { HomeAssistant } from "../../../types"; +import { showAlertDialog } from "../custom-card-helpers"; import { LovelaceBadge } from "../types"; +import { HuiEntityBadge } from "./hui-entity-badge"; import { ErrorBadgeConfig } from "./types"; export const createErrorBadgeElement = (config) => { @@ -28,24 +31,65 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge { this._config = config; } + private _viewDetail() { + let dumped: string | undefined; + + if (this._config!.origConfig) { + try { + dumped = dump(this._config!.origConfig); + } catch (err: any) { + dumped = `[Error dumping ${this._config!.origConfig}]`; + } + } + + showAlertDialog(this, { + title: this._config?.error, + warning: true, + text: dumped ? html`
${dumped}
` : "", + }); + } + protected render() { if (!this._config) { return nothing; } return html` - - - + `; } static get styles(): CSSResultGroup { - return css` - :host { - --ha-label-badge-color: var(--label-badge-red, #fce588); - } - `; + return [ + HuiEntityBadge.styles, + css` + .badge.error { + --badge-color: var(--error-color); + border-color: var(--badge-color); + } + ha-svg-icon { + color: var(--badge-color); + } + .state { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + pre { + font-family: var(--code-font-family, monospace); + white-space: break-spaces; + user-select: text; + } + `, + ]; } } diff --git a/src/panels/lovelace/badges/hui-view-badges.ts b/src/panels/lovelace/badges/hui-view-badges.ts new file mode 100644 index 0000000000..b1736b97dd --- /dev/null +++ b/src/panels/lovelace/badges/hui-view-badges.ts @@ -0,0 +1,220 @@ +import { mdiPlus } from "@mdi/js"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-sortable"; +import type { HaSortableOptions } from "../../../components/ha-sortable"; +import "../../../components/ha-svg-icon"; +import { HomeAssistant } from "../../../types"; +import "../components/hui-badge-edit-mode"; +import { moveBadge } from "../editor/config-util"; +import { Lovelace } from "../types"; +import { HuiBadge } from "./hui-badge"; + +const BADGE_SORTABLE_OPTIONS: HaSortableOptions = { + delay: 100, + delayOnTouchOnly: true, + direction: "horizontal", + invertedSwapThreshold: 0.7, +} as HaSortableOptions; + +@customElement("hui-view-badges") +export class HuiViewBadges extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace!: Lovelace; + + @property({ attribute: false }) public badges: HuiBadge[] = []; + + @property({ attribute: false }) public viewIndex!: number; + + @state() _dragging = false; + + private _badgeConfigKeys = new WeakMap(); + + private _checkAllHidden() { + const allHidden = + !this.lovelace.editMode && this.badges.every((section) => section.hidden); + this.toggleAttribute("hidden", allHidden); + } + + private _badgeVisibilityChanged = () => { + this._checkAllHidden(); + }; + + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener( + "badge-visibility-changed", + this._badgeVisibilityChanged + ); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener( + "badge-visibility-changed", + this._badgeVisibilityChanged + ); + } + + willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("badges") || changedProperties.has("lovelace")) { + this._checkAllHidden(); + } + } + + private _getBadgeKey(badge: HuiBadge) { + if (!this._badgeConfigKeys.has(badge)) { + this._badgeConfigKeys.set(badge, Math.random().toString()); + } + return this._badgeConfigKeys.get(badge)!; + } + + private _badgeMoved(ev) { + ev.stopPropagation(); + const { oldIndex, newIndex, oldPath, newPath } = ev.detail; + const newConfig = moveBadge( + this.lovelace!.config, + [...oldPath, oldIndex] as [number, number, number], + [...newPath, newIndex] as [number, number, number] + ); + this.lovelace!.saveConfig(newConfig); + } + + private _dragStart() { + this._dragging = true; + } + + private _dragEnd() { + this._dragging = false; + } + + private _addBadge() { + fireEvent(this, "ll-create-badge"); + } + + render() { + if (!this.lovelace) return nothing; + + const editMode = this.lovelace.editMode; + + const badges = this.badges; + + return html` + ${badges?.length > 0 || editMode + ? html` + +
+ ${repeat( + badges, + (badge) => this._getBadgeKey(badge), + (badge, idx) => html` + ${editMode + ? html` + + ${badge} + + ` + : badge} + ` + )} + ${editMode + ? html` + + ` + : nothing} +
+
+ ` + : nothing} + `; + } + + static get styles(): CSSResultGroup { + return css` + :host([hidden]) { + display: none !important; + } + + .badges { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin: 0; + } + + hui-badge-edit-mode { + display: block; + position: relative; + } + + .add { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + height: 36px; + padding: 6px 20px 6px 20px; + box-sizing: border-box; + width: auto; + border-radius: 18px; + background-color: transparent; + border-width: 2px; + border-style: dashed; + border-color: var(--primary-color); + --mdc-icon-size: 18px; + cursor: pointer; + color: var(--primary-text-color); + } + .add:focus { + border-style: solid; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-view-badges": HuiViewBadges; + } +} diff --git a/src/panels/lovelace/badges/types.ts b/src/panels/lovelace/badges/types.ts index 345c1c3d8e..682308d39a 100644 --- a/src/panels/lovelace/badges/types.ts +++ b/src/panels/lovelace/badges/types.ts @@ -13,6 +13,7 @@ export interface EntityFilterBadgeConfig extends LovelaceBadgeConfig { export interface ErrorBadgeConfig extends LovelaceBadgeConfig { error: string; + origConfig: LovelaceBadgeConfig; } export interface StateLabelBadgeConfig extends LovelaceBadgeConfig { @@ -25,3 +26,17 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig { hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface EntityBadgeConfig extends LovelaceBadgeConfig { + type: "entity"; + entity?: string; + name?: string; + icon?: string; + color?: string; + show_entity_picture?: boolean; + display_type?: "minimal" | "standard" | "complete"; + state_content?: string | string[]; + tap_action?: ActionConfig; + hold_action?: ActionConfig; + double_tap_action?: ActionConfig; +} diff --git a/src/panels/lovelace/card-features/common/card-feature-styles.ts b/src/panels/lovelace/card-features/common/card-feature-styles.ts new file mode 100644 index 0000000000..6a0fdcb1c0 --- /dev/null +++ b/src/panels/lovelace/card-features/common/card-feature-styles.ts @@ -0,0 +1,30 @@ +import { css } from "lit"; + +export const cardFeatureStyles = css` + ha-control-select-menu { + box-sizing: border-box; + --control-select-menu-height: var(--feature-height); + --control-select-menu-border-radius: var(--feature-border-radius); + line-height: 1.2; + display: block; + width: 100%; + } + ha-control-select { + --control-select-color: var(--feature-color); + --control-select-padding: 0; + --control-select-thickness: var(--feature-height); + --control-select-border-radius: var(--feature-border-radius); + --control-select-button-border-radius: var(--feature-border-radius); + } + ha-control-button-group { + --control-button-group-spacing: var(--feature-button-spacing); + --control-button-group-thickness: var(--feature-height); + } + ha-control-slider { + --control-slider-color: var(--feature-color); + --control-slider-background: var(--feature-color); + --control-slider-background-opacity: 0.2; + --control-slider-thickness: var(--feature-height); + --control-slider-border-radius: var(--feature-border-radius); + } +`; diff --git a/src/panels/lovelace/card-features/hui-alarm-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-alarm-modes-card-feature.ts index 2572f237c4..425f6b90e1 100644 --- a/src/panels/lovelace/card-features/hui-alarm-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-alarm-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiShieldOff } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; @@ -21,6 +21,7 @@ import { import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; import { AlarmModesCardFeatureConfig } from "./types"; @@ -135,44 +136,26 @@ class HuiAlarmModeCardFeature } return html` -
- - -
+ + `; } static get styles() { - return css` - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-card-feature.ts b/src/panels/lovelace/card-features/hui-card-feature.ts new file mode 100644 index 0000000000..a65fbd5ad5 --- /dev/null +++ b/src/panels/lovelace/card-features/hui-card-feature.ts @@ -0,0 +1,51 @@ +import type { HassEntity } from "home-assistant-js-websocket"; +import { LitElement, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { HomeAssistant } from "../../../types"; +import type { HuiErrorCard } from "../cards/hui-error-card"; +import { createCardFeatureElement } from "../create-element/create-card-feature-element"; +import type { LovelaceCardFeature } from "../types"; +import type { LovelaceCardFeatureConfig } from "./types"; + +@customElement("hui-card-feature") +export class HuiCardFeature extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: HassEntity; + + @property({ attribute: false }) public feature?: LovelaceCardFeatureConfig; + + @property({ attribute: false }) public color?: string; + + private _element?: LovelaceCardFeature | HuiErrorCard; + + private _getFeatureElement(feature: LovelaceCardFeatureConfig) { + if (!this._element) { + this._element = createCardFeatureElement(feature); + return this._element; + } + + return this._element; + } + + protected render() { + if (!this.feature) { + return nothing; + } + + const element = this._getFeatureElement(this.feature); + + if (this.hass) { + element.hass = this.hass; + (element as LovelaceCardFeature).stateObj = this.stateObj; + (element as LovelaceCardFeature).color = this.color; + } + return html`${element}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-card-feature": HuiCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/hui-card-features.ts b/src/panels/lovelace/card-features/hui-card-features.ts index 101561c78d..c0c8bb9429 100644 --- a/src/panels/lovelace/card-features/hui-card-features.ts +++ b/src/panels/lovelace/card-features/hui-card-features.ts @@ -1,17 +1,8 @@ import type { HassEntity } from "home-assistant-js-websocket"; -import { - CSSResultGroup, - LitElement, - TemplateResult, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { HomeAssistant } from "../../../types"; -import type { HuiErrorCard } from "../cards/hui-error-card"; -import { createCardFeatureElement } from "../create-element/create-card-feature-element"; -import type { LovelaceCardFeature } from "../types"; +import "./hui-card-feature"; import type { LovelaceCardFeatureConfig } from "./types"; @customElement("hui-card-features") @@ -24,44 +15,23 @@ export class HuiCardFeatures extends LitElement { @property({ attribute: false }) public color?: string; - private _featuresElements = new WeakMap< - LovelaceCardFeatureConfig, - LovelaceCardFeature | HuiErrorCard - >(); - - private _getFeatureElement(feature: LovelaceCardFeatureConfig) { - if (!this._featuresElements.has(feature)) { - const element = createCardFeatureElement(feature); - this._featuresElements.set(feature, element); - return element; - } - - return this._featuresElements.get(feature)!; - } - - private renderFeature( - featureConf: LovelaceCardFeatureConfig, - stateObj: HassEntity - ): TemplateResult { - const element = this._getFeatureElement(featureConf); - - if (this.hass) { - element.hass = this.hass; - (element as LovelaceCardFeature).stateObj = stateObj; - (element as LovelaceCardFeature).color = this.color; - } - - return html`${element}`; - } - protected render() { if (!this.features) { return nothing; } return html` - ${this.features.map((featureConf) => - this.renderFeature(featureConf, this.stateObj) - )} +
+ ${this.features.map( + (feature) => html` + + ` + )} +
`; } @@ -69,8 +39,24 @@ export class HuiCardFeatures extends LitElement { return css` :host { --feature-color: var(--state-icon-color); + --feature-padding: 12px; + --feature-height: 42px; + --feature-border-radius: 12px; + --feature-button-spacing: 12px; + position: relative; + width: 100%; + } + .container { + position: relative; display: flex; flex-direction: column; + padding: var(--feature-padding); + padding-top: 0px; + gap: var(--feature-padding); + width: 100%; + height: 100%; + box-sizing: border-box; + justify-content: space-evenly; } `; } diff --git a/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts index 3cfa5624b5..fb07ce89e5 100644 --- a/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiFan } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -14,8 +14,9 @@ import { ClimateEntity, ClimateEntityFeature } from "../../../data/climate"; import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { ClimateFanModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { ClimateFanModesCardFeatureConfig } from "./types"; export const supportsClimateFanModesCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -140,79 +141,55 @@ class HuiClimateFanModesCardFeature if (this._config.style === "icons") { return html` -
- - -
+ + `; } return html` -
- - ${this._currentFanMode - ? html`` - : html` `} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - - ` - )} - -
+ + ${this._currentFanMode + ? html`` + : html` `} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-climate-hvac-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-climate-hvac-modes-card-feature.ts index 3eab79b85e..65cf59959f 100644 --- a/src/panels/lovelace/card-features/hui-climate-hvac-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-climate-hvac-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiThermostat } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { stopPropagation } from "../../../common/dom/stop_propagation"; @@ -19,6 +19,7 @@ import { import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; import { ClimateHvacModesCardFeatureConfig } from "./types"; @@ -139,79 +140,56 @@ class HuiClimateHvacModesCardFeature if (this._config.style === "dropdown") { return html` -
- - ${this._currentHvacMode - ? html` - - ` - : html` - - `} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - + + ${this._currentHvacMode + ? html` + ` - )} - -
+ : html` + + `} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } return html` -
- - -
+ + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-climate-preset-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-climate-preset-modes-card-feature.ts index 5cd1e233e5..90178bcb1d 100644 --- a/src/panels/lovelace/card-features/hui-climate-preset-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-climate-preset-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiTuneVariant } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -14,8 +14,9 @@ import { ClimateEntity, ClimateEntityFeature } from "../../../data/climate"; import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { ClimatePresetModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { ClimatePresetModesCardFeatureConfig } from "./types"; export const supportsClimatePresetModesCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -142,84 +143,57 @@ class HuiClimatePresetModesCardFeature if (this._config.style === "icons") { return html` -
- - -
+ + `; } return html` -
- - ${this._currentPresetMode - ? html`` - : html` - - `} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - - ` - )} - -
+ + ${this._currentPresetMode + ? html`` + : html` + + `} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-climate-swing-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-climate-swing-modes-card-feature.ts index adc88c1948..b61f096600 100644 --- a/src/panels/lovelace/card-features/hui-climate-swing-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-climate-swing-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiArrowOscillating } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -14,8 +14,9 @@ import { ClimateEntity, ClimateEntityFeature } from "../../../data/climate"; import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { ClimateSwingModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { ClimateSwingModesCardFeatureConfig } from "./types"; export const supportsClimateSwingModesCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -142,82 +143,58 @@ class HuiClimateSwingModesCardFeature if (this._config.style === "icons") { return html` -
- - -
+ + `; } return html` -
- - ${this._currentSwingMode - ? html`` - : html` `} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - - ` - )} - -
+ + ${this._currentSwingMode + ? html`` + : html` `} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-cover-open-close-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-open-close-card-feature.ts index 1858112361..06a7eca48e 100644 --- a/src/panels/lovelace/card-features/hui-cover-open-close-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-cover-open-close-card-feature.ts @@ -1,6 +1,6 @@ import { mdiStop } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { @@ -18,6 +18,7 @@ import { } from "../../../data/cover"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { CoverOpenCloseCardFeatureConfig } from "./types"; export const supportsCoverOpenCloseCardFeature = (stateObj: HassEntity) => { @@ -128,12 +129,7 @@ class HuiCoverOpenCloseCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-cover-position-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-position-card-feature.ts index af7e8a5f23..cb3a5cf85b 100644 --- a/src/panels/lovelace/card-features/hui-cover-position-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-cover-position-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { computeCssColor } from "../../../common/color/compute-color"; @@ -10,10 +10,11 @@ import { stateColorCss } from "../../../common/entity/state_color"; import { supportsFeature } from "../../../common/entity/supports-feature"; import { CoverEntityFeature } from "../../../data/cover"; import { UNAVAILABLE } from "../../../data/entity"; +import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity_attributes"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { CoverPositionCardFeatureConfig } from "./types"; -import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity_attributes"; export const supportsCoverPositionCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -60,7 +61,7 @@ class HuiCoverPositionCardFeature } const percentage = stateActive(this.stateObj) - ? this.stateObj.attributes.current_position ?? 0 + ? (this.stateObj.attributes.current_position ?? 0) : 0; const value = Math.max(Math.round(percentage), 0); @@ -72,32 +73,31 @@ class HuiCoverPositionCardFeature : stateColorCss(this.stateObj); const style = { - "--color": color, + "--feature-color": color, // Use open color for inactive state to avoid grey slider that looks disabled "--state-cover-inactive-color": openColor, }; return html` -
- -
+ `; } @@ -112,19 +112,7 @@ class HuiCoverPositionCardFeature } static get styles() { - return css` - ha-control-slider { - --control-slider-color: var(--color); - --control-slider-background: var(--color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-cover-tilt-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-tilt-card-feature.ts index d09c71aa62..9fbee23208 100644 --- a/src/panels/lovelace/card-features/hui-cover-tilt-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-cover-tilt-card-feature.ts @@ -1,19 +1,20 @@ import { mdiArrowBottomLeft, mdiArrowTopRight, mdiStop } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { supportsFeature } from "../../../common/entity/supports-feature"; import "../../../components/ha-control-button"; import "../../../components/ha-control-button-group"; import { + CoverEntityFeature, canCloseTilt, canOpenTilt, canStopTilt, - CoverEntityFeature, } from "../../../data/cover"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { CoverTiltCardFeatureConfig } from "./types"; export const supportsCoverTiltCardFeature = (stateObj: HassEntity) => { @@ -120,12 +121,7 @@ class HuiCoverTiltCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts index 48e1a809c5..5942c70045 100644 --- a/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts @@ -13,6 +13,7 @@ import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity_attributes"; import { generateTiltSliderTrackBackgroundGradient } from "../../../state-control/cover/ha-state-control-cover-tilt-position"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { CoverTiltPositionCardFeatureConfig } from "./types"; const GRADIENT = generateTiltSliderTrackBackgroundGradient(); @@ -72,33 +73,32 @@ class HuiCoverTiltPositionCardFeature : stateColorCss(this.stateObj); const style = { - "--color": color, + "--feature-color": color, // Use open color for inactive state to avoid grey slider that looks disabled "--state-cover-inactive-color": openColor, }; return html` -
- -
-
+ +
`; } @@ -113,24 +113,15 @@ class HuiCoverTiltPositionCardFeature } static get styles() { - return css` - ha-control-slider { - /* Force inactive state to be colored for the slider */ - --control-slider-color: var(--color); - --control-slider-background: var(--color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - .gradient { - background: -webkit-linear-gradient(left, ${GRADIENT}); - opacity: 0.6; - } - `; + return [ + cardFeatureStyles, + css` + .gradient { + background: -webkit-linear-gradient(left, ${GRADIENT}); + opacity: 0.6; + } + `, + ]; } } diff --git a/src/panels/lovelace/card-features/hui-fan-preset-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-fan-preset-modes-card-feature.ts index d1cfb0fcc2..51c054e26f 100644 --- a/src/panels/lovelace/card-features/hui-fan-preset-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-fan-preset-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiTuneVariant } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -10,12 +10,13 @@ import "../../../components/ha-control-select"; import type { ControlSelectOption } from "../../../components/ha-control-select"; import "../../../components/ha-control-select-menu"; import type { HaControlSelectMenu } from "../../../components/ha-control-select-menu"; -import { FanEntity, FanEntityFeature } from "../../../data/fan"; import { UNAVAILABLE } from "../../../data/entity"; +import { FanEntity, FanEntityFeature } from "../../../data/fan"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { FanPresetModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { FanPresetModesCardFeatureConfig } from "./types"; export const supportsFanPresetModesCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -139,84 +140,57 @@ class HuiFanPresetModesCardFeature if (this._config.style === "icons") { return html` -
- - -
+ + `; } return html` -
- - ${this._currentPresetMode - ? html`` - : html` - - `} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - - ` - )} - -
+ + ${this._currentPresetMode + ? html`` + : html` + + `} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-fan-speed-card-feature.ts b/src/panels/lovelace/card-features/hui-fan-speed-card-feature.ts index 932c3a93d2..93314761d2 100644 --- a/src/panels/lovelace/card-features/hui-fan-speed-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-fan-speed-card-feature.ts @@ -24,6 +24,7 @@ import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; import { FanSpeedCardFeatureConfig } from "./types"; import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity_attributes"; +import { cardFeatureStyles } from "./common/card-feature-styles"; export const supportsFanSpeedCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -73,7 +74,7 @@ class HuiFanSpeedCardFeature extends LitElement implements LovelaceCardFeature { const speedCount = computeFanSpeedCount(this.stateObj); const percentage = stateActive(this.stateObj) - ? this.stateObj.attributes.percentage ?? 0 + ? (this.stateObj.attributes.percentage ?? 0) : 0; if (speedCount <= FAN_SPEED_COUNT_MAX_FOR_BUTTONS) { @@ -88,35 +89,11 @@ class HuiFanSpeedCardFeature extends LitElement implements LovelaceCardFeature { const speed = fanPercentageToSpeed(this.stateObj, percentage); return html` -
- - -
- `; - } - - const value = Math.max(Math.round(percentage), 0); - - return html` -
- -
+ > + + `; + } + + const value = Math.max(Math.round(percentage), 0); + + return html` + `; } @@ -153,28 +150,16 @@ class HuiFanSpeedCardFeature extends LitElement implements LovelaceCardFeature { } static get styles() { - return css` - ha-control-slider { - --control-slider-color: var(--feature-color); - --control-slider-background: var(--feature-color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-background: var(--feature-color); - --control-select-background-opacity: 0.2; - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return [ + cardFeatureStyles, + css` + ha-control-select { + /* Color the background to match the slider style */ + --control-select-background: var(--feature-color); + --control-select-background-opacity: 0.2; + } + `, + ]; } } diff --git a/src/panels/lovelace/card-features/hui-humidifier-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-humidifier-modes-card-feature.ts index ee5176f9ad..510d946857 100644 --- a/src/panels/lovelace/card-features/hui-humidifier-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-humidifier-modes-card-feature.ts @@ -1,6 +1,6 @@ import { mdiTuneVariant } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -17,8 +17,9 @@ import { } from "../../../data/humidifier"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { HumidifierModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { HumidifierModesCardFeatureConfig } from "./types"; export const supportsHumidifierModesCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -143,79 +144,55 @@ class HuiHumidifierModesCardFeature if (this._config.style === "icons") { return html` -
- - -
+ + `; } return html` -
- - ${this._currentMode - ? html`` - : html``} - ${options.map( - (option) => html` - - ${option.icon}${option.label} - - ` - )} - -
+ + ${this._currentMode + ? html`` + : html``} + ${options.map( + (option) => html` + + ${option.icon}${option.label} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-humidifier-toggle-card-feature.ts b/src/panels/lovelace/card-features/hui-humidifier-toggle-card-feature.ts index 647da49f1a..a1c2abba64 100644 --- a/src/panels/lovelace/card-features/hui-humidifier-toggle-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-humidifier-toggle-card-feature.ts @@ -1,6 +1,6 @@ import { mdiPower, mdiWaterPercent } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, PropertyValues, TemplateResult, css, html } from "lit"; +import { LitElement, PropertyValues, TemplateResult, html } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -11,6 +11,7 @@ import { UNAVAILABLE } from "../../../data/entity"; import { HumidifierEntity, HumidifierState } from "../../../data/humidifier"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { HumidifierToggleCardFeatureConfig } from "./types"; export const supportsHumidifierToggleCardFeature = (stateObj: HassEntity) => { @@ -95,37 +96,23 @@ class HuiHumidifierToggleCardFeature })); return html` -
- - -
+ + `; } static get styles() { - return css` - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-lawn-mower-commands-card-feature.ts b/src/panels/lovelace/card-features/hui-lawn-mower-commands-card-feature.ts index 52ec9c59d2..de74459b01 100644 --- a/src/panels/lovelace/card-features/hui-lawn-mower-commands-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-lawn-mower-commands-card-feature.ts @@ -1,6 +1,6 @@ import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { supportsFeature } from "../../../common/entity/supports-feature"; @@ -14,6 +14,7 @@ import { } from "../../../data/lawn_mower"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { LAWN_MOWER_COMMANDS, LawnMowerCommand, @@ -171,12 +172,7 @@ class HuiLawnMowerCommandCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-light-brightness-card-feature.ts b/src/panels/lovelace/card-features/hui-light-brightness-card-feature.ts index 7eeeb3d3bd..3390f01e65 100644 --- a/src/panels/lovelace/card-features/hui-light-brightness-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-light-brightness-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { stateActive } from "../../../common/entity/state_active"; @@ -8,6 +8,7 @@ import { UNAVAILABLE } from "../../../data/entity"; import { lightSupportsBrightness } from "../../../data/light"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { LightBrightnessCardFeatureConfig } from "./types"; export const supportsLightBrightnessCardFeature = (stateObj: HassEntity) => { @@ -58,19 +59,17 @@ class HuiLightBrightnessCardFeature : undefined; return html` -
- -
+ `; } @@ -85,19 +84,7 @@ class HuiLightBrightnessCardFeature } static get styles() { - return css` - ha-control-slider { - --control-slider-color: var(--feature-color); - --control-slider-background: var(--feature-color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-light-color-temp-card-feature.ts b/src/panels/lovelace/card-features/hui-light-color-temp-card-feature.ts index fa7ed6b9d9..f7a71b4ece 100644 --- a/src/panels/lovelace/card-features/hui-light-color-temp-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-light-color-temp-card-feature.ts @@ -16,6 +16,7 @@ import { LightColorMode, lightSupportsColorMode } from "../../../data/light"; import { generateColorTemperatureGradient } from "../../../dialogs/more-info/components/lights/light-color-temp-picker"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { LightColorTempCardFeatureConfig } from "./types"; export const supportsLightColorTempCardFeature = (stateObj: HassEntity) => { @@ -73,23 +74,21 @@ class HuiLightColorTempCardFeature const gradient = this._generateTemperatureGradient(minKelvin!, maxKelvin); return html` -
- -
+ `; } @@ -108,22 +107,18 @@ class HuiLightColorTempCardFeature } static get styles() { - return css` - ha-control-slider { - --control-slider-color: var(--feature-color); - --control-slider-background: -webkit-linear-gradient( - left, - var(--gradient) - ); - --control-slider-background-opacity: 1; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return [ + cardFeatureStyles, + css` + ha-control-slider { + --control-slider-background: -webkit-linear-gradient( + left, + var(--gradient) + ); + --control-slider-background-opacity: 1; + } + `, + ]; } } diff --git a/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts b/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts index 19d737bb21..1c321134fd 100644 --- a/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts @@ -1,6 +1,6 @@ import { mdiLock, mdiLockOpenVariant } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { CSSResultGroup, LitElement, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -14,6 +14,7 @@ import { } from "../../../data/lock"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { LockCommandsCardFeatureConfig } from "./types"; export const supportsLockCommandsCardFeature = (stateObj: HassEntity) => { @@ -88,12 +89,7 @@ class HuiLockCommandsCardFeature } static get styles(): CSSResultGroup { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts b/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts index 298daabb55..a5a0462289 100644 --- a/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts @@ -15,6 +15,7 @@ import { import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; import { LockOpenDoorCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; export const supportsLockOpenDoorCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -112,35 +113,34 @@ class HuiLockOpenDoorCardFeature } static get styles(): CSSResultGroup { - return css` - ha-control-button { - font-size: 14px; - } - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - .open-button { - width: 130px; - } - .open-button.confirm { - --control-button-background-color: var(--warning-color); - } - .open-done { - font-size: 14px; - line-height: 14px; - display: flex; - align-items: center; - justify-content: center; - flex-direction: row; - gap: 8px; - font-weight: 500; - color: var(--success-color); - margin: 0 12px 12px 12px; - height: 40px; - text-align: center; - } - `; + return [ + cardFeatureStyles, + css` + ha-control-button { + font-size: 14px; + } + .open-button { + width: 130px; + } + .open-button.confirm { + --control-button-background-color: var(--warning-color); + } + .open-done { + font-size: 14px; + line-height: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + gap: 8px; + font-weight: 500; + color: var(--success-color); + margin: 0 12px 12px 12px; + height: 40px; + text-align: center; + } + `, + ]; } } diff --git a/src/panels/lovelace/card-features/hui-numeric-input-card-feature.ts b/src/panels/lovelace/card-features/hui-numeric-input-card-feature.ts index e725a446ab..50ab4a5d6f 100644 --- a/src/panels/lovelace/card-features/hui-numeric-input-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-numeric-input-card-feature.ts @@ -1,16 +1,17 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { isUnavailableState } from "../../../data/entity"; -import { HomeAssistant } from "../../../types"; -import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { NumericInputCardFeatureConfig } from "./types"; import "../../../components/ha-control-button"; import "../../../components/ha-control-button-group"; import "../../../components/ha-control-number-buttons"; import "../../../components/ha-control-slider"; import "../../../components/ha-icon"; +import { isUnavailableState } from "../../../data/entity"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; +import { NumericInputCardFeatureConfig } from "./types"; export const supportsNumericInputCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -81,50 +82,36 @@ class HuiNumericInputCardFeature const stateObj = this.stateObj; + if (this._config.style === "buttons") { + return html` + + `; + } return html` -
- ${this._config.style === "buttons" - ? html`` - : html``} -
+ `; } static get styles() { - return css` - ha-control-number-buttons { - width: auto; - } - ha-control-slider { - --control-slider-color: var(--feature-color); - --control-slider-background: var(--feature-color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-select-options-card-feature.ts b/src/panels/lovelace/card-features/hui-select-options-card-feature.ts index 60b99116e4..8f63910ca4 100644 --- a/src/panels/lovelace/card-features/hui-select-options-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-select-options-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -10,8 +10,9 @@ import { InputSelectEntity } from "../../../data/input_select"; import { SelectEntity } from "../../../data/select"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { SelectOptionsCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { SelectOptionsCardFeatureConfig } from "./types"; export const supportsSelectOptionsCardFeature = (stateObj: HassEntity) => { const domain = computeDomain(stateObj.entity_id); @@ -119,45 +120,30 @@ class HuiSelectOptionsCardFeature ); return html` -
- - ${options.map( - (option) => html` - - ${this.hass!.formatEntityState(stateObj, option)} - - ` - )} - -
+ + ${options.map( + (option) => html` + + ${this.hass!.formatEntityState(stateObj, option)} + + ` + )} + `; } static get styles() { - return css` - ha-control-select-menu { - box-sizing: border-box; - --control-select-menu-height: 40px; - --control-select-menu-border-radius: 10px; - line-height: 1.2; - display: block; - width: 100%; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-target-humidity-card-feature.ts b/src/panels/lovelace/card-features/hui-target-humidity-card-feature.ts index 2f637d050b..cb10385bc6 100644 --- a/src/panels/lovelace/card-features/hui-target-humidity-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-target-humidity-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import "../../../components/ha-control-slider"; @@ -7,6 +7,7 @@ import { UNAVAILABLE } from "../../../data/entity"; import { HumidifierEntity } from "../../../data/humidifier"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { TargetHumidityCardFeatureConfig } from "./types"; export const supportsTargetHumidityCardFeature = (stateObj: HassEntity) => { @@ -84,39 +85,22 @@ class HuiTargetHumidityCardFeature } return html` -
- -
+ `; } static get styles() { - return css` - ha-control-slider { - --control-slider-color: var(--feature-color); - --control-slider-background: var(--feature-color); - --control-slider-background-opacity: 0.2; - --control-slider-thickness: 40px; - --control-slider-border-radius: 10px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-target-temperature-card-feature.ts b/src/panels/lovelace/card-features/hui-target-temperature-card-feature.ts index 638eeab585..7ed7bdb3f2 100644 --- a/src/panels/lovelace/card-features/hui-target-temperature-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-target-temperature-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { UNIT_F } from "../../../common/const"; @@ -18,6 +18,7 @@ import { } from "../../../data/water_heater"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { TargetTemperatureCardFeatureConfig } from "./types"; type Target = "value" | "low" | "high"; @@ -283,12 +284,7 @@ class HuiTargetTemperatureCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-update-actions-card-feature.ts b/src/panels/lovelace/card-features/hui-update-actions-card-feature.ts index a2f9ddb8ad..173a32883d 100644 --- a/src/panels/lovelace/card-features/hui-update-actions-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-update-actions-card-feature.ts @@ -1,6 +1,6 @@ import { mdiCancel, mdiCellphoneArrowDown } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { stateActive } from "../../../common/entity/state_active"; @@ -16,6 +16,7 @@ import { import { showUpdateBackupDialogParams } from "../../../dialogs/update_backup/show-update-backup-dialog"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { UpdateActionsCardFeatureConfig } from "./types"; export const DEFAULT_UPDATE_BACKUP_OPTION = "ask"; @@ -149,12 +150,7 @@ class HuiUpdateActionsCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-vacuum-commands-card-feature.ts b/src/panels/lovelace/card-features/hui-vacuum-commands-card-feature.ts index 02ceff183c..228d3032d7 100644 --- a/src/panels/lovelace/card-features/hui-vacuum-commands-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-vacuum-commands-card-feature.ts @@ -8,7 +8,7 @@ import { mdiTargetVariant, } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import { supportsFeature } from "../../../common/entity/supports-feature"; @@ -25,6 +25,7 @@ import { } from "../../../data/vacuum"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { VACUUM_COMMANDS, VacuumCommand, @@ -210,12 +211,7 @@ class HuiVacuumCommandCardFeature } static get styles() { - return css` - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/card-features/hui-water-heater-operation-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-water-heater-operation-modes-card-feature.ts index f676b16ca6..a1c7c6690b 100644 --- a/src/panels/lovelace/card-features/hui-water-heater-operation-modes-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-water-heater-operation-modes-card-feature.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -18,8 +18,9 @@ import { } from "../../../data/water_heater"; import { HomeAssistant } from "../../../types"; import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; -import { WaterHeaterOperationModesCardFeatureConfig } from "./types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; import { filterModes } from "./common/filter-modes"; +import { WaterHeaterOperationModesCardFeatureConfig } from "./types"; export const supportsWaterHeaterOperationModesCardFeature = ( stateObj: HassEntity @@ -118,41 +119,23 @@ class HuiWaterHeaterOperationModeCardFeature })); return html` -
- - -
+ + `; } static get styles() { - return css` - ha-control-select { - --control-select-color: var(--feature-color); - --control-select-padding: 0; - --control-select-thickness: 40px; - --control-select-border-radius: 10px; - --control-select-button-border-radius: 10px; - } - ha-control-button-group { - margin: 0 12px 12px 12px; - --control-button-group-spacing: 12px; - } - .container { - padding: 0 12px 12px 12px; - width: auto; - } - `; + return cardFeatureStyles; } } diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index 95944eff0a..954ccd9d1a 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -38,6 +38,7 @@ import { LovelaceCard } from "../../types"; import { EnergyDevicesDetailGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; import { getCommonOptions } from "./common/energy-chart-options"; +import { storage } from "../../../../common/decorators/storage"; const UNIT = "kWh"; @@ -64,7 +65,12 @@ export class HuiEnergyDevicesDetailGraphCard @state() private _compareEnd?: Date; - @state() private _hiddenStats = new Set(); + @storage({ + key: "energy-devices-hidden-stats", + state: true, + subscribe: false, + }) + private _hiddenStats: string[] = []; protected hassSubscribeRequiredHostProps = ["_config"]; @@ -143,19 +149,18 @@ export class HuiEnergyDevicesDetailGraphCard } private _datasetHidden(ev) { - ev.stopPropagation(); - this._hiddenStats.add( - this._data!.prefs.device_consumption[ev.detail.index].stat_consumption - ); - this.requestUpdate("_hiddenStats"); + this._hiddenStats = [ + ...this._hiddenStats, + this._data!.prefs.device_consumption[ev.detail.index].stat_consumption, + ]; } private _datasetUnhidden(ev) { - ev.stopPropagation(); - this._hiddenStats.delete( - this._data!.prefs.device_consumption[ev.detail.index].stat_consumption + this._hiddenStats = this._hiddenStats.filter( + (stat) => + stat !== + this._data!.prefs.device_consumption[ev.detail.index].stat_consumption ); - this.requestUpdate("_hiddenStats"); } private _createOptions = memoizeOne( @@ -341,7 +346,7 @@ export class HuiEnergyDevicesDetailGraphCard statisticsMetaData[source.stat_consumption] ), hidden: - this._hiddenStats.has(source.stat_consumption) || itemExceedsMax, + this._hiddenStats.includes(source.stat_consumption) || itemExceedsMax, borderColor: compare ? color + "7F" : color, backgroundColor: compare ? color + "32" : color + "7F", data: consumptionData, diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 34c47a691e..e8347d0854 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -25,8 +25,6 @@ declare global { export class HuiCard extends ReactiveElement { @property({ type: Boolean }) public preview = false; - @property({ attribute: false }) public isPanel = false; - @property({ attribute: false }) public config?: LovelaceCardConfig; @property({ attribute: false }) public hass?: HomeAssistant; @@ -150,8 +148,10 @@ export class HuiCard extends ReactiveElement { if (changedProps.has("config")) { const elementConfig = this._elementConfig; if (this.config !== elementConfig && this.config) { - const typeChanged = this.config?.type !== elementConfig?.type; - if (typeChanged) { + const typeChanged = + this.config?.type !== elementConfig?.type || this.preview; + // Rebuild the card if the type of the card has changed or if we are in preview mode + if (typeChanged || this.preview) { this._loadElement(this.config); } else { this._updateElement(this.config); @@ -176,11 +176,14 @@ export class HuiCard extends ReactiveElement { this._loadElement(createErrorCardConfig(e.message, null)); } } - if (changedProps.has("isPanel")) { - this._element.isPanel = this.isPanel; - } if (changedProps.has("layout")) { - this._element.layout = this.layout; + try { + this._element.layout = this.layout; + // For backwards compatibility + (this._element as any).isPanel = this.layout === "panel"; + } catch (e: any) { + this._loadElement(createErrorCardConfig(e.message, null)); + } } } diff --git a/src/panels/lovelace/cards/hui-entities-card.ts b/src/panels/lovelace/cards/hui-entities-card.ts index 4fb12c4661..152c8b1ca4 100644 --- a/src/panels/lovelace/cards/hui-entities-card.ts +++ b/src/panels/lovelace/cards/hui-entities-card.ts @@ -302,7 +302,9 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard { state_color: this._config.state_color, ...(entityConf as EntityConfig), } as EntityConfig) - : entityConf + : entityConf.type === "perform-action" + ? { ...entityConf, type: "call-service" } + : entityConf ); if (this._hass) { element.hass = this._hass; diff --git a/src/panels/lovelace/cards/hui-entity-filter-card.ts b/src/panels/lovelace/cards/hui-entity-filter-card.ts index d79e88ad0d..804801cc3f 100644 --- a/src/panels/lovelace/cards/hui-entity-filter-card.ts +++ b/src/panels/lovelace/cards/hui-entity-filter-card.ts @@ -53,7 +53,7 @@ export class HuiEntityFilterCard @property({ attribute: false }) public hass?: HomeAssistant; - @property({ type: Boolean }) public isPanel = false; + @property({ attribute: false }) public layout?: string; @property({ type: Boolean }) public preview = false; @@ -118,7 +118,7 @@ export class HuiEntityFilterCard if (this._element) { this._element.hass = this.hass; this._element.preview = this.preview; - this._element.isPanel = this.isPanel; + this._element.layout = this.layout; } if (changedProps.has("_config")) { diff --git a/src/panels/lovelace/cards/hui-iframe-card.ts b/src/panels/lovelace/cards/hui-iframe-card.ts index 48113e02d6..cc2cdeade5 100644 --- a/src/panels/lovelace/cards/hui-iframe-card.ts +++ b/src/panels/lovelace/cards/hui-iframe-card.ts @@ -29,9 +29,6 @@ export class HuiIframeCard extends LitElement implements LovelaceCard { }; } - @property({ type: Boolean, reflect: true }) - public isPanel = false; - @property({ attribute: false }) public layout?: string; @@ -63,7 +60,7 @@ export class HuiIframeCard extends LitElement implements LovelaceCard { } let padding = ""; - const ignoreAspectRatio = this.isPanel || this.layout === "grid"; + const ignoreAspectRatio = this.layout === "panel" || this.layout === "grid"; if (!ignoreAspectRatio) { if (this._config.aspect_ratio) { const ratio = parseAspectRatio(this._config.aspect_ratio); diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index 0c810280c2..17e6df279f 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -54,11 +54,7 @@ interface MapEntityConfig extends EntityConfig { class HuiMapCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ type: Boolean, reflect: true }) - public isPanel = false; - - @property({ attribute: false }) - public layout?: string; + @property({ attribute: false }) public layout?: string; @state() private _stateHistory?: HistoryStates; @@ -301,7 +297,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { private _computePadding(): void { const root = this.shadowRoot!.getElementById("root"); - const ignoreAspectRatio = this.isPanel || this.layout === "grid"; + const ignoreAspectRatio = this.layout === "panel" || this.layout === "grid"; if (!this._config || ignoreAspectRatio || !root) { return; } diff --git a/src/panels/lovelace/cards/hui-markdown-card.ts b/src/panels/lovelace/cards/hui-markdown-card.ts index ec78139c22..5ab0227f18 100644 --- a/src/panels/lovelace/cards/hui-markdown-card.ts +++ b/src/panels/lovelace/cards/hui-markdown-card.ts @@ -9,6 +9,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import "../../../components/ha-card"; import "../../../components/ha-markdown"; @@ -113,7 +114,15 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard { if (changedProps.has("_config")) { this._tryConnect(); } - + const shouldBeHidden = + this._templateResult && + this._config.show_empty === false && + this._templateResult.result.length === 0; + if (shouldBeHidden !== this.hidden) { + this.style.display = shouldBeHidden ? "none" : "block"; + this.toggleAttribute("hidden", shouldBeHidden); + fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden }); + } const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldConfig = changedProps.get("_config") as | MarkdownCardConfig diff --git a/src/panels/lovelace/cards/hui-picture-card.ts b/src/panels/lovelace/cards/hui-picture-card.ts index be5902ba84..f5abf64b58 100644 --- a/src/panels/lovelace/cards/hui-picture-card.ts +++ b/src/panels/lovelace/cards/hui-picture-card.ts @@ -10,6 +10,7 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { computeDomain } from "../../../common/entity/compute_domain"; import "../../../components/ha-card"; import { computeImageUrl, ImageEntity } from "../../../data/image"; import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; @@ -21,6 +22,7 @@ import { hasConfigChanged } from "../common/has-changed"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { LovelaceCard, LovelaceCardEditor } from "../types"; import { PictureCardConfig } from "./types"; +import { PersonEntity } from "../../../data/person"; @customElement("hui-picture-card") export class HuiPictureCard extends LitElement implements LovelaceCard { @@ -95,10 +97,10 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { return nothing; } - let stateObj: ImageEntity | undefined; + let stateObj: ImageEntity | PersonEntity | undefined; if (this._config.image_entity) { - stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + stateObj = this.hass.states[this._config.image_entity]; if (!stateObj) { return html` ${createEntityNotFoundWarning(this.hass, this._config.image_entity)} @@ -106,6 +108,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { } } + let image: string | undefined = this._config.image; + if (this._config.image_entity) { + const domain: string = computeDomain(this._config.image_entity); + switch (domain) { + case "image": + image = computeImageUrl(stateObj as ImageEntity); + break; + case "person": + if ((stateObj as PersonEntity).attributes.entity_picture) { + image = (stateObj as PersonEntity).attributes.entity_picture; + } + break; + } + } + return html` `; diff --git a/src/panels/lovelace/cards/hui-picture-elements-card.ts b/src/panels/lovelace/cards/hui-picture-elements-card.ts index 09f0455c2a..9ddf4d65e3 100644 --- a/src/panels/lovelace/cards/hui-picture-elements-card.ts +++ b/src/panels/lovelace/cards/hui-picture-elements-card.ts @@ -8,17 +8,24 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { computeDomain } from "../../../common/entity/compute_domain"; import "../../../components/ha-card"; import { ImageEntity, computeImageUrl } from "../../../data/image"; import { HomeAssistant } from "../../../types"; import { findEntities } from "../common/find-entities"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; -import { LovelaceCard } from "../types"; +import { LovelaceCard, LovelaceCardEditor } from "../types"; import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element"; import { PictureElementsCardConfig } from "./types"; +import { PersonEntity } from "../../../data/person"; @customElement("hui-picture-elements-card") class HuiPictureElementsCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-picture-elements-card-editor"); + return document.createElement("hui-picture-elements-card-editor"); + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _elements?: LovelaceElement[]; @@ -116,9 +123,21 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { return nothing; } - let stateObj: ImageEntity | undefined; + let image: string | undefined = this._config.image; if (this._config.image_entity) { - stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + const stateObj: ImageEntity | PersonEntity | undefined = + this.hass.states[this._config.image_entity]; + const domain: string = computeDomain(this._config.image_entity); + switch (domain) { + case "image": + image = computeImageUrl(stateObj as ImageEntity); + break; + case "person": + if ((stateObj as PersonEntity).attributes.entity_picture) { + image = (stateObj as PersonEntity).attributes.entity_picture; + } + break; + } } return html` @@ -126,7 +145,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
${entityState}
`; } - const domain = computeDomain(this._config.entity); + const domain: string = computeDomain(this._config.entity); + let image: string | undefined = this._config.image; + switch (domain) { + case "image": + image = computeImageUrl(stateObj as ImageEntity); + break; + case "person": + if ((stateObj as PersonEntity).attributes.entity_picture) { + image = (stateObj as PersonEntity).attributes.entity_picture; + } + break; + } return html` @state() protected _config?: T; - @property({ type: Boolean, reflect: true }) - public isPanel = false; + @property({ attribute: false }) public layout?: string; public getCardSize(): number | Promise { return 1; @@ -62,6 +61,10 @@ export abstract class HuiStackCard }); } } + + if (changedProperties.has("layout")) { + this.toggleAttribute("ispanel", this.layout === "panel"); + } } private _createCardElement(cardConfig: LovelaceCardConfig) { diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index ae8c8a99c0..19ece5430b 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -1,19 +1,11 @@ import { mdiExclamationThick, mdiHelp } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { - CSSResultGroup, - LitElement, - TemplateResult, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; -import { ensureArray } from "../../../common/array/ensure-array"; import { computeCssColor } from "../../../common/color/compute-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { DOMAINS_TOGGLE } from "../../../common/const"; @@ -30,17 +22,14 @@ import "../../../components/tile/ha-tile-image"; import type { TileImageStyle } from "../../../components/tile/ha-tile-image"; import "../../../components/tile/ha-tile-info"; import { cameraUrlWithWidthHeight } from "../../../data/camera"; -import { isUnavailableState } from "../../../data/entity"; import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; -import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor"; -import { UpdateEntity, computeUpdateStateDisplay } from "../../../data/update"; +import "../../../state-display/state-display"; import { HomeAssistant } from "../../../types"; import "../card-features/hui-card-features"; import { actionHandler } from "../common/directives/action-handler-directive"; import { findEntities } from "../common/find-entities"; import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; -import "../components/hui-timestamp-display"; import type { LovelaceCard, LovelaceCardEditor, @@ -49,8 +38,6 @@ import type { import { renderTileBadge } from "./tile/badges/tile-badge"; import type { ThermostatCardConfig, TileCardConfig } from "./types"; -const TIMESTAMP_STATE_DOMAINS = ["button", "input_button", "scene"]; - export const getEntityDefaultTileIconAction = (entityId: string) => { const domain = computeDomain(entityId); const supportsIconAction = @@ -126,8 +113,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { let grid_min_columns = 2; let grid_rows = 1; if (this._config?.features?.length) { - const featureHeight = Math.ceil((this._config.features.length * 2) / 3); - grid_rows += featureHeight; + grid_rows += this._config.features.length; } if (this._config?.vertical) { grid_rows++; @@ -208,118 +194,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } ); - private _renderStateContent( - stateObj: HassEntity, - stateContent: string | string[] - ) { - const contents = ensureArray(stateContent); - - const values = contents - .map((content) => { - if (content === "state") { - const domain = computeDomain(stateObj.entity_id); - if ( - (stateObj.attributes.device_class === - SENSOR_DEVICE_CLASS_TIMESTAMP || - TIMESTAMP_STATE_DOMAINS.includes(domain)) && - !isUnavailableState(stateObj.state) - ) { - return html` - - `; - } - - return this.hass!.formatEntityState(stateObj); - } - if (content === "last-changed") { - return html` - - `; - } - if (content === "last-updated") { - return html` - - `; - } - if (content === "last_triggered") { - return html` - - `; - } - if (stateObj.attributes[content] == null) { - return undefined; - } - return this.hass!.formatEntityAttributeValue(stateObj, content); - }) - .filter(Boolean); - - if (!values.length) { - return html`${this.hass!.formatEntityState(stateObj)}`; - } - - return html` - ${values.map( - (value, index, array) => - html`${value}${index < array.length - 1 ? " ⸱ " : nothing}` - )} - `; - } - - private _renderState(stateObj: HassEntity): TemplateResult | typeof nothing { - const domain = computeDomain(stateObj.entity_id); - const active = stateActive(stateObj); - - if (domain === "light" && active) { - return this._renderStateContent(stateObj, ["brightness"]); - } - - if (domain === "fan" && active) { - return this._renderStateContent(stateObj, ["percentage"]); - } - - if (domain === "cover" && active) { - return this._renderStateContent(stateObj, ["state", "current_position"]); - } - - if (domain === "valve" && active) { - return this._renderStateContent(stateObj, ["state", "current_position"]); - } - - if (domain === "humidifier") { - return this._renderStateContent(stateObj, ["state", "current_humidity"]); - } - - if (domain === "climate") { - return this._renderStateContent(stateObj, [ - "state", - "current_temperature", - ]); - } - - if (domain === "update") { - return html`${computeUpdateStateDisplay( - stateObj as UpdateEntity, - this.hass! - )}`; - } - - return this._renderStateContent(stateObj, "state"); - } - get hasCardAction() { return ( !this._config?.tap_action || @@ -366,17 +240,21 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } const name = this._config.name || stateObj.attributes.friendly_name; - - const localizedState = this._config.hide_state - ? nothing - : this._config.state_content - ? this._renderStateContent(stateObj, this._config.state_content) - : this._renderState(stateObj); - const active = stateActive(stateObj); const color = this._computeStateColor(stateObj, this._config.color); const domain = computeDomain(stateObj.entity_id); + const stateDisplay = this._config.hide_state + ? nothing + : html` + + + `; + const style = { "--tile-color": color, }; @@ -400,51 +278,53 @@ export class HuiTileCard extends LitElement implements LovelaceCard { >
-
-
- ${imageUrl - ? html` - - ` - : html` - - - - `} - ${renderTileBadge(stateObj, this.hass)} +
+
+
+ ${imageUrl + ? html` + + ` + : html` + + + + `} + ${renderTileBadge(stateObj, this.hass)} +
+
- + ${this._config.features + ? html` + + ` + : nothing}
- ${this._config.features - ? html` - - ` - : nothing} `; } @@ -492,31 +372,43 @@ export class HuiTileCard extends LitElement implements LovelaceCard { margin: calc(-1 * var(--ha-card-border-width, 1px)); overflow: hidden; } + .container { + margin: calc(-1 * var(--ha-card-border-width, 1px)); + display: flex; + flex-direction: column; + flex: 1; + } .content { + position: relative; display: flex; flex-direction: row; align-items: center; - padding: 12px; + padding: 10px; + flex: 1; + box-sizing: border-box; + pointer-events: none; } .vertical { flex-direction: column; text-align: center; + justify-content: center; } .vertical .icon-container { - margin-bottom: 12px; + margin-bottom: 10px; margin-right: 0; margin-inline-start: initial; margin-inline-end: initial; } .vertical ha-tile-info { width: 100%; + flex: none; } .icon-container { position: relative; flex: none; - margin-right: 12px; + margin-right: 10px; margin-inline-start: initial; - margin-inline-end: 12px; + margin-inline-end: 10px; direction: var(--direction); transition: transform 180ms ease-in-out; } @@ -535,8 +427,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard { inset-inline-end: -3px; inset-inline-start: initial; } - .icon-container:not([role="button"]) { - pointer-events: none; + .icon-container[role="button"] { + pointer-events: auto; } .icon-container[role="button"]:focus-visible, .icon-container[role="button"]:active { @@ -544,11 +436,9 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } ha-tile-info { position: relative; - flex: 1; min-width: 0; transition: background-color 180ms ease-in-out; box-sizing: border-box; - pointer-events: none; } hui-card-features { --feature-color: var(--tile-color); diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 91e8bc2dfc..8bf731f56d 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -66,6 +66,8 @@ export interface EntitiesCardEntityConfig extends EntityConfig { | "tilt-position" | "brightness"; action_name?: string; + action?: string; + /** @deprecated use "action" instead */ service?: string; // "service_data" is kept for backwards compatibility. Replaced by "data". service_data?: Record; @@ -316,6 +318,7 @@ export interface MarkdownCardConfig extends LovelaceCardConfig { card_size?: number; entity_ids?: string | string[]; theme?: string; + show_empty?: boolean; } export interface MediaControlCardConfig extends LovelaceCardConfig { @@ -406,6 +409,7 @@ export interface PictureGlanceCardConfig extends LovelaceCardConfig { entities: Array; title?: string; image?: string; + image_entity?: string; camera_image?: string; camera_view?: HuiImage["cameraView"]; state_image?: Record; diff --git a/src/panels/lovelace/common/compute-tooltip.ts b/src/panels/lovelace/common/compute-tooltip.ts index b371984b44..b002cd2caf 100644 --- a/src/panels/lovelace/common/compute-tooltip.ts +++ b/src/panels/lovelace/common/compute-tooltip.ts @@ -45,7 +45,7 @@ function computeActionTooltip( break; case "call-service": tooltip += `${hass.localize( - "ui.panel.lovelace.cards.picture-elements.call_service", + "ui.panel.lovelace.cards.picture-elements.perform_action", { name: config.service } )}`; break; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 6f75cf3559..c2e8920fde 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -31,6 +31,8 @@ import { } from "../cards/types"; import { EntityConfig } from "../entity-rows/types"; import { ButtonsHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; +import { EntityBadgeConfig } from "../badges/types"; const HIDE_DOMAIN = new Set([ "automation", @@ -113,7 +115,7 @@ export const computeSection = ( type: "tile", entity, show_entity_picture: - ["person", "camera", "image"].includes(computeDomain(entity)) || + ["camera", "image", "person"].includes(computeDomain(entity)) || undefined, }) as TileCardConfig ), @@ -310,6 +312,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"] diff --git a/src/panels/lovelace/common/graph/coordinates.ts b/src/panels/lovelace/common/graph/coordinates.ts index 56f5251965..02226d2bab 100644 --- a/src/panels/lovelace/common/graph/coordinates.ts +++ b/src/panels/lovelace/common/graph/coordinates.ts @@ -22,9 +22,15 @@ const calcPoints = ( let xRatio = width / (hours - (detail === 1 ? 1 : 0)); xRatio = isFinite(xRatio) ? xRatio : width; - const first = history.filter(Boolean)[0]; + let first = history.filter(Boolean)[0]; + if (detail > 1) { + first = first.filter(Boolean)[0]; + } let last = [average(first), lastValue(first)]; + const getY = (value: number): number => + height + strokeWidth / 2 - (value - min) / yRatio; + const getCoords = (item: any[], i: number, offset = 0, depth = 1) => { if (depth > 1 && item) { return item.forEach((subItem, index) => @@ -37,8 +43,7 @@ const calcPoints = ( if (item) { last = [average(item), lastValue(item)]; } - const y = - height + strokeWidth / 2 - ((item ? last[0] : last[1]) - min) / yRatio; + const y = getY(item ? last[0] : last[1]); return coords.push([x, y]); }; @@ -46,11 +51,7 @@ const calcPoints = ( getCoords(history[i], i, 0, detail); } - if (coords.length === 1) { - coords[1] = [width, coords[0][1]]; - } - - coords.push([width, coords[coords.length - 1][1]]); + coords.push([width, getY(last[1])]); return coords; }; diff --git a/src/panels/lovelace/common/handle-action.ts b/src/panels/lovelace/common/handle-action.ts index b9b4041d5f..609e0c162b 100644 --- a/src/panels/lovelace/common/handle-action.ts +++ b/src/panels/lovelace/common/handle-action.ts @@ -56,8 +56,12 @@ export const handleAction = async ( forwardHaptic("warning"); let serviceName; - if (actionConfig.action === "call-service") { - const [domain, service] = actionConfig.service.split(".", 2); + if ( + actionConfig.action === "call-service" || + actionConfig.action === "perform-action" + ) { + const [domain, service] = (actionConfig.perform_action || + actionConfig.service)!.split(".", 2); const serviceDomains = hass.services; if (domain in serviceDomains && service in serviceDomains[domain]) { await hass.loadBackendTranslation("title"); @@ -145,15 +149,17 @@ export const handleAction = async ( } break; } + case "perform-action": case "call-service": { - if (!actionConfig.service) { + if (!actionConfig.perform_action && !actionConfig.service) { showToast(node, { - message: hass.localize("ui.panel.lovelace.cards.actions.no_service"), + message: hass.localize("ui.panel.lovelace.cards.actions.no_action"), }); forwardHaptic("failure"); return; } - const [domain, service] = actionConfig.service.split(".", 2); + const [domain, service] = (actionConfig.perform_action || + actionConfig.service)!.split(".", 2); hass.callService( domain, service, diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 92fa589987..a13f1367db 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -33,7 +33,7 @@ const DEFAULT_ACTIONS: UiAction[] = [ "toggle", "navigate", "url", - "call-service", + "perform-action", "assist", "none", ]; @@ -98,12 +98,12 @@ export class HuiActionEditor extends LitElement { get _service(): string { const config = this.config as CallServiceActionConfig; - return config?.service || ""; + return config?.perform_action || config?.service || ""; } private _serviceAction = memoizeOne( (config: CallServiceActionConfig): ServiceAction => ({ - service: this._service, + action: this._service, ...(config.data || config.service_data ? { data: config.data ?? config.service_data } : null), @@ -127,13 +127,19 @@ export class HuiActionEditor extends LitElement { const actions = this.actions ?? DEFAULT_ACTIONS; + let action = this.config?.action || "default"; + + if (action === "call-service") { + action = "perform-action"; + } + return html` ` : nothing} - ${this.catchInteraction ?? !DOMAINS_INPUT_ROW.includes(domain) + ${(this.catchInteraction ?? !DOMAINS_INPUT_ROW.includes(domain)) ? html`
import("../badges/hui-entity-filter-badge"), + "state-label": () => import("../badges/hui-state-label-badge"), }; +// This will not return an error card but will throw the error +export const tryCreateBadgeElement = (config: LovelaceBadgeConfig) => + tryCreateLovelaceElement( + "badge", + config, + ALWAYS_LOADED_TYPES, + LAZY_LOAD_TYPES, + undefined, + "entity" + ); + export const createBadgeElement = (config: LovelaceBadgeConfig) => createLovelaceElement( "badge", @@ -14,5 +30,8 @@ export const createBadgeElement = (config: LovelaceBadgeConfig) => ALWAYS_LOADED_TYPES, LAZY_LOAD_TYPES, undefined, - "state-label" + "entity" ); + +export const getBadgeElementClass = (type: string) => + getLovelaceElementClass(type, "badge", ALWAYS_LOADED_TYPES, LAZY_LOAD_TYPES); diff --git a/src/panels/lovelace/create-element/create-element-base.ts b/src/panels/lovelace/create-element/create-element-base.ts index 5ba763733c..33aaecb0af 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -12,17 +12,18 @@ import { stripCustomPrefix, } from "../../../data/lovelace_custom_cards"; import { LovelaceCardFeatureConfig } from "../card-features/types"; -import type { HuiErrorCard } from "../cards/hui-error-card"; import type { ErrorCardConfig } from "../cards/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceBadge, + LovelaceBadgeConstructor, LovelaceCard, LovelaceCardConstructor, LovelaceCardFeature, LovelaceCardFeatureConstructor, + LovelaceElementConstructor, LovelaceHeaderFooter, LovelaceHeaderFooterConstructor, LovelaceRowConstructor, @@ -39,12 +40,12 @@ interface CreateElementConfigTypes { badge: { config: LovelaceBadgeConfig; element: LovelaceBadge; - constructor: unknown; + constructor: LovelaceBadgeConstructor; }; element: { config: LovelaceElementConfig; element: LovelaceElement; - constructor: unknown; + constructor: LovelaceElementConstructor; }; row: { config: LovelaceRowConfig; @@ -87,16 +88,36 @@ export const createErrorCardElement = (config: ErrorCardConfig) => { return el; }; +export const createErrorBadgeElement = (config: ErrorCardConfig) => { + const el = document.createElement("hui-error-badge"); + if (customElements.get("hui-error-badge")) { + el.setConfig(config); + } else { + import("../badges/hui-error-badge"); + customElements.whenDefined("hui-error-badge").then(() => { + customElements.upgrade(el); + el.setConfig(config); + }); + } + return el; +}; + export const createErrorCardConfig = (error, origConfig) => ({ type: "error", error, origConfig, }); +export const createErrorBadgeConfig = (error, origConfig) => ({ + type: "error", + error, + origConfig, +}); + const _createElement = ( tag: string, config: CreateElementConfigTypes[T]["config"] -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { const element = document.createElement( tag ) as CreateElementConfigTypes[T]["element"]; @@ -106,11 +127,18 @@ const _createElement = ( }; const _createErrorElement = ( + tagSuffix: T, error: string, config: CreateElementConfigTypes[T]["config"] -): HuiErrorCard => createErrorCardElement(createErrorCardConfig(error, config)); +): CreateElementConfigTypes[T]["element"] => { + if (tagSuffix === "badge") { + return createErrorBadgeElement(createErrorBadgeConfig(error, config)); + } + return createErrorCardElement(createErrorCardConfig(error, config)); +}; const _customCreate = ( + tagSuffix: T, tag: string, config: CreateElementConfigTypes[T]["config"] ) => { @@ -119,6 +147,7 @@ const _customCreate = ( } const element = _createErrorElement( + tagSuffix, `Custom element doesn't exist: ${tag}.`, config ); @@ -175,7 +204,7 @@ export const createLovelaceElement = ( domainTypes?: { _domain_not_found: string; [domain: string]: string }, // Default type if no type given. If given, entity types will not work. defaultType?: string -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { try { return tryCreateLovelaceElement( tagSuffix, @@ -188,7 +217,7 @@ export const createLovelaceElement = ( } catch (err: any) { // eslint-disable-next-line console.error(tagSuffix, config.type, err); - return _createErrorElement(err.message, config); + return _createErrorElement(tagSuffix, err.message, config); } }; @@ -203,7 +232,7 @@ export const tryCreateLovelaceElement = < domainTypes?: { _domain_not_found: string; [domain: string]: string }, // Default type if no type given. If given, entity types will not work. defaultType?: string -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { if (!config || typeof config !== "object") { throw new Error("Config is not an object"); } @@ -220,7 +249,7 @@ export const tryCreateLovelaceElement = < const customTag = config.type ? _getCustomTag(config.type) : undefined; if (customTag) { - return _customCreate(customTag, config); + return _customCreate(tagSuffix, customTag, config); } let type: string | undefined; diff --git a/src/panels/lovelace/create-element/create-hui-element.ts b/src/panels/lovelace/create-element/create-hui-element.ts index aacf1614cd..59c411829f 100644 --- a/src/panels/lovelace/create-element/create-hui-element.ts +++ b/src/panels/lovelace/create-element/create-hui-element.ts @@ -18,5 +18,9 @@ const ALWAYS_LOADED_TYPES = new Set([ "state-label", ]); -export const createHuiElement = (config: LovelaceElementConfig) => - createLovelaceElement("element", config, ALWAYS_LOADED_TYPES); +export const createHuiElement = (config: LovelaceElementConfig) => { + if (config.type === "action-button") { + config = { ...config, type: "service-button" }; + } + return createLovelaceElement("element", config, ALWAYS_LOADED_TYPES); +}; diff --git a/src/panels/lovelace/create-element/create-picture-element.ts b/src/panels/lovelace/create-element/create-picture-element.ts new file mode 100644 index 0000000000..ce848b3bd0 --- /dev/null +++ b/src/panels/lovelace/create-element/create-picture-element.ts @@ -0,0 +1,42 @@ +import "../elements/hui-conditional-element"; +import "../elements/hui-icon-element"; +import "../elements/hui-image-element"; +import "../elements/hui-service-button-element"; +import "../elements/hui-state-badge-element"; +import "../elements/hui-state-icon-element"; +import "../elements/hui-state-label-element"; +import { LovelaceElementConfig } from "../elements/types"; +import { + createLovelaceElement, + getLovelaceElementClass, +} from "./create-element-base"; + +const ALWAYS_LOADED_TYPES = new Set([ + "conditional", + "icon", + "image", + "service-button", + "state-badge", + "state-icon", + "state-label", +]); + +const LAZY_LOAD_TYPES = {}; + +export const createPictureElementElement = (config: LovelaceElementConfig) => + createLovelaceElement( + "element", + config, + ALWAYS_LOADED_TYPES, + LAZY_LOAD_TYPES, + undefined, + undefined + ); + +export const getPictureElementClass = (type: string) => + getLovelaceElementClass( + type, + "element", + ALWAYS_LOADED_TYPES, + LAZY_LOAD_TYPES + ); diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts new file mode 100644 index 0000000000..dad6679e8c --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts @@ -0,0 +1,109 @@ +import { css, CSSResultGroup, html, nothing, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators"; +import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import { getBadgeElementClass } from "../../create-element/create-badge-element"; +import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types"; +import { HuiElementEditor } from "../hui-element-editor"; +import "./hui-badge-visibility-editor"; + +type Tab = "config" | "visibility"; + +@customElement("hui-badge-element-editor") +export class HuiBadgeElementEditor extends HuiElementEditor { + @state() private _curTab: Tab = "config"; + + protected async getConfigElement(): Promise { + const elClass = await getBadgeElementClass(this.configElementType!); + + // Check if a GUI editor exists + if (elClass && elClass.getConfigElement) { + return elClass.getConfigElement(); + } + + return undefined; + } + + protected async getConfigForm(): Promise { + const elClass = await getBadgeElementClass(this.configElementType!); + + // Check if a schema exists + if (elClass && elClass.getConfigForm) { + return elClass.getConfigForm(); + } + + return undefined; + } + + private _handleTabSelected(ev: CustomEvent): void { + if (!ev.detail.value) { + return; + } + this._curTab = ev.detail.value.id; + } + + private _configChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this.value = ev.detail.value; + } + + protected renderConfigElement(): TemplateResult { + const displayedTabs: Tab[] = ["config", "visibility"]; + + let content: TemplateResult<1> | typeof nothing = nothing; + + switch (this._curTab) { + case "config": + content = html`${super.renderConfigElement()}`; + break; + case "visibility": + content = html` + + `; + break; + } + return html` + + ${displayedTabs.map( + (tab, index) => html` + + ${this.hass.localize( + `ui.panel.lovelace.editor.edit_badge.tab_${tab}` + )} + + ` + )} + + ${content} + `; + } + + static get styles(): CSSResultGroup { + return [ + HuiElementEditor.styles, + css` + paper-tabs { + --paper-tabs-selection-bar-color: var(--primary-color); + color: var(--primary-text-color); + text-transform: uppercase; + margin-bottom: 16px; + border-bottom: 1px solid var(--divider-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-element-editor": HuiBadgeElementEditor; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts new file mode 100644 index 0000000000..81edb16c9f --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts @@ -0,0 +1,590 @@ +import Fuse, { IFuseOptions } from "fuse.js"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { styleMap } from "lit/directives/style-map"; +import { until } from "lit/directives/until"; +import memoizeOne from "memoize-one"; +import { storage } from "../../../../common/decorators/storage"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { stringCompare } from "../../../../common/string/compare"; +import { stripDiacritics } from "../../../../common/string/strip-diacritics"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/search-input"; +import { isUnavailableState } from "../../../../data/entity"; +import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { + CUSTOM_TYPE_PREFIX, + CustomBadgeEntry, + customBadges, + getCustomBadgeEntry, +} from "../../../../data/lovelace_custom_cards"; +import type { HomeAssistant } from "../../../../types"; +import { getStripDiacriticsFn } from "../../../../util/fuse"; +import { + calcUnusedEntities, + computeUsedEntities, +} from "../../common/compute-unused-entities"; +import { tryCreateBadgeElement } from "../../create-element/create-badge-element"; +import type { LovelaceBadge } from "../../types"; +import { getBadgeStubConfig } from "../get-badge-stub-config"; +import { coreBadges } from "../lovelace-badges"; +import type { Badge, BadgePickTarget } from "../types"; + +interface BadgeElement { + badge: Badge; + element: TemplateResult; +} + +@customElement("hui-badge-picker") +export class HuiBadgePicker extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public suggestedBadges?: string[]; + + @storage({ + key: "dashboardBadgeClipboard", + state: true, + subscribe: true, + storage: "sessionStorage", + }) + private _clipboard?: LovelaceBadgeConfig; + + @state() private _badges: BadgeElement[] = []; + + public lovelace?: LovelaceConfig; + + public badgePicked?: (badgeConf: LovelaceBadgeConfig) => void; + + @state() private _filter = ""; + + @state() private _width?: number; + + @state() private _height?: number; + + private _unusedEntities?: string[]; + + private _usedEntities?: string[]; + + private _filterBadges = memoizeOne( + (badgeElements: BadgeElement[], filter?: string): BadgeElement[] => { + if (!filter) { + return badgeElements; + } + let badges = badgeElements.map( + (badgeElement: BadgeElement) => badgeElement.badge + ); + const options: IFuseOptions = { + keys: ["type", "name", "description"], + isCaseSensitive: false, + minMatchCharLength: Math.min(filter.length, 2), + threshold: 0.2, + getFn: getStripDiacriticsFn, + }; + const fuse = new Fuse(badges, options); + badges = fuse + .search(stripDiacritics(filter)) + .map((result) => result.item); + return badgeElements.filter((badgeElement: BadgeElement) => + badges.includes(badgeElement.badge) + ); + } + ); + + private _suggestedBadges = memoizeOne( + (badgeElements: BadgeElement[]): BadgeElement[] => + badgeElements.filter( + (badgeElement: BadgeElement) => badgeElement.badge.isSuggested + ) + ); + + private _customBadges = memoizeOne( + (badgeElements: BadgeElement[]): BadgeElement[] => + badgeElements.filter( + (badgeElement: BadgeElement) => + badgeElement.badge.isCustom && !badgeElement.badge.isSuggested + ) + ); + + private _otherBadges = memoizeOne( + (badgeElements: BadgeElement[]): BadgeElement[] => + badgeElements.filter( + (badgeElement: BadgeElement) => + !badgeElement.badge.isSuggested && !badgeElement.badge.isCustom + ) + ); + + protected render() { + if ( + !this.hass || + !this.lovelace || + !this._unusedEntities || + !this._usedEntities + ) { + return nothing; + } + + const suggestedBadges = this._suggestedBadges(this._badges); + const otherBadges = this._otherBadges(this._badges); + const customBadgesItems = this._customBadges(this._badges); + + return html` + +
+
+ ${this._filter + ? this._filterBadges(this._badges, this._filter).map( + (badgeElement: BadgeElement) => badgeElement.element + ) + : html` + ${suggestedBadges.length > 0 + ? html` +
+ ${this.hass!.localize( + `ui.panel.lovelace.editor.badge.generic.suggested_badges` + )} +
+ ` + : nothing} + ${this._renderClipboardBadge()} + ${suggestedBadges.map( + (badgeElement: BadgeElement) => badgeElement.element + )} + ${suggestedBadges.length > 0 + ? html` +
+ ${this.hass!.localize( + `ui.panel.lovelace.editor.badge.generic.other_badges` + )} +
+ ` + : nothing} + ${otherBadges.map( + (badgeElement: BadgeElement) => badgeElement.element + )} + ${customBadgesItems.length > 0 + ? html` +
+ ${this.hass!.localize( + `ui.panel.lovelace.editor.badge.generic.custom_badges` + )} +
+ ` + : nothing} + ${customBadgesItems.map( + (badgeElement: BadgeElement) => badgeElement.element + )} + `} +
+
+
+
+ ${this.hass!.localize( + `ui.panel.lovelace.editor.badge.generic.manual` + )} +
+
+ ${this.hass!.localize( + `ui.panel.lovelace.editor.badge.generic.manual_description` + )} +
+
+
+
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if (!oldHass) { + return true; + } + + if (oldHass.locale !== this.hass!.locale) { + return true; + } + + return false; + } + + protected firstUpdated(): void { + if (!this.hass || !this.lovelace) { + return; + } + + const usedEntities = computeUsedEntities(this.lovelace); + const unusedEntities = calcUnusedEntities(this.hass, usedEntities); + + this._usedEntities = [...usedEntities].filter( + (eid) => + this.hass!.states[eid] && + !isUnavailableState(this.hass!.states[eid].state) + ); + this._unusedEntities = [...unusedEntities].filter( + (eid) => + this.hass!.states[eid] && + !isUnavailableState(this.hass!.states[eid].state) + ); + + this._loadBages(); + } + + private _loadBages() { + let badges = coreBadges.map((badge) => ({ + name: this.hass!.localize( + `ui.panel.lovelace.editor.badge.${badge.type}.name` + ), + description: this.hass!.localize( + `ui.panel.lovelace.editor.badge.${badge.type}.description` + ), + isSuggested: this.suggestedBadges?.includes(badge.type) || false, + ...badge, + })); + + badges = badges.sort((a, b) => { + if (a.isSuggested && !b.isSuggested) { + return -1; + } + if (!a.isSuggested && b.isSuggested) { + return 1; + } + return stringCompare( + a.name || a.type, + b.name || b.type, + this.hass?.language + ); + }); + + if (customBadges.length > 0) { + badges = badges.concat( + customBadges + .map((cbadge: CustomBadgeEntry) => ({ + type: cbadge.type, + name: cbadge.name, + description: cbadge.description, + showElement: cbadge.preview, + isCustom: true, + })) + .sort((a, b) => + stringCompare( + a.name || a.type, + b.name || b.type, + this.hass?.language + ) + ) + ); + } + this._badges = badges.map((badge) => ({ + badge: badge, + element: html`${until( + this._renderBadgeElement(badge), + html` +
+ +
+ ` + )}`, + })); + } + + private _renderClipboardBadge() { + if (!this._clipboard) { + return nothing; + } + + return html` ${until( + this._renderBadgeElement( + { + type: this._clipboard.type, + showElement: true, + isCustom: false, + name: this.hass!.localize( + "ui.panel.lovelace.editor.badge.generic.paste" + ), + description: `${this.hass!.localize( + "ui.panel.lovelace.editor.badge.generic.paste_description", + { + type: this._clipboard.type, + } + )}`, + }, + this._clipboard + ), + html` +
+ +
+ ` + )}`; + } + + private _handleSearchChange(ev: CustomEvent) { + const value = ev.detail.value; + + if (!value) { + // Reset when we no longer filter + this._width = undefined; + this._height = undefined; + } else if (!this._width || !this._height) { + // Save height and width so the dialog doesn't jump while searching + const div = this.shadowRoot!.getElementById("content"); + if (div && !this._width) { + const width = div.clientWidth; + if (width) { + this._width = width; + } + } + if (div && !this._height) { + const height = div.clientHeight; + if (height) { + this._height = height; + } + } + } + + this._filter = value; + } + + private _badgePicked(ev: Event): void { + const config: LovelaceBadgeConfig = (ev.currentTarget! as BadgePickTarget) + .config; + + fireEvent(this, "config-changed", { config }); + } + + private _tryCreateBadgeElement(badge: LovelaceBadgeConfig) { + const element = tryCreateBadgeElement(badge) as LovelaceBadge; + element.hass = this.hass; + element.addEventListener( + "ll-rebuild", + (ev) => { + ev.stopPropagation(); + this._rebuildBadge(element, badge); + }, + { once: true } + ); + return element; + } + + private _rebuildBadge( + badgeElToReplace: LovelaceBadge, + config: LovelaceBadgeConfig + ): void { + let newBadgeEl: LovelaceBadge; + try { + newBadgeEl = this._tryCreateBadgeElement(config); + } catch (err: any) { + return; + } + if (badgeElToReplace.parentElement) { + badgeElToReplace.parentElement!.replaceChild( + newBadgeEl, + badgeElToReplace + ); + } + } + + private async _renderBadgeElement( + badge: Badge, + config?: LovelaceBadgeConfig + ): Promise { + let { type } = badge; + const { showElement, isCustom, name, description } = badge; + const customBadge = isCustom ? getCustomBadgeEntry(type) : undefined; + if (isCustom) { + type = `${CUSTOM_TYPE_PREFIX}${type}`; + } + + let element: LovelaceBadge | undefined; + let badgeConfig: LovelaceBadgeConfig = config ?? { type }; + + if (this.hass && this.lovelace) { + if (!config) { + badgeConfig = await getBadgeStubConfig( + this.hass, + type, + this._unusedEntities!, + this._usedEntities! + ); + } + + if (showElement) { + try { + element = this._tryCreateBadgeElement(badgeConfig); + } catch (err: any) { + element = undefined; + } + } + } + + return html` +
+
+
+ ${customBadge + ? `${this.hass!.localize( + "ui.panel.lovelace.editor.badge_picker.custom_badge" + )}: ${customBadge.name || customBadge.type}` + : name} +
+
+ ${element && element.tagName !== "HUI-ERROR-BADGE" + ? element + : customBadge + ? customBadge.description || + this.hass!.localize( + `ui.panel.lovelace.editor.badge_picker.no_description` + ) + : description} +
+
+ `; + } + + static get styles(): CSSResultGroup { + return [ + css` + search-input { + display: block; + --mdc-shape-small: var(--badge-picker-search-shape); + margin: var(--badge-picker-search-margin); + } + + .badges-container-header { + font-size: 16px; + font-weight: 500; + padding: 12px 8px 4px 8px; + margin: 0; + grid-column: 1 / -1; + } + + .badges-container { + display: grid; + grid-gap: 8px 8px; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + margin-top: 20px; + } + + .badge { + height: 100%; + max-width: 500px; + display: flex; + flex-direction: column; + border-radius: var(--ha-card-border-radius, 12px); + background: var(--primary-background-color, #fafafa); + cursor: pointer; + position: relative; + overflow: hidden; + border: var(--ha-card-border-width, 1px) solid + var(--ha-card-border-color, var(--divider-color)); + } + + .badge-header { + color: var(--ha-card-header-color, --primary-text-color); + font-family: var(--ha-card-header-font-family, inherit); + font-size: 16px; + font-weight: bold; + letter-spacing: -0.012em; + line-height: 20px; + padding: 12px 16px; + display: block; + text-align: center; + background: var( + --ha-card-background, + var(--card-background-color, white) + ); + border-bottom: 1px solid var(--divider-color); + } + + .preview { + pointer-events: none; + margin: 20px; + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + } + + .description { + text-align: center; + } + + .spinner { + align-items: center; + justify-content: center; + } + + .overlay { + position: absolute; + width: 100%; + height: 100%; + z-index: 1; + box-sizing: border-box; + border-radius: var(--ha-card-border-radius, 12px); + } + + .manual { + grid-column: 1 / -1; + max-width: none; + } + + .icon { + position: absolute; + top: 8px; + right: 8px; + inset-inline-start: 8px; + inset-inline-end: 8px; + border-radius: 50%; + --mdc-icon-size: 16px; + line-height: 16px; + box-sizing: border-box; + color: var(--text-primary-color); + padding: 4px; + } + .icon.custom { + background: var(--warning-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-picker": HuiBadgePicker; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts new file mode 100644 index 0000000000..f7f1612d9f --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts @@ -0,0 +1,59 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-alert"; +import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; +import { HomeAssistant } from "../../../../types"; +import { Condition } from "../../common/validate-condition"; +import "../conditions/ha-card-conditions-editor"; + +@customElement("hui-badge-visibility-editor") +export class HuiBadgeVisibilityEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public config!: LovelaceCardConfig; + + render() { + const conditions = this.config.visibility ?? []; + return html` +

+ ${this.hass.localize( + `ui.panel.lovelace.editor.edit_badge.visibility.explanation` + )} +

+ + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const conditions = ev.detail.value as Condition[]; + const newConfig: LovelaceCardConfig = { + ...this.config, + visibility: conditions, + }; + if (newConfig.visibility?.length === 0) { + delete newConfig.visibility; + } + fireEvent(this, "value-changed", { value: newConfig }); + } + + static styles = css` + .intro { + margin: 0; + color: var(--secondary-text-color); + margin-bottom: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-visibility-editor": HuiBadgeVisibilityEditor; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts b/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts new file mode 100644 index 0000000000..77bbfaead8 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts @@ -0,0 +1,289 @@ +import "@material/mwc-tab-bar/mwc-tab-bar"; +import "@material/mwc-tab/mwc-tab"; +import { mdiClose } from "@mdi/js"; +import { css, CSSResultGroup, 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 memoize from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import { DataTableRowData } from "../../../../components/data-table/ha-data-table"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-header"; +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 { computeBadges } from "../../common/generate-lovelace-config"; +import "../card-editor/hui-entity-picker-table"; +import { findLovelaceContainer } from "../lovelace-path"; +import "./hui-badge-picker"; +import { 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 + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: CreateBadgeDialogParams; + + @state() private _containerConfig!: LovelaceViewConfig; + + @state() private _selectedEntities: string[] = []; + + @state() private _currTabIndex = 0; + + public async showDialog(params: CreateBadgeDialogParams): Promise { + this._params = params; + + const containerConfig = findLovelaceContainer( + params.lovelaceConfig, + params.path + ); + + if ("strategy" in containerConfig) { + throw new Error("Can't edit strategy"); + } + + this._containerConfig = containerConfig; + } + + public closeDialog(): boolean { + this._params = undefined; + this._currTabIndex = 0; + this._selectedEntities = []; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + protected render() { + if (!this._params) { + return nothing; + } + + const title = this._containerConfig.title + ? this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.pick_badge_title", + { name: `"${this._containerConfig.title}"` } + ) + : this.hass!.localize("ui.panel.lovelace.editor.edit_badge.pick_badge"); + + return html` + + + + ${title} + + + + + + ${cache( + this._currTabIndex === 0 + ? html` + + ` + : html` + + ` + )} + +
+ + ${this.hass!.localize("ui.common.cancel")} + + ${this._selectedEntities.length + ? html` + + ${this.hass!.localize("ui.common.continue")} + + ` + : ""} +
+
+ `; + } + + private _ignoreKeydown(ev: KeyboardEvent) { + ev.stopPropagation(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-dialog { + --mdc-dialog-max-height: 100%; + height: 100%; + } + } + + @media all and (min-width: 850px) { + ha-dialog { + --mdc-dialog-min-width: 845px; + } + } + + ha-dialog { + --mdc-dialog-max-width: 845px; + --dialog-content-padding: 2px 24px 20px 24px; + --dialog-z-index: 6; + } + + ha-dialog.table { + --dialog-content-padding: 0; + } + + @media (min-width: 1200px) { + ha-dialog { + --mdc-dialog-max-width: calc(100vw - 32px); + --mdc-dialog-min-width: 1000px; + } + } + + hui-badge-picker { + --badge-picker-search-shape: 0; + --badge-picker-search-margin: -2px -24px 0; + } + hui-entity-picker-table { + display: block; + height: calc(100vh - 198px); + --mdc-shape-small: 0; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + hui-entity-picker-table { + height: calc(100vh - 158px); + } + } + `, + ]; + } + + private _handleBadgePicked(ev) { + const config = ev.detail.config; + if (this._params!.entities && this._params!.entities.length) { + if (Object.keys(config).includes("entities")) { + config.entities = this._params!.entities; + } else if (Object.keys(config).includes("entity")) { + config.entity = this._params!.entities[0]; + } + } + + showEditBadgeDialog(this, { + lovelaceConfig: this._params!.lovelaceConfig, + saveConfig: this._params!.saveConfig, + path: this._params!.path, + badgeConfig: config, + }); + + this.closeDialog(); + } + + private _handleTabChanged(ev: CustomEvent): void { + const newTab = ev.detail.index; + if (newTab === this._currTabIndex) { + return; + } + + this._currTabIndex = ev.detail.index; + this._selectedEntities = []; + } + + private _handleSelectedChanged(ev: CustomEvent): void { + this._selectedEntities = ev.detail.selectedEntities; + } + + private _cancel(ev?: Event) { + if (ev) { + ev.stopPropagation(); + } + 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(); + } + + private _allEntities = memoize((entities) => + Object.keys(entities).map((entity) => { + const stateObj = this.hass.states[entity]; + return { + icon: "", + entity_id: entity, + stateObj, + name: computeStateName(stateObj), + domain: computeDomain(entity), + last_changed: stateObj!.last_changed, + } as DataTableRowData; + }) + ); +} + +declare global { + interface HTMLElementTagNameMap { + "hui-dialog-create-badge": HuiCreateDialogBadge; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts b/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts new file mode 100644 index 0000000000..efd61b5309 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts @@ -0,0 +1,524 @@ +import { mdiClose, mdiHelpCircle } from "@mdi/js"; +import deepFreeze from "deep-freeze"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import type { HASSDomEvent } from "../../../../common/dom/fire_event"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeRTLDirection } from "../../../../common/util/compute_rtl"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import { + ensureBadgeConfig, + LovelaceBadgeConfig, +} from "../../../../data/lovelace/config/badge"; +import { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; +import { + getCustomBadgeEntry, + isCustomType, + stripCustomPrefix, +} from "../../../../data/lovelace_custom_cards"; +import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; +import "../../badges/hui-badge"; +import "../../sections/hui-section"; +import { addBadge, replaceBadge } from "../config-util"; +import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url"; +import type { ConfigChangedEvent } from "../hui-element-editor"; +import { findLovelaceContainer } from "../lovelace-path"; +import type { GUIModeChangedEvent } from "../types"; +import "./hui-badge-element-editor"; +import type { HuiBadgeElementEditor } from "./hui-badge-element-editor"; +import type { EditBadgeDialogParams } from "./show-edit-badge-dialog"; + +declare global { + // for fire event + interface HASSDomEvents { + "reload-lovelace": undefined; + } + // for add event listener + interface HTMLElementEventMap { + "reload-lovelace": HASSDomEvent; + } +} + +@customElement("hui-dialog-edit-badge") +export class HuiDialogEditBadge + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public large = false; + + @state() private _params?: EditBadgeDialogParams; + + @state() private _badgeConfig?: LovelaceBadgeConfig; + + @state() private _containerConfig!: LovelaceViewConfig; + + @state() private _saving = false; + + @state() private _error?: string; + + @state() private _guiModeAvailable? = true; + + @query("hui-badge-element-editor") + private _badgeEditorEl?: HuiBadgeElementEditor; + + @state() private _GUImode = true; + + @state() private _documentationURL?: string; + + @state() private _dirty = false; + + @state() private _isEscapeEnabled = true; + + public async showDialog(params: EditBadgeDialogParams): Promise { + this._params = params; + this._GUImode = true; + this._guiModeAvailable = true; + + const containerConfig = findLovelaceContainer( + params.lovelaceConfig, + params.path + ); + + if ("strategy" in containerConfig) { + throw new Error("Can't edit strategy"); + } + + this._containerConfig = containerConfig; + + if ("badgeConfig" in params) { + this._badgeConfig = params.badgeConfig; + this._dirty = true; + } else { + const badge = this._containerConfig.badges?.[params.badgeIndex]; + this._badgeConfig = badge != null ? ensureBadgeConfig(badge) : badge; + } + + if (this._badgeConfig?.type === "state-label") { + this._badgeConfig = { ...this._badgeConfig, type: "entity" }; + } + + this.large = false; + if (this._badgeConfig && !Object.isFrozen(this._badgeConfig)) { + this._badgeConfig = deepFreeze(this._badgeConfig); + } + } + + public closeDialog(): boolean { + this._isEscapeEnabled = true; + window.removeEventListener("dialog-closed", this._enableEscapeKeyClose); + window.removeEventListener("hass-more-info", this._disableEscapeKeyClose); + if (this._dirty) { + this._confirmCancel(); + return false; + } + this._params = undefined; + this._badgeConfig = undefined; + this._error = undefined; + this._documentationURL = undefined; + this._dirty = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + protected updated(changedProps: PropertyValues): void { + if ( + !this._badgeConfig || + this._documentationURL !== undefined || + !changedProps.has("_badgeConfig") + ) { + return; + } + + const oldConfig = changedProps.get("_badgeConfig") as LovelaceBadgeConfig; + + if (oldConfig?.type !== this._badgeConfig!.type) { + this._documentationURL = this._badgeConfig!.type + ? getBadgeDocumentationURL(this.hass, this._badgeConfig!.type) + : undefined; + } + } + + private _enableEscapeKeyClose = (ev: any) => { + if (ev.detail.dialog === "ha-more-info-dialog") { + this._isEscapeEnabled = true; + } + }; + + private _disableEscapeKeyClose = () => { + this._isEscapeEnabled = false; + }; + + protected render() { + if (!this._params) { + return nothing; + } + + let heading: string; + if (this._badgeConfig && this._badgeConfig.type) { + let badgeName: string | undefined; + if (isCustomType(this._badgeConfig.type)) { + // prettier-ignore + badgeName = getCustomBadgeEntry( + stripCustomPrefix(this._badgeConfig.type) + )?.name; + // Trim names that end in " Card" so as not to redundantly duplicate it + if (badgeName?.toLowerCase().endsWith(" badge")) { + badgeName = badgeName.substring(0, badgeName.length - 6); + } + } else { + badgeName = this.hass!.localize( + `ui.panel.lovelace.editor.badge.${this._badgeConfig.type}.name` + ); + } + heading = this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.typed_header", + { type: badgeName } + ); + } else if (!this._badgeConfig) { + heading = this._containerConfig.title + ? this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.pick_badge_view_title", + { name: this._containerConfig.title } + ) + : this.hass!.localize("ui.panel.lovelace.editor.edit_badge.pick_badge"); + } else { + heading = this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.header" + ); + } + + return html` + + + + ${heading} + ${this._documentationURL !== undefined + ? html` + + + + ` + : nothing} + +
+
+ +
+
+ + ${this._error + ? html` + + ` + : ``} +
+
+ ${this._badgeConfig !== undefined + ? html` + + ${this.hass!.localize( + !this._badgeEditorEl || this._GUImode + ? "ui.panel.lovelace.editor.edit_badge.show_code_editor" + : "ui.panel.lovelace.editor.edit_badge.show_visual_editor" + )} + + ` + : ""} +
+ + ${this.hass!.localize("ui.common.cancel")} + + ${this._badgeConfig !== undefined && this._dirty + ? html` + + ${this._saving + ? html` + + ` + : this.hass!.localize("ui.common.save")} + + ` + : ``} +
+
+ `; + } + + private _enlarge() { + this.large = !this.large; + } + + private _ignoreKeydown(ev: KeyboardEvent) { + ev.stopPropagation(); + } + + private _handleConfigChanged(ev: HASSDomEvent) { + this._badgeConfig = deepFreeze(ev.detail.config); + this._error = ev.detail.error; + this._guiModeAvailable = ev.detail.guiModeAvailable; + this._dirty = true; + } + + private _handleGUIModeChanged(ev: HASSDomEvent): void { + ev.stopPropagation(); + this._GUImode = ev.detail.guiMode; + this._guiModeAvailable = ev.detail.guiModeAvailable; + } + + private _toggleMode(): void { + this._badgeEditorEl?.toggleMode(); + } + + private _opened() { + window.addEventListener("dialog-closed", this._enableEscapeKeyClose); + window.addEventListener("hass-more-info", this._disableEscapeKeyClose); + this._badgeEditorEl?.focusYamlEditor(); + } + + private get _canSave(): boolean { + if (this._saving) { + return false; + } + if (this._badgeConfig === undefined) { + return false; + } + if (this._badgeEditorEl && this._badgeEditorEl.hasError) { + return false; + } + return true; + } + + private async _confirmCancel() { + // Make sure the open state of this dialog is handled before the open state of confirm dialog + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + const confirm = await showConfirmationDialog(this, { + title: this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.unsaved_changes" + ), + text: this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.confirm_cancel" + ), + dismissText: this.hass!.localize("ui.common.stay"), + confirmText: this.hass!.localize("ui.common.leave"), + }); + if (confirm) { + this._cancel(); + } + } + + private _cancel(ev?: Event) { + if (ev) { + ev.stopPropagation(); + } + this._dirty = false; + this.closeDialog(); + } + + private async _save(): Promise { + if (!this._canSave) { + return; + } + if (!this._dirty) { + this.closeDialog(); + return; + } + this._saving = true; + const path = this._params!.path; + await this._params!.saveConfig( + "badgeConfig" in this._params! + ? addBadge(this._params!.lovelaceConfig, path, this._badgeConfig!) + : replaceBadge( + this._params!.lovelaceConfig, + [...path, this._params!.badgeIndex], + this._badgeConfig! + ) + ); + this._saving = false; + this._dirty = false; + showSaveSuccessToast(this, this.hass); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + :host { + --code-mirror-max-height: calc(100vh - 176px); + } + + ha-dialog { + --mdc-dialog-max-width: 100px; + --dialog-z-index: 6; + --dialog-surface-position: fixed; + --dialog-surface-top: 40px; + --mdc-dialog-max-width: 90vw; + --dialog-content-padding: 24px 12px; + } + + .content { + width: calc(90vw - 48px); + max-width: 1000px; + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-dialog { + height: 100%; + --mdc-dialog-max-height: 100%; + --dialog-surface-top: 0px; + --mdc-dialog-max-width: 100vw; + } + .content { + width: 100%; + max-width: 100%; + } + } + + @media all and (min-width: 451px) and (min-height: 501px) { + :host([large]) .content { + max-width: none; + } + } + + .center { + margin-left: auto; + margin-right: auto; + } + + .content { + display: flex; + flex-direction: column; + } + + .content .element-editor { + margin: 0 10px; + } + + @media (min-width: 1000px) { + .content { + flex-direction: row; + } + .content > * { + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + } + } + .hidden { + display: none; + } + .element-editor { + margin-bottom: 8px; + } + .blur { + filter: blur(2px) grayscale(100%); + } + .element-preview { + position: relative; + height: max-content; + background: var(--primary-background-color); + padding: 10px; + border-radius: 4px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .element-preview ha-circular-progress { + top: 50%; + left: 50%; + position: absolute; + z-index: 10; + } + .gui-mode-button { + margin-right: auto; + margin-inline-end: auto; + margin-inline-start: initial; + } + .header { + display: flex; + align-items: center; + justify-content: space-between; + } + ha-dialog-header a { + color: inherit; + text-decoration: none; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-dialog-edit-badge": HuiDialogEditBadge; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-dialog-suggest-badge.ts b/src/panels/lovelace/editor/badge-editor/hui-dialog-suggest-badge.ts new file mode 100644 index 0000000000..c021950222 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-suggest-badge.ts @@ -0,0 +1,204 @@ +import deepFreeze from "deep-freeze"; +import { css, CSSResultGroup, 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 type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; +import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; +import "../../badges/hui-badge"; +import { addBadges } from "../config-util"; +import { + LovelaceContainerPath, + parseLovelaceContainerPath, +} from "../lovelace-path"; +import { 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 _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; + if (!Object.isFrozen(this._badgeConfig)) { + this._badgeConfig = deepFreeze(this._badgeConfig); + } + if (this._yamlEditor) { + this._yamlEditor.setValue(this._badgeConfig); + } + } + + public closeDialog(): void { + this._params = undefined; + this._badgeConfig = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _renderPreview() { + if (this._badgeConfig) { + return html` +
+ ${this._badgeConfig.map( + (badgeConfig) => html` + + ` + )} +
+ `; + } + return nothing; + } + + protected render() { + if (!this._params) { + return nothing; + } + return html` + +
+ ${this._renderPreview()} + ${this._params.yaml && this._badgeConfig + ? html` +
+ +
+ ` + : nothing} +
+ + ${this._params.yaml + ? this.hass!.localize("ui.common.close") + : this.hass!.localize("ui.common.cancel")} + + ${!this._params.yaml + ? html` + + ${this._saving + ? html` + + ` + : this.hass!.localize( + "ui.panel.lovelace.editor.suggest_badge.add" + )} + + ` + : nothing} +
+ `; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-dialog { + max-height: 100%; + height: 100%; + } + } + @media all and (min-width: 850px) { + ha-dialog { + width: 845px; + } + } + ha-dialog { + max-width: 845px; + --dialog-z-index: 6; + } + .hidden { + display: none; + } + .element-preview { + position: relative; + display: flex; + align-items: flex-start; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + 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 { + 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; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/show-create-badge-dialog.ts b/src/panels/lovelace/editor/badge-editor/show-create-badge-dialog.ts new file mode 100644 index 0000000000..ab2313d10b --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/show-create-badge-dialog.ts @@ -0,0 +1,25 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { LovelaceContainerPath } from "../lovelace-path"; + +export interface CreateBadgeDialogParams { + lovelaceConfig: LovelaceConfig; + saveConfig: (config: LovelaceConfig) => void; + path: LovelaceContainerPath; + suggestedBadges?: string[]; + entities?: string[]; // We can pass entity id's that will be added to the config when a badge is picked +} + +export const importCreateBadgeDialog = () => + import("./hui-dialog-create-badge"); + +export const showCreateBadgeDialog = ( + element: HTMLElement, + createBadgeDialogParams: CreateBadgeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-create-badge", + dialogImport: importCreateBadgeDialog, + dialogParams: createBadgeDialogParams, + }); +}; diff --git a/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts b/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts new file mode 100644 index 0000000000..e7640e9887 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts @@ -0,0 +1,30 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { LovelaceContainerPath } from "../lovelace-path"; + +export type EditBadgeDialogParams = { + lovelaceConfig: LovelaceConfig; + saveConfig: (config: LovelaceConfig) => void; + path: LovelaceContainerPath; +} & ( + | { + badgeIndex: number; + } + | { + badgeConfig: LovelaceBadgeConfig; + } +); + +export const importEditBadgeDialog = () => import("./hui-dialog-edit-badge"); + +export const showEditBadgeDialog = ( + element: HTMLElement, + editBadgeDialogParams: EditBadgeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-edit-badge", + dialogImport: importEditBadgeDialog, + dialogParams: editBadgeDialogParams, + }); +}; diff --git a/src/panels/lovelace/editor/badge-editor/show-suggest-badge-dialog.ts b/src/panels/lovelace/editor/badge-editor/show-suggest-badge-dialog.ts new file mode 100644 index 0000000000..06bb94e186 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/show-suggest-badge-dialog.ts @@ -0,0 +1,26 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { LovelaceContainerPath } from "../lovelace-path"; + +export interface SuggestBadgeDialogParams { + lovelaceConfig?: LovelaceConfig; + yaml?: boolean; + saveConfig?: (config: LovelaceConfig) => void; + path?: LovelaceContainerPath; + entities?: string[]; // We pass this to create dialog when user chooses "Pick own" + badgeConfig: LovelaceBadgeConfig[]; // We can pass a suggested config +} + +const importSuggestBadgeDialog = () => import("./hui-dialog-suggest-badge"); + +export const showSuggestBadgeDialog = ( + element: HTMLElement, + suggestBadgeDialogParams: SuggestBadgeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-suggest-badge", + dialogImport: importSuggestBadgeDialog, + dialogParams: suggestBadgeDialogParams, + }); +}; diff --git a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts index aa4806689a..d7a264aaea 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts @@ -9,18 +9,18 @@ import { HuiElementEditor } from "../hui-element-editor"; import "./hui-card-layout-editor"; import "./hui-card-visibility-editor"; -type Tab = "config" | "visibility" | "layout"; +const tabs = ["config", "visibility", "layout"] as const; @customElement("hui-card-element-editor") export class HuiCardElementEditor extends HuiElementEditor { - @state() private _curTab: Tab = "config"; - @property({ type: Boolean, attribute: "show-visibility-tab" }) public showVisibilityTab = false; @property({ type: Boolean, attribute: "show-layout-tab" }) public showLayoutTab = false; + @state() private _currTab: (typeof tabs)[number] = tabs[0]; + protected async getConfigElement(): Promise { const elClass = await getCardElementClass(this.configElementType!); @@ -43,20 +43,13 @@ export class HuiCardElementEditor extends HuiElementEditor { return undefined; } - private _handleTabSelected(ev: CustomEvent): void { - if (!ev.detail.value) { - return; - } - this._curTab = ev.detail.value.id; - } - private _configChanged(ev: CustomEvent): void { ev.stopPropagation(); this.value = ev.detail.value; } protected renderConfigElement(): TemplateResult { - const displayedTabs: Tab[] = ["config"]; + const displayedTabs: string[] = ["config"]; if (this.showVisibilityTab) displayedTabs.push("visibility"); if (this.showLayoutTab) displayedTabs.push("layout"); @@ -64,7 +57,7 @@ export class HuiCardElementEditor extends HuiElementEditor { let content: TemplateResult<1> | typeof nothing = nothing; - switch (this._curTab) { + switch (this._currTab) { case "config": content = html`${super.renderConfigElement()}`; break; @@ -88,33 +81,38 @@ export class HuiCardElementEditor extends HuiElementEditor { `; } return html` - ${displayedTabs.map( - (tab, index) => html` - - ${this.hass.localize( + (tab) => html` + + > + ` )} - + ${content} `; } + private _handleTabChanged(ev: CustomEvent): void { + const newTab = tabs[ev.detail.index]; + if (newTab === this._currTab) { + return; + } + this._currTab = newTab; + } + static get styles(): CSSResultGroup { return [ HuiElementEditor.styles, css` - paper-tabs { - --paper-tabs-selection-bar-color: var(--primary-color); - color: var(--primary-text-color); + mwc-tab-bar { text-transform: uppercase; margin-bottom: 16px; border-bottom: 1px solid var(--divider-color); diff --git a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts index 3537955479..c8686a4dfe 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts @@ -52,7 +52,7 @@ export class HuiCardPicker extends LitElement { @property({ attribute: false }) public suggestedCards?: string[]; @storage({ - key: "lovelaceClipboard", + key: "dashboardCardClipboard", state: true, subscribe: true, storage: "sessionStorage", @@ -490,7 +490,7 @@ export class HuiCardPicker extends LitElement { .cards-container { display: grid; grid-gap: 8px 8px; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-top: 20px; } @@ -560,6 +560,7 @@ export class HuiCardPicker extends LitElement { .manual { max-width: none; + grid-column: 1 / -1; } .icon { diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts index eb9294f9e5..9b3c3aa9d1 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts @@ -30,15 +30,15 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import { haStyleDialog } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; +import "../../cards/hui-card"; import "../../sections/hui-section"; import { addCard, replaceCard } from "../config-util"; -import { getCardDocumentationURL } from "../get-card-documentation-url"; +import { getCardDocumentationURL } from "../get-dashboard-documentation-url"; import type { ConfigChangedEvent } from "../hui-element-editor"; import { findLovelaceContainer } from "../lovelace-path"; import type { GUIModeChangedEvent } from "../types"; import "./hui-card-element-editor"; import type { HuiCardElementEditor } from "./hui-card-element-editor"; -import "../../cards/hui-card"; import type { EditCardDialogParams } from "./show-edit-card-dialog"; declare global { diff --git a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts index 51f42252c1..8caff95723 100644 --- a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts +++ b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts @@ -64,7 +64,8 @@ export class HuiEntityPickerTable extends LitElement { title: this.hass!.localize("ui.panel.lovelace.unused_entities.entity"), sortable: true, filterable: true, - grows: true, + flex: 2, + main: true, direction: "asc", template: (entity: any) => html`
@@ -81,7 +82,6 @@ export class HuiEntityPickerTable extends LitElement { title: this.hass!.localize("ui.panel.lovelace.unused_entities.entity_id"), sortable: true, filterable: true, - width: "30%", hidden: narrow, }; @@ -89,7 +89,6 @@ export class HuiEntityPickerTable extends LitElement { title: this.hass!.localize("ui.panel.lovelace.unused_entities.domain"), sortable: true, filterable: true, - width: "15%", hidden: narrow, }; @@ -99,7 +98,6 @@ export class HuiEntityPickerTable extends LitElement { ), type: "numeric", sortable: true, - width: "15%", hidden: narrow, template: (entity) => html` + + `; + } + + return html` + + + + + `; + } + + private _formChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _conditionChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!this._config) { + return; + } + const conditions = ev.detail.value; + this._config = { ...this._config, conditions }; + fireEvent(this, "config-changed", { config: this._config }); + } + + private _elementsChanged(ev: CustomEvent): void { + ev.stopPropagation(); + + const config = { + ...this._config, + elements: ev.detail.elements as LovelaceElementConfig[], + } as LovelaceCardConfig; + + fireEvent(this, "config-changed", { config }); + } + + private _handleSubElementChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const configValue = this._subElementEditorConfig?.type; + const value = ev.detail.config; + + if (configValue === "element") { + const newConfigElements = this._config.elements!.concat(); + if (!value) { + newConfigElements.splice(this._subElementEditorConfig!.index!, 1); + this._goBack(); + } else { + newConfigElements[this._subElementEditorConfig!.index!] = value; + } + + this._config = { ...this._config!, elements: newConfigElements }; + } + + this._subElementEditorConfig = { + ...this._subElementEditorConfig!, + elementConfig: value, + }; + + fireEvent(this, "config-changed", { config: this._config }); + } + + private _editDetailElement(ev: HASSDomEvent): void { + this._subElementEditorConfig = ev.detail.subElementConfig; + } + + private _goBack(ev?): void { + ev?.stopPropagation(); + this._subElementEditorConfig = undefined; + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-conditional-element-editor": HuiConditionalElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-icon-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-icon-element-editor.ts new file mode 100644 index 0000000000..bbf89f7477 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-icon-element-editor.ts @@ -0,0 +1,88 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { any, assert, literal, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-form/ha-form"; +import { LovelacePictureElementEditor } from "../../../types"; +import { IconElementConfig } from "../../../elements/types"; +import { actionConfigStruct } from "../../structs/action-struct"; + +const iconElementConfigStruct = object({ + type: literal("icon"), + entity: optional(string()), + icon: optional(string()), + style: optional(any()), + title: optional(string()), + tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), +}); + +const SCHEMA = [ + { name: "icon", selector: { icon: {} } }, + { name: "title", selector: { text: {} } }, + { name: "entity", selector: { entity: {} } }, + { + name: "tap_action", + selector: { + ui_action: {}, + }, + }, + { + name: "hold_action", + selector: { + ui_action: {}, + }, + }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-icon-element-editor") +export class HuiIconElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: IconElementConfig; + + public setConfig(config: IconElementConfig): void { + assert(config, iconElementConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-icon-element-editor": HuiIconElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-image-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-image-element-editor.ts new file mode 100644 index 0000000000..06c9ee68d6 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-image-element-editor.ts @@ -0,0 +1,103 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { any, assert, literal, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-form/ha-form"; +import { LovelacePictureElementEditor } from "../../../types"; +import { ImageElementConfig } from "../../../elements/types"; +import { actionConfigStruct } from "../../structs/action-struct"; + +const imageElementConfigStruct = object({ + type: literal("image"), + entity: optional(string()), + image: optional(string()), + style: optional(any()), + title: optional(string()), + tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), + camera_image: optional(string()), + camera_view: optional(string()), + state_image: optional(any()), + filter: optional(string()), + state_filter: optional(any()), + aspect_ratio: optional(string()), +}); + +const SCHEMA = [ + { name: "entity", selector: { entity: {} } }, + { name: "title", selector: { text: {} } }, + { + name: "tap_action", + selector: { + ui_action: {}, + }, + }, + { + name: "hold_action", + selector: { + ui_action: {}, + }, + }, + { name: "image", selector: { text: {} } }, + { name: "camera_image", selector: { entity: { domain: "camera" } } }, + { + name: "camera_view", + selector: { select: { options: ["auto", "live"] } }, + }, + { name: "state_image", selector: { object: {} } }, + { name: "filter", selector: { text: {} } }, + { name: "state_filter", selector: { object: {} } }, + { name: "aspect_ratio", selector: { text: {} } }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-image-element-editor") +export class HuiImageElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ImageElementConfig; + + public setConfig(config: ImageElementConfig): void { + assert(config, imageElementConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-image-element-editor": HuiImageElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-service-button-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-service-button-element-editor.ts new file mode 100644 index 0000000000..ae3039d327 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-service-button-element-editor.ts @@ -0,0 +1,124 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { any, assert, enums, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import "../../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import "../../../../../components/ha-service-control"; +import { ServiceAction } from "../../../../../data/script"; +import type { HomeAssistant } from "../../../../../types"; +import { ServiceButtonElementConfig } from "../../../elements/types"; +import { LovelacePictureElementEditor } from "../../../types"; + +const serviceButtonElementConfigStruct = object({ + type: enums(["service-button", "action-button"]), + style: optional(any()), + title: optional(string()), + action: optional(string()), + service: optional(string()), + service_data: optional(any()), + data: optional(any()), + target: optional(any()), +}); + +const SCHEMA = [ + { name: "title", required: true, selector: { text: {} } }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-service-button-element-editor") +export class HuiServiceButtonElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ServiceButtonElementConfig; + + public setConfig(config: ServiceButtonElementConfig): void { + assert(config, serviceButtonElementConfigStruct); + this._config = config; + } + + private _serviceData = memoizeOne( + (config: ServiceButtonElementConfig): ServiceAction => ({ + action: config?.action ?? config?.service, + data: config?.data ?? config?.service_data, + target: config?.target, + }) + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { + config: { ...this._config, ...ev.detail.value }, + }); + } + + private _serviceDataChanged(ev: CustomEvent<{ value: ServiceAction }>): void { + const config: ServiceButtonElementConfig = { + ...this._config!, + action: ev.detail.value.action, + data: ev.detail.value.data, + target: ev.detail.value.target, + }; + + if ("service" in config) { + delete config.service; + } + + if ("service_data" in config) { + delete config.service_data; + } + + fireEvent(this, "config-changed", { + config, + }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; + + static get styles() { + return css` + ha-service-control { + display: block; + margin-top: 16px; + --service-control-padding: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-service-button-element-editor": HuiServiceButtonElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-state-badge-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-state-badge-element-editor.ts new file mode 100644 index 0000000000..f5cf03d0fb --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-state-badge-element-editor.ts @@ -0,0 +1,86 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { any, assert, literal, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-form/ha-form"; +import { LovelacePictureElementEditor } from "../../../types"; +import { StateBadgeElementConfig } from "../../../elements/types"; +import { actionConfigStruct } from "../../structs/action-struct"; + +const stateBadgeElementConfigStruct = object({ + type: literal("state-badge"), + entity: optional(string()), + style: optional(any()), + title: optional(string()), + tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), +}); + +const SCHEMA = [ + { name: "entity", required: true, selector: { entity: {} } }, + { name: "title", selector: { text: {} } }, + { + name: "tap_action", + selector: { + ui_action: {}, + }, + }, + { + name: "hold_action", + selector: { + ui_action: {}, + }, + }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-state-badge-element-editor") +export class HuiStateBadgeElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StateBadgeElementConfig; + + public setConfig(config: StateBadgeElementConfig): void { + assert(config, stateBadgeElementConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-state-badge-element-editor": HuiStateBadgeElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-state-icon-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-state-icon-element-editor.ts new file mode 100644 index 0000000000..c5ebb70a19 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-state-icon-element-editor.ts @@ -0,0 +1,98 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { + any, + assert, + boolean, + literal, + object, + optional, + string, +} from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-form/ha-form"; +import { LovelacePictureElementEditor } from "../../../types"; +import { StateIconElementConfig } from "../../../elements/types"; +import { actionConfigStruct } from "../../structs/action-struct"; + +const stateIconElementConfigStruct = object({ + type: literal("state-icon"), + entity: optional(string()), + icon: optional(string()), + state_color: optional(boolean()), + style: optional(any()), + title: optional(string()), + tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), +}); + +const SCHEMA = [ + { name: "entity", required: true, selector: { entity: {} } }, + { name: "icon", selector: { icon: {} } }, + { name: "title", selector: { text: {} } }, + { name: "state_color", default: true, selector: { boolean: {} } }, + { + name: "tap_action", + selector: { + ui_action: {}, + }, + }, + { + name: "hold_action", + selector: { + ui_action: {}, + }, + }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-state-icon-element-editor") +export class HuiStateIconElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StateIconElementConfig; + + public setConfig(config: StateIconElementConfig): void { + assert(config, stateIconElementConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-state-icon-element-editor": HuiStateIconElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/elements/hui-state-label-element-editor.ts b/src/panels/lovelace/editor/config-elements/elements/hui-state-label-element-editor.ts new file mode 100644 index 0000000000..af2043801d --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/elements/hui-state-label-element-editor.ts @@ -0,0 +1,98 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { any, assert, literal, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-form/ha-form"; +import { LovelacePictureElementEditor } from "../../../types"; +import { StateLabelElementConfig } from "../../../elements/types"; +import { actionConfigStruct } from "../../structs/action-struct"; + +const stateLabelElementConfigStruct = object({ + type: literal("state-label"), + entity: optional(string()), + attribute: optional(string()), + prefix: optional(string()), + suffix: optional(string()), + style: optional(any()), + title: optional(string()), + tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), +}); + +const SCHEMA = [ + { name: "entity", required: true, selector: { entity: {} } }, + { + name: "attribute", + selector: { attribute: {} }, + context: { + filter_entity: "entity", + }, + }, + { name: "prefix", selector: { text: {} } }, + { name: "suffix", selector: { text: {} } }, + { name: "title", selector: { text: {} } }, + { + name: "tap_action", + selector: { + ui_action: {}, + }, + }, + { + name: "hold_action", + selector: { + ui_action: {}, + }, + }, + { name: "style", selector: { object: {} } }, +] as const; + +@customElement("hui-state-label-element-editor") +export class HuiStateLabelElementEditor + extends LitElement + implements LovelacePictureElementEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StateLabelElementConfig; + + public setConfig(config: StateLabelElementConfig): void { + assert(config, stateLabelElementConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = (schema: SchemaUnion) => + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + this.hass!.localize(`ui.panel.lovelace.editor.elements.${schema.name}`) || + schema.name; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-state-label-element-editor": HuiStateLabelElementEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts index b217bdc577..dbc5d2fb26 100644 --- a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts @@ -45,7 +45,7 @@ export class HuiConditionalCardEditor @property({ attribute: false }) public lovelace?: LovelaceConfig; @storage({ - key: "lovelaceClipboard", + key: "dashboardCardClipboard", state: false, subscribe: false, storage: "sessionStorage", diff --git a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts index 106b0b97ec..7c6f14a1fd 100644 --- a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts @@ -67,9 +67,10 @@ const castEntitiesRowConfigStruct = object({ }); const callServiceEntitiesRowConfigStruct = object({ - type: literal("call-service"), + type: enums(["call-service", "perform-action"]), name: string(), - service: string(), + service: optional(string()), + action: optional(string()), icon: optional(string()), action_name: optional(string()), // "service_data" is kept for backwards compatibility. Replaced by "data". @@ -149,6 +150,7 @@ const entitiesRowConfigStruct = dynamic((value) => { case "buttons": { return buttonsEntitiesRowConfigStruct; } + case "perform-action": case "call-service": { return callServiceEntitiesRowConfigStruct; } diff --git a/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts new file mode 100644 index 0000000000..d1000fbb5d --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts @@ -0,0 +1,238 @@ +import { mdiGestureTap, mdiPalette } from "@mdi/js"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { + array, + assert, + assign, + boolean, + enums, + object, + optional, + string, + union, +} from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { + DEFAULT_DISPLAY_TYPE, + DISPLAY_TYPES, +} from "../../badges/hui-entity-badge"; +import { EntityBadgeConfig } from "../../badges/types"; +import type { LovelaceBadgeEditor } from "../../types"; +import "../hui-sub-element-editor"; +import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct"; +import { configElementStyle } from "./config-elements-style"; +import "./hui-card-features-editor"; + +const badgeConfigStruct = assign( + baseLovelaceBadgeConfig, + object({ + entity: optional(string()), + display_type: optional(enums(DISPLAY_TYPES)), + name: optional(string()), + icon: optional(string()), + state_content: optional(union([string(), array(string())])), + color: optional(string()), + show_entity_picture: optional(boolean()), + tap_action: optional(actionConfigStruct), + show_name: optional(boolean()), + image: optional(string()), + }) +); + +@customElement("hui-entity-badge-editor") +export class HuiEntityBadgeEditor + extends LitElement + implements LovelaceBadgeEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EntityBadgeConfig; + + public setConfig(config: EntityBadgeConfig): void { + assert(config, badgeConfigStruct); + this._config = config; + } + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { name: "entity", selector: { entity: {} } }, + { + name: "", + type: "expandable", + iconPath: mdiPalette, + title: localize(`ui.panel.lovelace.editor.badge.entity.appearance`), + schema: [ + { + name: "display_type", + selector: { + select: { + mode: "dropdown", + options: DISPLAY_TYPES.map((type) => ({ + value: type, + label: localize( + `ui.panel.lovelace.editor.badge.entity.display_type_options.${type}` + ), + })), + }, + }, + }, + { + name: "", + type: "grid", + schema: [ + { + name: "name", + selector: { + text: {}, + }, + }, + { + name: "icon", + selector: { + icon: {}, + }, + context: { icon_entity: "entity" }, + }, + { + name: "color", + selector: { + ui_color: { default_color: true }, + }, + }, + { + name: "show_entity_picture", + selector: { + boolean: {}, + }, + }, + ], + }, + + { + name: "state_content", + selector: { + ui_state_content: {}, + }, + context: { + filter_entity: "entity", + }, + }, + ], + }, + { + name: "", + type: "expandable", + title: localize(`ui.panel.lovelace.editor.badge.entity.interactions`), + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + ], + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const schema = this._schema(this.hass!.localize); + + const data = { ...this._config }; + + if (!data.display_type) { + data.display_type = DEFAULT_DISPLAY_TYPE; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const newConfig = ev.detail.value as EntityBadgeConfig; + + const config: EntityBadgeConfig = { + ...newConfig, + }; + + if (!config.state_content) { + delete config.state_content; + } + + if (config.display_type === "standard") { + delete config.display_type; + } + + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "color": + case "state_content": + case "display_type": + case "show_entity_picture": + return this.hass!.localize( + `ui.panel.lovelace.editor.badge.entity.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; + + static get styles() { + return [ + configElementStyle, + css` + .container { + display: flex; + flex-direction: column; + } + ha-form { + display: block; + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entity-badge-editor": HuiEntityBadgeEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts index 20ff12e88b..6bd9d93cba 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts @@ -25,7 +25,10 @@ const cardConfigStruct = assign( const SCHEMA = [ { name: "image", selector: { image: {} } }, - { name: "image_entity", selector: { entity: { domain: "image" } } }, + { + name: "image_entity", + selector: { entity: { domain: ["image", "person"] } }, + }, { name: "alt_text", selector: { text: {} } }, { name: "theme", selector: { theme: {} } }, { diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-elements-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-elements-card-editor.ts new file mode 100644 index 0000000000..329a19ab8e --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-picture-elements-card-editor.ts @@ -0,0 +1,214 @@ +import { CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { + any, + array, + assert, + assign, + object, + optional, + string, + type, +} from "superstruct"; +import memoizeOne from "memoize-one"; +import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-card"; +import "../../../../components/ha-form/ha-form"; +import "../../../../components/ha-icon"; +import "../../../../components/ha-switch"; +import type { HomeAssistant } from "../../../../types"; +import type { PictureElementsCardConfig } from "../../cards/types"; +import type { LovelaceCardEditor } from "../../types"; +import "../hui-sub-element-editor"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { EditSubElementEvent, SubElementEditorConfig } from "../types"; +import { configElementStyle } from "./config-elements-style"; +import "../hui-picture-elements-card-row-editor"; +import { LovelaceElementConfig } from "../../elements/types"; +import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; +import { LocalizeFunc } from "../../../../common/translations/localize"; + +const genericElementConfigStruct = type({ + type: string(), +}); + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + image: optional(string()), + camera_image: optional(string()), + camera_view: optional(string()), + elements: array(genericElementConfigStruct), + title: optional(string()), + state_filter: optional(any()), + theme: optional(string()), + dark_mode_image: optional(string()), + dark_mode_filter: optional(any()), + }) +); + +@customElement("hui-picture-elements-card-editor") +export class HuiPictureElementsCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: PictureElementsCardConfig; + + @state() private _subElementEditorConfig?: SubElementEditorConfig; + + public setConfig(config: PictureElementsCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + } + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "", + type: "expandable", + title: localize( + "ui.panel.lovelace.editor.card.picture-elements.card_options" + ), + schema: [ + { name: "title", selector: { text: {} } }, + { name: "image", selector: { text: {} } }, + { name: "dark_mode_image", selector: { text: {} } }, + { + name: "camera_image", + selector: { entity: { domain: "camera" } }, + }, + { + name: "camera_view", + selector: { select: { options: ["auto", "live"] } }, + }, + { name: "theme", selector: { theme: {} } }, + { name: "state_filter", selector: { object: {} } }, + { name: "dark_mode_filter", selector: { object: {} } }, + ], + }, + ] as const + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + if (this._subElementEditorConfig) { + return html` + + + `; + } + + return html` + + + `; + } + + private _formChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _elementsChanged(ev: CustomEvent): void { + ev.stopPropagation(); + + const config = { + ...this._config, + elements: ev.detail.elements as LovelaceElementConfig[], + } as LovelaceCardConfig; + + fireEvent(this, "config-changed", { config }); + } + + private _handleSubElementChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const configValue = this._subElementEditorConfig?.type; + const value = ev.detail.config; + + if (configValue === "element") { + const newConfigElements = this._config.elements!.concat(); + if (!value) { + newConfigElements.splice(this._subElementEditorConfig!.index!, 1); + this._goBack(); + } else { + newConfigElements[this._subElementEditorConfig!.index!] = value; + } + + this._config = { ...this._config!, elements: newConfigElements }; + } + + this._subElementEditorConfig = { + ...this._subElementEditorConfig!, + elementConfig: value, + }; + + fireEvent(this, "config-changed", { config: this._config }); + } + + private _editDetailElement(ev: HASSDomEvent): void { + this._subElementEditorConfig = ev.detail.subElementConfig; + } + + private _goBack(): void { + this._subElementEditorConfig = undefined; + } + + private _computeLabelCallback = (schema) => { + switch (schema.name) { + case "dark_mode_image": + case "state_filter": + case "dark_mode_filter": + return ( + this.hass!.localize( + `ui.panel.lovelace.editor.card.picture-elements.${schema.name}` + ) || schema.name + ); + default: + return ( + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || schema.name + ); + } + }; + + static get styles(): CSSResultGroup { + return [configElementStyle]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-picture-elements-card-editor": HuiPictureElementsCardEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts index 1c74428d38..8ed4b388db 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts @@ -36,7 +36,10 @@ const cardConfigStruct = assign( const SCHEMA = [ { name: "title", selector: { text: {} } }, { name: "image", selector: { image: {} } }, - { name: "image_entity", selector: { entity: { domain: "image" } } }, + { + name: "image_entity", + selector: { entity: { domain: ["image", "person"] } }, + }, { name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "", diff --git a/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts index c5e81164e1..896ea21296 100644 --- a/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts @@ -67,7 +67,7 @@ export class HuiStackCardEditor @property({ attribute: false }) public lovelace?: LovelaceConfig; @storage({ - key: "lovelaceClipboard", + key: "dashboardCardClipboard", state: false, subscribe: false, storage: "sessionStorage", diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index 491bec6e31..587bb85fe4 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -1,5 +1,4 @@ import { mdiGestureTap, mdiPalette } from "@mdi/js"; -import { HassEntity } from "home-assistant-js-websocket"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; @@ -14,9 +13,7 @@ import { string, union, } from "superstruct"; -import { ensureArray } from "../../../../common/array/ensure-array"; import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event"; -import { formatEntityAttributeNameFunc } from "../../../../common/translations/entity-state"; import { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-form/ha-form"; import type { @@ -38,59 +35,6 @@ import { EditSubElementEvent, SubElementEditorConfig } from "../types"; import { configElementStyle } from "./config-elements-style"; import "./hui-card-features-editor"; -const HIDDEN_ATTRIBUTES = [ - "access_token", - "available_modes", - "code_arm_required", - "code_format", - "color_modes", - "device_class", - "editable", - "effect_list", - "entity_id", - "entity_picture", - "event_types", - "fan_modes", - "fan_speed_list", - "friendly_name", - "frontend_stream_type", - "has_date", - "has_time", - "hvac_modes", - "icon", - "id", - "max_color_temp_kelvin", - "max_mireds", - "max_temp", - "max", - "min_color_temp_kelvin", - "min_mireds", - "min_temp", - "min", - "mode", - "operation_list", - "options", - "percentage_step", - "precipitation_unit", - "preset_modes", - "pressure_unit", - "sound_mode_list", - "source_list", - "state_class", - "step", - "supported_color_modes", - "supported_features", - "swing_modes", - "target_temp_step", - "temperature_unit", - "token", - "unit_of_measurement", - "visibility_unit", - "wind_speed_unit", - "battery_icon", - "battery_level", -]; - const cardConfigStruct = assign( baseLovelaceCardConfig, object({ @@ -127,9 +71,7 @@ export class HuiTileCardEditor private _schema = memoizeOne( ( localize: LocalizeFunc, - formatEntityAttributeName: formatEntityAttributeNameFunc, entityId: string | undefined, - stateObj: HassEntity | undefined, hideState: boolean ) => [ @@ -183,41 +125,10 @@ export class HuiTileCardEditor { name: "state_content", selector: { - select: { - mode: "dropdown", - reorder: true, - custom_value: true, - multiple: true, - options: [ - { - label: localize( - `ui.panel.lovelace.editor.card.tile.state_content_options.state` - ), - value: "state", - }, - { - label: localize( - `ui.panel.lovelace.editor.card.tile.state_content_options.last-changed` - ), - value: "last-changed", - }, - { - label: localize( - `ui.panel.lovelace.editor.card.tile.state_content_options.last-updated` - ), - value: "last-updated", - }, - ...Object.keys(stateObj?.attributes ?? {}) - .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) - .map((attribute) => ({ - value: attribute, - label: formatEntityAttributeName( - stateObj!, - attribute - ), - })), - ], - }, + ui_state_content: {}, + }, + context: { + filter_entity: "entity", }, }, ] as const satisfies readonly HaFormSchema[]) @@ -227,7 +138,7 @@ export class HuiTileCardEditor { name: "", type: "expandable", - title: localize(`ui.panel.lovelace.editor.card.tile.actions`), + title: localize(`ui.panel.lovelace.editor.card.tile.interactions`), iconPath: mdiGestureTap, schema: [ { @@ -268,9 +179,7 @@ export class HuiTileCardEditor const schema = this._schema( this.hass!.localize, - this.hass.formatEntityAttributeName, this._config.entity, - stateObj, this._config.hide_state ?? false ); @@ -287,10 +196,7 @@ export class HuiTileCardEditor `; } - const data = { - ...this._config, - state_content: ensureArray(this._config.state_content), - }; + const data = this._config; return html` { - const cards = findLovelaceCards(config, path); + const cards = findLovelaceItems("cards", config, path); const newCards = cards ? [...cards, cardConfig] : [cardConfig]; - const newConfig = updateLovelaceCards(config, path, newCards); + const newConfig = updateLovelaceItems("cards", config, path, newCards); return newConfig; }; @@ -34,9 +38,9 @@ export const addCards = ( path: LovelaceContainerPath, cardConfigs: LovelaceCardConfig[] ): LovelaceConfig => { - const cards = findLovelaceCards(config, path); + const cards = findLovelaceItems("cards", config, path); const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs]; - const newConfig = updateLovelaceCards(config, path, newCards); + const newConfig = updateLovelaceItems("cards", config, path, newCards); return newConfig; }; @@ -48,13 +52,18 @@ export const replaceCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = (cards ?? []).map((origConf, ind) => ind === cardIndex ? cardConfig : origConf ); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -65,11 +74,16 @@ export const deleteCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = (cards ?? []).filter((_origConf, ind) => ind !== cardIndex); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -81,13 +95,18 @@ export const insertCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = cards ? [...cards.slice(0, cardIndex), cardConfig, ...cards.slice(cardIndex)] : [cardConfig]; - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -99,7 +118,7 @@ export const moveCardToIndex = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = cards ? [...cards] : []; @@ -110,7 +129,12 @@ export const moveCardToIndex = ( newCards.splice(oldIndex, 1); newCards.splice(newIndex, 0, card); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -132,7 +156,7 @@ export const moveCardToContainer = ( } const fromContainerPath = getLovelaceContainerPath(fromPath); - const cards = findLovelaceCards(config, fromContainerPath); + const cards = findLovelaceItems("cards", config, fromContainerPath); const card = cards![fromCardIndex]; let newConfig = addCard(config, toPath, card); @@ -148,7 +172,7 @@ export const moveCard = ( ): LovelaceConfig => { const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath); const fromContainerPath = getLovelaceContainerPath(fromPath); - const cards = findLovelaceCards(config, fromContainerPath); + const cards = findLovelaceItems("cards", config, fromContainerPath); const card = cards![fromCardIndex]; let newConfig = deleteCard(config, fromPath); @@ -298,3 +322,109 @@ export const moveSection = ( return newConfig; }; + +export const addBadge = ( + config: LovelaceConfig, + path: LovelaceContainerPath, + badgeConfig: LovelaceBadgeConfig +): LovelaceConfig => { + const badges = findLovelaceItems("badges", config, path); + const newBadges = badges ? [...badges, badgeConfig] : [badgeConfig]; + const newConfig = updateLovelaceItems("badges", config, path, newBadges); + return newConfig; +}; + +export const addBadges = ( + config: LovelaceConfig, + path: LovelaceContainerPath, + badgeConfig: LovelaceBadgeConfig[] +): LovelaceConfig => { + const badges = findLovelaceItems("badges", config, path); + const newBadges = badges ? [...badges, ...badgeConfig] : [...badgeConfig]; + const newConfig = updateLovelaceItems("badges", config, path, newBadges); + return newConfig; +}; + +export const replaceBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath, + cardConfig: LovelaceBadgeConfig +): LovelaceConfig => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = (badges ?? []).map((origConf, ind) => + ind === cardIndex ? cardConfig : origConf + ); + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const deleteBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath +): LovelaceConfig => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = (badges ?? []).filter( + (_origConf, ind) => ind !== cardIndex + ); + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const insertBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath, + badgeConfig: LovelaceBadgeConfig +) => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = badges + ? [...badges.slice(0, cardIndex), badgeConfig, ...badges.slice(cardIndex)] + : [badgeConfig]; + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const moveBadge = ( + config: LovelaceConfig, + fromPath: LovelaceCardPath, + toPath: LovelaceCardPath +): LovelaceConfig => { + const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath); + const fromContainerPath = getLovelaceContainerPath(fromPath); + const badges = findLovelaceItems("badges", config, fromContainerPath); + const badge = badges![fromCardIndex]; + + let newConfig = deleteBadge(config, fromPath); + newConfig = insertBadge(newConfig, toPath, ensureBadgeConfig(badge)); + + return newConfig; +}; diff --git a/src/panels/lovelace/editor/entity-row-editor/hui-row-element-editor.ts b/src/panels/lovelace/editor/entity-row-editor/hui-row-element-editor.ts index bdf7aef9cf..2471121ccd 100644 --- a/src/panels/lovelace/editor/entity-row-editor/hui-row-element-editor.ts +++ b/src/panels/lovelace/editor/entity-row-editor/hui-row-element-editor.ts @@ -13,6 +13,10 @@ export class HuiRowElementEditor extends HuiElementEditor { return GENERIC_ROW_TYPE; } + if (this.value?.type === "perform-action") { + return "call-service"; + } + return this.value?.type; } diff --git a/src/panels/lovelace/editor/get-badge-stub-config.ts b/src/panels/lovelace/editor/get-badge-stub-config.ts new file mode 100644 index 0000000000..63c64e57a8 --- /dev/null +++ b/src/panels/lovelace/editor/get-badge-stub-config.ts @@ -0,0 +1,26 @@ +import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; +import { HomeAssistant } from "../../../types"; +import { getBadgeElementClass } from "../create-element/create-badge-element"; + +export const getBadgeStubConfig = async ( + hass: HomeAssistant, + type: string, + entities: string[], + entitiesFallback: string[] +): Promise => { + let badgeConfig: LovelaceCardConfig = { type }; + + const elClass = await getBadgeElementClass(type); + + if (elClass && elClass.getStubConfig) { + const classStubConfig = await elClass.getStubConfig( + hass, + entities, + entitiesFallback + ); + + badgeConfig = { ...badgeConfig, ...classStubConfig }; + } + + return badgeConfig; +}; diff --git a/src/panels/lovelace/editor/get-card-documentation-url.ts b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts similarity index 55% rename from src/panels/lovelace/editor/get-card-documentation-url.ts rename to src/panels/lovelace/editor/get-dashboard-documentation-url.ts index e312463d4b..aada4e5978 100644 --- a/src/panels/lovelace/editor/get-card-documentation-url.ts +++ b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts @@ -1,4 +1,5 @@ import { + getCustomBadgeEntry, getCustomCardEntry, isCustomType, stripCustomPrefix, @@ -14,5 +15,16 @@ export const getCardDocumentationURL = ( return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL; } - return `${documentationUrl(hass, "/lovelace/")}${type}`; + return `${documentationUrl(hass, "/dashboards/")}${type}`; +}; + +export const getBadgeDocumentationURL = ( + hass: HomeAssistant, + type: string +): string | undefined => { + if (isCustomType(type)) { + return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL; + } + + return `${documentationUrl(hass, "/dashboards/badges")}`; }; diff --git a/src/panels/lovelace/editor/hui-badge-preview.ts b/src/panels/lovelace/editor/hui-badge-preview.ts deleted file mode 100644 index ac8125abd1..0000000000 --- a/src/panels/lovelace/editor/hui-badge-preview.ts +++ /dev/null @@ -1,91 +0,0 @@ -import "../../../components/entity/ha-state-label-badge"; -import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; -import { HomeAssistant } from "../../../types"; -import { createErrorBadgeConfig } from "../badges/hui-error-badge"; -import { createBadgeElement } from "../create-element/create-badge-element"; -import { LovelaceBadge } from "../types"; -import { ConfigError } from "./types"; - -export class HuiBadgePreview extends HTMLElement { - private _hass?: HomeAssistant; - - private _element?: LovelaceBadge; - - private _config?: LovelaceBadgeConfig; - - private get _error() { - return this._element?.tagName === "HUI-ERROR-CARD"; - } - - constructor() { - super(); - this.addEventListener("ll-rebuild", () => { - this._cleanup(); - if (this._config) { - this.config = this._config; - } - }); - } - - set hass(hass: HomeAssistant) { - this._hass = hass; - if (this._element) { - this._element.hass = hass; - } - } - - set error(error: ConfigError) { - this._createBadge( - createErrorBadgeConfig(`${error.type}: ${error.message}`) - ); - } - - set config(configValue: LovelaceBadgeConfig) { - const curConfig = this._config; - this._config = configValue; - - if (!configValue) { - this._cleanup(); - return; - } - - if (!this._element) { - this._createBadge(configValue); - return; - } - - // in case the element was an error element we always want to recreate it - if (!this._error && curConfig && configValue.type === curConfig.type) { - this._element.setConfig(configValue); - } else { - this._createBadge(configValue); - } - } - - private _createBadge(configValue: LovelaceBadgeConfig): void { - this._cleanup(); - this._element = createBadgeElement(configValue); - - if (this._hass) { - this._element!.hass = this._hass; - } - - this.appendChild(this._element!); - } - - private _cleanup() { - if (!this._element) { - return; - } - this.removeChild(this._element); - this._element = undefined; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-badge-preview": HuiBadgePreview; - } -} - -customElements.define("hui-badge-preview", HuiBadgePreview); diff --git a/src/panels/lovelace/editor/hui-element-editor.ts b/src/panels/lovelace/editor/hui-element-editor.ts index ab18788d99..b3be3fb3bd 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -23,6 +23,7 @@ import type { HomeAssistant } from "../../../types"; import { LovelaceCardFeatureConfig } from "../card-features/types"; import type { LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceElementConfig } from "../elements/types"; import type { LovelaceConfigForm, LovelaceGenericElementEditor, @@ -32,6 +33,7 @@ import type { HuiFormEditor } from "./config-elements/hui-form-editor"; import "./config-elements/hui-generic-entity-row-editor"; import { GUISupportError } from "./gui-support-error"; import { EditSubElementEvent, GUIModeChangedEvent } from "./types"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; export interface ConfigChangedEvent { config: @@ -39,7 +41,9 @@ export interface ConfigChangedEvent { | LovelaceRowConfig | LovelaceHeaderFooterConfig | LovelaceCardFeatureConfig - | LovelaceStrategyConfig; + | LovelaceStrategyConfig + | LovelaceElementConfig + | LovelaceBadgeConfig; error?: string; guiModeAvailable?: boolean; } diff --git a/src/panels/lovelace/editor/hui-picture-elements-card-row-editor.ts b/src/panels/lovelace/editor/hui-picture-elements-card-row-editor.ts new file mode 100644 index 0000000000..8c3d20e772 --- /dev/null +++ b/src/panels/lovelace/editor/hui-picture-elements-card-row-editor.ts @@ -0,0 +1,259 @@ +import { mdiClose, mdiPencil, mdiContentDuplicate } from "@mdi/js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-svg-icon"; +import { HomeAssistant } from "../../../types"; +import "../../../components/ha-select"; +import type { HaSelect } from "../../../components/ha-select"; +import { + ConditionalElementConfig, + IconElementConfig, + ImageElementConfig, + LovelaceElementConfig, + ServiceButtonElementConfig, + StateBadgeElementConfig, + StateIconElementConfig, + StateLabelElementConfig, +} from "../elements/types"; + +declare global { + interface HASSDomEvents { + "elements-changed": { + elements: any[]; + }; + } +} + +const elementTypes: string[] = [ + "state-badge", + "state-icon", + "state-label", + "action-button", + "icon", + "image", + "conditional", +]; + +@customElement("hui-picture-elements-card-row-editor") +export class HuiPictureElementsCardRowEditor extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public elements?: LovelaceElementConfig[]; + + @query("ha-select") private _select!: HaSelect; + + protected render() { + if (!this.elements || !this.hass) { + return nothing; + } + + return html` +

+ ${this.hass.localize( + "ui.panel.lovelace.editor.card.picture-elements.elements" + )} +

+
+ ${this.elements.map( + (element, index) => html` +
+ ${element.type + ? html` +
+
+ + ${this.hass?.localize( + `ui.panel.lovelace.editor.card.picture-elements.element_types.${element.type}` + ) || element.type} + + ${this._getSecondaryDescription(element)} +
+
+ ` + : nothing} + + + +
+ ` + )} + + ${elementTypes.map( + (element) => html` + ${this.hass?.localize( + `ui.panel.lovelace.editor.card.picture-elements.element_types.${element}` + )} + ` + )} + +
+ `; + } + + private _getSecondaryDescription(element: LovelaceElementConfig): string { + switch (element.type) { + case "icon": + return element.title ?? (element as IconElementConfig).icon ?? ""; + case "state-badge": + case "state-icon": + case "state-label": + return ( + element.title ?? + ( + element as + | StateBadgeElementConfig + | StateIconElementConfig + | StateLabelElementConfig + ).entity ?? + "" + ); + case "action-button": + case "service-button": + return ( + element.title ?? + (element as ServiceButtonElementConfig).action ?? + (element as ServiceButtonElementConfig).service ?? + "" + ); + case "image": + return ( + element.title ?? + (element as ImageElementConfig).image ?? + (element as ImageElementConfig).camera_image ?? + "" + ); + case "conditional": + return ( + element.title ?? + `${((element as ConditionalElementConfig).elements || []).length.toString()} ${this.hass?.localize("ui.panel.lovelace.editor.card.picture-elements.elements")}` + ); + } + return "Unknown type"; + } + + private async _addElement(ev): Promise { + const value = ev.target!.value; + if (value === "") { + return; + } + const newElements = this.elements!.concat({ + type: value! as string, + ...(value !== "conditional" + ? { + style: { + top: "50%", + left: "50%", + }, + } + : {}), + } as LovelaceElementConfig); + fireEvent(this, "elements-changed", { elements: newElements }); + this._select.select(-1); + } + + private _removeRow(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + const newElements = this.elements!.concat(); + + newElements.splice(index, 1); + + fireEvent(this, "elements-changed", { elements: newElements }); + } + + private _editRow(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + fireEvent(this, "edit-detail-element", { + subElementConfig: { + index, + type: "element", + elementConfig: this.elements![index], + }, + }); + } + + private _duplicateRow(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + const newElements = [...this.elements!, this.elements![index]]; + + fireEvent(this, "elements-changed", { elements: newElements }); + } + + static get styles(): CSSResultGroup { + return css` + .element { + display: flex; + align-items: center; + } + + .element-row { + height: 60px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; + } + + .element-row div { + display: flex; + flex-direction: column; + } + + .remove-icon, + .edit-icon, + .duplicate-icon { + --mdc-icon-button-size: 36px; + color: var(--secondary-text-color); + } + + .secondary { + font-size: 12px; + color: var(--secondary-text-color); + } + + ha-select { + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-picture-elements-card-row-editor": HuiPictureElementsCardRowEditor; + } +} diff --git a/src/panels/lovelace/editor/hui-sub-element-editor.ts b/src/panels/lovelace/editor/hui-sub-element-editor.ts index 62848734dd..df6ed4308d 100644 --- a/src/panels/lovelace/editor/hui-sub-element-editor.ts +++ b/src/panels/lovelace/editor/hui-sub-element-editor.ts @@ -1,6 +1,13 @@ import "@material/mwc-button"; import { mdiCodeBraces, mdiListBoxOutline } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + TemplateResult, +} from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-icon-button"; @@ -13,6 +20,7 @@ import "./header-footer-editor/hui-header-footer-element-editor"; import type { HuiElementEditor } from "./hui-element-editor"; import "./feature-editor/hui-card-feature-element-editor"; import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types"; +import "./picture-element-editor/hui-picture-element-element-editor"; declare global { interface HASSDomEvents { @@ -95,7 +103,18 @@ export class HuiSubElementEditor extends LitElement { @GUImode-changed=${this._handleGUIModeChanged} > ` - : ""} + : this.config.type === "element" + ? html` + + ` + : nothing} `; } diff --git a/src/panels/lovelace/editor/lovelace-badges.ts b/src/panels/lovelace/editor/lovelace-badges.ts new file mode 100644 index 0000000000..c4b17856bf --- /dev/null +++ b/src/panels/lovelace/editor/lovelace-badges.ts @@ -0,0 +1,8 @@ +import { Badge } from "./types"; + +export const coreBadges: Badge[] = [ + { + type: "entity", + showElement: true, + }, +]; diff --git a/src/panels/lovelace/editor/lovelace-path.ts b/src/panels/lovelace/editor/lovelace-path.ts index d4527126ba..22f58dfbd5 100644 --- a/src/panels/lovelace/editor/lovelace-path.ts +++ b/src/panels/lovelace/editor/lovelace-path.ts @@ -1,3 +1,4 @@ +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { LovelaceSectionRawConfig, @@ -80,35 +81,6 @@ export const findLovelaceContainer: FindLovelaceContainer = ( return section; }; -export const findLovelaceCards = ( - config: LovelaceConfig, - path: LovelaceContainerPath -): LovelaceCardConfig[] | undefined => { - const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); - - const view = config.views[viewIndex]; - - if (!view) { - throw new Error("View does not exist"); - } - if (isStrategyView(view)) { - throw new Error("Can not find cards in a strategy view"); - } - if (sectionIndex === undefined) { - return view.cards; - } - - const section = view.sections?.[sectionIndex]; - - if (!section) { - throw new Error("Section does not exist"); - } - if (isStrategySection(section)) { - throw new Error("Can not find cards in a strategy section"); - } - return section.cards; -}; - export const updateLovelaceContainer = ( config: LovelaceConfig, path: LovelaceContainerPath, @@ -153,10 +125,16 @@ export const updateLovelaceContainer = ( }; }; -export const updateLovelaceCards = ( +type LovelaceItemKeys = { + cards: LovelaceCardConfig[]; + badges: (Partial | string)[]; +}; + +export const updateLovelaceItems = ( + key: T, config: LovelaceConfig, path: LovelaceContainerPath, - cards: LovelaceCardConfig[] + items: LovelaceItemKeys[T] ): LovelaceConfig => { const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); @@ -164,13 +142,13 @@ export const updateLovelaceCards = ( const newViews = config.views.map((view, vIndex) => { if (vIndex !== viewIndex) return view; if (isStrategyView(view)) { - throw new Error("Can not update cards in a strategy view"); + throw new Error(`Can not update ${key} in a strategy view`); } if (sectionIndex === undefined) { updated = true; return { ...view, - cards, + [key]: items, }; } @@ -181,12 +159,12 @@ export const updateLovelaceCards = ( const newSections = view.sections.map((section, sIndex) => { if (sIndex !== sectionIndex) return section; if (isStrategySection(section)) { - throw new Error("Can not update cards in a strategy section"); + throw new Error(`Can not update ${key} in a strategy section`); } updated = true; return { ...section, - cards, + [key]: items, }; }); return { @@ -196,10 +174,43 @@ export const updateLovelaceCards = ( }); if (!updated) { - throw new Error("Can not update cards in a non-existing view/section"); + throw new Error(`Can not update ${key} in a non-existing view/section`); } return { ...config, views: newViews, }; }; + +export const findLovelaceItems = ( + key: T, + config: LovelaceConfig, + path: LovelaceContainerPath +): LovelaceItemKeys[T] | undefined => { + const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); + + const view = config.views[viewIndex]; + + if (!view) { + throw new Error("View does not exist"); + } + if (isStrategyView(view)) { + throw new Error("Can not find cards in a strategy view"); + } + if (sectionIndex === undefined) { + return view[key] as LovelaceItemKeys[T] | undefined; + } + + const section = view.sections?.[sectionIndex]; + + if (!section) { + throw new Error("Section does not exist"); + } + if (isStrategySection(section)) { + throw new Error("Can not find cards in a strategy section"); + } + if (key === "cards") { + return section[key as "cards"] as LovelaceItemKeys[T] | undefined; + } + throw new Error(`${key} is not supported in section`); +}; diff --git a/src/panels/lovelace/editor/picture-element-editor/hui-picture-element-element-editor.ts b/src/panels/lovelace/editor/picture-element-editor/hui-picture-element-element-editor.ts new file mode 100644 index 0000000000..946061d366 --- /dev/null +++ b/src/panels/lovelace/editor/picture-element-editor/hui-picture-element-element-editor.ts @@ -0,0 +1,33 @@ +import { customElement } from "lit/decorators"; +import { LovelaceElementConfig } from "../../elements/types"; +import type { LovelacePictureElementEditor } from "../../types"; +import { HuiElementEditor } from "../hui-element-editor"; +import { getPictureElementClass } from "../../create-element/create-picture-element"; + +@customElement("hui-picture-element-element-editor") +export class HuiPictureElementElementEditor extends HuiElementEditor { + protected get configElementType(): string | undefined { + return this.value?.type === "action-button" + ? "service-button" + : this.value?.type; + } + + protected async getConfigElement(): Promise< + LovelacePictureElementEditor | undefined + > { + const elClass = await getPictureElementClass(this.configElementType!); + + // Check if a GUI editor exists + if (elClass && elClass.getConfigElement) { + return elClass.getConfigElement(); + } + + return undefined; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-picture-element-element-editor": HuiPictureElementElementEditor; + } +} diff --git a/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts b/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts index de52e96888..91ab6c3f2f 100644 --- a/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts +++ b/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts @@ -1,7 +1,5 @@ import { ActionDetail } from "@material/mwc-list"; import { mdiCheck, mdiClose, mdiDotsVertical } from "@mdi/js"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; import { CSSResultGroup, LitElement, @@ -35,6 +33,8 @@ import { import "./hui-section-settings-editor"; import "./hui-section-visibility-editor"; import type { EditSectionDialogParams } from "./show-edit-section-dialog"; +import "@material/mwc-tab-bar/mwc-tab-bar"; +import "@material/mwc-tab/mwc-tab"; const TABS = ["tab-settings", "tab-visibility"] as const; @@ -51,7 +51,7 @@ export class HuiDialogEditSection @state() private _yamlMode = false; - @state() private _curTab: (typeof TABS)[number] = TABS[0]; + @state() private _currTab: (typeof TABS)[number] = TABS[0]; @query("ha-yaml-editor") private _editor?: HaYamlEditor; @@ -77,7 +77,7 @@ export class HuiDialogEditSection this._params = undefined; this._yamlMode = false; this._config = undefined; - this._curTab = TABS[0]; + this._currTab = TABS[0]; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -101,7 +101,7 @@ export class HuiDialogEditSection > `; } else { - switch (this._curTab) { + switch (this._currTab) { case "tab-settings": content = html` ${!this._yamlMode ? html` - ${TABS.map( - (tab, index) => html` - - ${this.hass!.localize( + (tab) => html` + + > + ` )} - + ` : nothing} @@ -221,11 +220,12 @@ export class HuiDialogEditSection this._config = ev.detail.value; } - private _handleTabSelected(ev: CustomEvent): void { - if (!ev.detail.value) { + private _handleTabChanged(ev: CustomEvent): void { + const newTab = TABS[ev.detail.index]; + if (newTab === this._currTab) { return; } - this._curTab = ev.detail.value.id; + this._currTab = newTab; } private async _handleAction(ev: CustomEvent) { @@ -293,8 +293,7 @@ export class HuiDialogEditSection ha-dialog.yaml-mode { --dialog-content-padding: 0; } - paper-tabs { - --paper-tabs-selection-bar-color: var(--primary-color); + mwc-tab-bar { color: var(--primary-text-color); text-transform: uppercase; padding: 0 20px; diff --git a/src/panels/lovelace/editor/structs/action-struct.ts b/src/panels/lovelace/editor/structs/action-struct.ts index d0229a9908..e69e59b874 100644 --- a/src/panels/lovelace/editor/structs/action-struct.ts +++ b/src/panels/lovelace/editor/structs/action-struct.ts @@ -31,8 +31,9 @@ const actionConfigStructUrl = object({ }); const actionConfigStructService = object({ - action: literal("call-service"), - service: string(), + action: enums(["call-service", "perform-action"]), + service: optional(string()), + perform_action: optional(string()), service_data: optional(object()), data: optional(object()), target: optional( @@ -64,6 +65,7 @@ export const actionConfigStructType = object({ "toggle", "more-info", "call-service", + "perform-action", "url", "navigate", "assist", @@ -77,6 +79,9 @@ export const actionConfigStruct = dynamic((value) => { case "call-service": { return actionConfigStructService; } + case "perform-action": { + return actionConfigStructService; + } case "navigate": { return actionConfigStructNavigate; } diff --git a/src/panels/lovelace/editor/structs/base-badge-struct.ts b/src/panels/lovelace/editor/structs/base-badge-struct.ts new file mode 100644 index 0000000000..b738119cef --- /dev/null +++ b/src/panels/lovelace/editor/structs/base-badge-struct.ts @@ -0,0 +1,6 @@ +import { object, string, any } from "superstruct"; + +export const baseLovelaceBadgeConfig = object({ + type: string(), + visibility: any(), +}); diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 44c42beac8..0aacf3c406 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -7,6 +7,8 @@ import { import { EntityConfig, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceCardFeatureConfig } from "../card-features/types"; +import { LovelaceElementConfig } from "../elements/types"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; export interface YamlChangedEvent extends Event { detail: { @@ -65,6 +67,15 @@ export interface Card { isSuggested?: boolean; } +export interface Badge { + type: string; + name?: string; + description?: string; + showElement?: boolean; + isCustom?: boolean; + isSuggested?: boolean; +} + export interface HeaderFooter { type: LovelaceHeaderFooterConfig["type"]; icon?: string; @@ -74,13 +85,18 @@ export interface CardPickTarget extends EventTarget { config: LovelaceCardConfig; } +export interface BadgePickTarget extends EventTarget { + config: LovelaceBadgeConfig; +} + export interface SubElementEditorConfig { index?: number; elementConfig?: | LovelaceRowConfig | LovelaceHeaderFooterConfig - | LovelaceCardFeatureConfig; - type: "header" | "footer" | "row" | "feature"; + | LovelaceCardFeatureConfig + | LovelaceElementConfig; + type: "header" | "footer" | "row" | "feature" | "element"; } export interface EditSubElementEvent { diff --git a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts index e08e70a383..a14e5b35a6 100644 --- a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts +++ b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts @@ -1,8 +1,6 @@ import "@material/mwc-button"; import { ActionDetail } from "@material/mwc-list"; import { mdiCheck, mdiClose, mdiDotsVertical } from "@mdi/js"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; import { CSSResultGroup, LitElement, @@ -38,20 +36,17 @@ import { DEFAULT_VIEW_LAYOUT, PANEL_VIEW_LAYOUT, SECTION_VIEW_LAYOUT, - VIEWS_NO_BADGE_SUPPORT, } from "../../views/const"; import { addView, deleteView, replaceView } from "../config-util"; -import "../hui-badge-preview"; -import { processEditorEntities } from "../process-editor-entities"; -import { - EntitiesEditorEvent, - ViewEditEvent, - ViewVisibilityChangeEvent, -} from "../types"; +import { ViewEditEvent, ViewVisibilityChangeEvent } from "../types"; import "./hui-view-editor"; import "./hui-view-background-editor"; import "./hui-view-visibility-editor"; import { EditViewDialogParams } from "./show-edit-view-dialog"; +import "@material/mwc-tab-bar/mwc-tab-bar"; +import "@material/mwc-tab/mwc-tab"; + +const TABS = ["tab-settings", "tab-background", "tab-visibility"] as const; @customElement("hui-dialog-edit-view") export class HuiDialogEditView extends LitElement { @@ -63,7 +58,7 @@ export class HuiDialogEditView extends LitElement { @state() private _saving = false; - @state() private _curTab?: string; + @state() private _currTab: (typeof TABS)[number] = TABS[0]; @state() private _dirty = false; @@ -71,8 +66,6 @@ export class HuiDialogEditView extends LitElement { @query("ha-yaml-editor") private _editor?: HaYamlEditor; - private _curTabIndex = 0; - get _type(): string { if (!this._config) { return DEFAULT_VIEW_LAYOUT; @@ -110,11 +103,11 @@ export class HuiDialogEditView extends LitElement { } public closeDialog(): void { - this._curTabIndex = 0; this._params = undefined; this._config = {}; this._yamlMode = false; this._dirty = false; + this._currTab = TABS[0]; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -145,7 +138,7 @@ export class HuiDialogEditView extends LitElement { > `; } else { - switch (this._curTab) { + switch (this._currTab) { case "tab-settings": content = html` `; break; - case "tab-badges": - content = html` - ${this._config?.badges?.length - ? html` - ${VIEWS_NO_BADGE_SUPPORT.includes(this._type) - ? html` - - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_badges.view_no_badges" - )} - - ` - : nothing} -
- ${this._config.badges.map( - (badgeConfig) => html` - - ` - )} -
- ` - : nothing} - - `; - break; case "tab-visibility": content = html` `; break; - case "tab-cards": - content = html` Cards `; - break; } } @@ -291,33 +249,21 @@ export class HuiDialogEditView extends LitElement { ` : nothing} ${!this._yamlMode - ? html` - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_settings" - )} - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_background" - )} - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_badges" - )} - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_visibility" - )} - ` + ${TABS.map( + (tab) => html` + + + ` + )} + ` : nothing} ${content} @@ -409,11 +355,12 @@ export class HuiDialogEditView extends LitElement { this._delete(); } - private _handleTabSelected(ev: CustomEvent): void { - if (!ev.detail.value) { + private _handleTabChanged(ev: CustomEvent): void { + const newTab = TABS[ev.detail.index]; + if (newTab === this._currTab) { return; } - this._curTab = ev.detail.value.id; + this._currTab = newTab; } private async _save(): Promise { @@ -437,10 +384,6 @@ export class HuiDialogEditView extends LitElement { viewConf.cards = []; } - if (!viewConf.badges?.length) { - delete viewConf.badges; - } - const lovelace = this._params.lovelace!; try { @@ -495,17 +438,6 @@ export class HuiDialogEditView extends LitElement { this._dirty = true; } - private _badgesChanged(ev: EntitiesEditorEvent): void { - if (!this.hass || !ev.detail || !ev.detail.entities) { - return; - } - this._config = { - ...this._config, - badges: processEditorEntities(ev.detail.entities), - }; - this._dirty = true; - } - private _viewYamlChanged(ev: CustomEvent) { ev.stopPropagation(); if (!ev.detail.isValid) { @@ -553,8 +485,7 @@ export class HuiDialogEditView extends LitElement { font-size: inherit; font-weight: inherit; } - paper-tabs { - --paper-tabs-selection-bar-color: var(--primary-color); + mwc-tab-bar { color: var(--primary-text-color); text-transform: uppercase; padding: 0 20px; @@ -580,12 +511,6 @@ export class HuiDialogEditView extends LitElement { color: var(--error-color); border-bottom: 1px solid var(--error-color); } - .preview-badges { - display: flex; - justify-content: center; - margin: 12px 16px; - flex-wrap: wrap; - } .incompatible { display: block; } diff --git a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts index f38b6c6e6e..ff0c8465b3 100644 --- a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts +++ b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts @@ -146,11 +146,11 @@ export class HuiViewEditor extends LitElement { if ( this.isNew && !this._suggestedPath && - config.title && + this._config.path === config.path && (!this._config.path || config.path === slugify(this._config.title || "", "-")) ) { - config.path = slugify(config.title, "-"); + config.path = slugify(config.title || "", "-"); } fireEvent(this, "view-config-changed", { config }); diff --git a/src/panels/lovelace/elements/hui-conditional-element.ts b/src/panels/lovelace/elements/hui-conditional-element.ts index 3f515e5870..838bb70874 100644 --- a/src/panels/lovelace/elements/hui-conditional-element.ts +++ b/src/panels/lovelace/elements/hui-conditional-element.ts @@ -4,6 +4,7 @@ import { checkConditionsMet, validateConditionalConfig, } from "../common/validate-condition"; +import { LovelacePictureElementEditor } from "../types"; import { ConditionalElementConfig, LovelaceElement, @@ -11,6 +12,13 @@ import { } from "./types"; class HuiConditionalElement extends HTMLElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/elements/hui-conditional-element-editor" + ); + return document.createElement("hui-conditional-element-editor"); + } + public _hass?: HomeAssistant; private _config?: ConditionalElementConfig; diff --git a/src/panels/lovelace/elements/hui-icon-element.ts b/src/panels/lovelace/elements/hui-icon-element.ts index 0dcd51fdd7..4daadc94ed 100644 --- a/src/panels/lovelace/elements/hui-icon-element.ts +++ b/src/panels/lovelace/elements/hui-icon-element.ts @@ -8,10 +8,16 @@ import { actionHandler } from "../common/directives/action-handler-directive"; import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; import { IconElementConfig, LovelaceElement } from "./types"; +import { LovelacePictureElementEditor } from "../types"; import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; @customElement("hui-icon-element") export class HuiIconElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/elements/hui-icon-element-editor"); + return document.createElement("hui-icon-element-editor"); + } + public hass?: HomeAssistant; @state() private _config?: IconElementConfig; diff --git a/src/panels/lovelace/elements/hui-image-element.ts b/src/panels/lovelace/elements/hui-image-element.ts index 4fc9159c72..104461661a 100644 --- a/src/panels/lovelace/elements/hui-image-element.ts +++ b/src/panels/lovelace/elements/hui-image-element.ts @@ -10,9 +10,15 @@ import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; import "../components/hui-image"; import { ImageElementConfig, LovelaceElement } from "./types"; +import { LovelacePictureElementEditor } from "../types"; @customElement("hui-image-element") export class HuiImageElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/elements/hui-image-element-editor"); + return document.createElement("hui-image-element-editor"); + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: ImageElementConfig; diff --git a/src/panels/lovelace/elements/hui-service-button-element.ts b/src/panels/lovelace/elements/hui-service-button-element.ts index 91c8abcb75..73bed5d415 100644 --- a/src/panels/lovelace/elements/hui-service-button-element.ts +++ b/src/panels/lovelace/elements/hui-service-button-element.ts @@ -3,12 +3,20 @@ import { customElement, state } from "lit/decorators"; import "../../../components/buttons/ha-call-service-button"; import { HomeAssistant } from "../../../types"; import { LovelaceElement, ServiceButtonElementConfig } from "./types"; +import { LovelacePictureElementEditor } from "../types"; @customElement("hui-service-button-element") export class HuiServiceButtonElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/elements/hui-service-button-element-editor" + ); + return document.createElement("hui-service-button-element-editor"); + } + public hass?: HomeAssistant; @state() private _config?: ServiceButtonElementConfig; @@ -18,18 +26,21 @@ export class HuiServiceButtonElement private _service?: string; public setConfig(config: ServiceButtonElementConfig): void { - if (!config || !config.service) { - throw Error("Service required"); + if (!config || (!config.action && !config.service)) { + throw Error("Action required"); } - [this._domain, this._service] = config.service.split(".", 2); + [this._domain, this._service] = (config.action ?? config.service)!.split( + ".", + 2 + ); if (!this._domain) { - throw Error("Service does not have a service domain"); + throw Error("Action does not have a domain"); } if (!this._service) { - throw Error("Service does not have a service name"); + throw Error("Action does not have a action name"); } this._config = config; @@ -40,14 +51,26 @@ export class HuiServiceButtonElement return nothing; } + const { entity_id, label_id, floor_id, device_id, area_id } = + this._config.service_data ?? this._config.data ?? {}; + const updatedTarget = this._config.target ?? { + entity_id, + label_id, + floor_id, + device_id, + area_id, + }; + return html` ${this._config.title} + ${this._config.title} + `; } diff --git a/src/panels/lovelace/elements/hui-state-badge-element.ts b/src/panels/lovelace/elements/hui-state-badge-element.ts index 446bae8cdc..78d69a31ca 100644 --- a/src/panels/lovelace/elements/hui-state-badge-element.ts +++ b/src/panels/lovelace/elements/hui-state-badge-element.ts @@ -12,12 +12,20 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import "../components/hui-warning-element"; import { LovelaceElement, StateBadgeElementConfig } from "./types"; +import { LovelacePictureElementEditor } from "../types"; @customElement("hui-state-badge-element") export class HuiStateBadgeElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/elements/hui-state-badge-element-editor" + ); + return document.createElement("hui-state-badge-element-editor"); + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: StateBadgeElementConfig; @@ -44,7 +52,7 @@ export class HuiStateBadgeElement if (!stateObj) { return html` `; } diff --git a/src/panels/lovelace/elements/hui-state-icon-element.ts b/src/panels/lovelace/elements/hui-state-icon-element.ts index 1f21100479..84e0a7add6 100644 --- a/src/panels/lovelace/elements/hui-state-icon-element.ts +++ b/src/panels/lovelace/elements/hui-state-icon-element.ts @@ -18,10 +18,18 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import "../components/hui-warning-element"; import { LovelaceElement, StateIconElementConfig } from "./types"; +import { LovelacePictureElementEditor } from "../types"; import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; @customElement("hui-state-icon-element") export class HuiStateIconElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/elements/hui-state-icon-element-editor" + ); + return document.createElement("hui-state-icon-element-editor"); + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: StateIconElementConfig; @@ -52,7 +60,7 @@ export class HuiStateIconElement extends LitElement implements LovelaceElement { if (!stateObj) { return html` `; } diff --git a/src/panels/lovelace/elements/hui-state-label-element.ts b/src/panels/lovelace/elements/hui-state-label-element.ts index a8176fdb4a..ef699bf6a6 100644 --- a/src/panels/lovelace/elements/hui-state-label-element.ts +++ b/src/panels/lovelace/elements/hui-state-label-element.ts @@ -18,9 +18,17 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import "../components/hui-warning-element"; import { LovelaceElement, StateLabelElementConfig } from "./types"; +import { LovelacePictureElementEditor } from "../types"; @customElement("hui-state-label-element") class HuiStateLabelElement extends LitElement implements LovelaceElement { + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/elements/hui-state-label-element-editor" + ); + return document.createElement("hui-state-label-element-editor"); + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: StateLabelElementConfig; @@ -47,7 +55,7 @@ class HuiStateLabelElement extends LitElement implements LovelaceElement { if (!stateObj) { return html` `; } diff --git a/src/panels/lovelace/elements/types.ts b/src/panels/lovelace/elements/types.ts index 2cddae5721..fcd9a44865 100644 --- a/src/panels/lovelace/elements/types.ts +++ b/src/panels/lovelace/elements/types.ts @@ -1,3 +1,4 @@ +import { HassServiceTarget } from "home-assistant-js-websocket"; import { ActionConfig } from "../../../data/lovelace/config/action"; import { HomeAssistant } from "../../../types"; import { Condition } from "../common/validate-condition"; @@ -25,6 +26,7 @@ export interface LovelaceElement extends HTMLElement { export interface ConditionalElementConfig extends LovelaceElementConfigBase { conditions: Condition[]; elements: LovelaceElementConfigBase[]; + title?: string; } export interface IconElementConfig extends LovelaceElementConfigBase { @@ -33,7 +35,8 @@ export interface IconElementConfig extends LovelaceElementConfigBase { tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; - icon: string; + icon?: string; + title?: string; } export interface ImageElementConfig extends LovelaceElementConfigBase { @@ -51,16 +54,22 @@ export interface ImageElementConfig extends LovelaceElementConfigBase { filter?: string; state_filter?: string; aspect_ratio?: string; + title?: string; } export interface ServiceButtonElementConfig extends LovelaceElementConfigBase { title?: string; + /* @deprecated "service" is kept for backwards compatibility. Replaced by "action". */ service?: string; + action?: string; + target?: HassServiceTarget; + /* @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ service_data?: Record; + data?: Record; } export interface StateBadgeElementConfig extends LovelaceElementConfigBase { - entity: string; + entity?: string; title?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; @@ -68,20 +77,22 @@ export interface StateBadgeElementConfig extends LovelaceElementConfigBase { } export interface StateIconElementConfig extends LovelaceElementConfigBase { - entity: string; + entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; icon?: string; state_color?: boolean; + title?: string; } export interface StateLabelElementConfig extends LovelaceElementConfigBase { - entity: string; + entity?: string; attribute?: string; prefix?: string; suffix?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; + title?: string; } diff --git a/src/panels/lovelace/entity-rows/hui-timer-entity-row.ts b/src/panels/lovelace/entity-rows/hui-timer-entity-row.ts index bd58e41534..cb6e6d17a1 100644 --- a/src/panels/lovelace/entity-rows/hui-timer-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-timer-entity-row.ts @@ -1,7 +1,6 @@ -import { HassEntity } from "home-assistant-js-websocket"; -import { html, LitElement, PropertyValues, nothing } from "lit"; +import { LitElement, PropertyValues, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { computeDisplayTimer, timerTimeRemaining } from "../../../data/timer"; +import "../../../state-display/ha-timer-remaining-time"; import { HomeAssistant } from "../../../types"; import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; @@ -14,42 +13,11 @@ class HuiTimerEntityRow extends LitElement { @state() private _config?: EntityConfig; - @state() private _timeRemaining?: number; - - private _interval?: number; - public setConfig(config: EntityConfig): void { if (!config) { throw new Error("Invalid configuration"); } this._config = config; - - if (!this.hass) { - return; - } - - const stateObj = this.hass!.states[this._config.entity]; - - if (stateObj) { - this._startInterval(stateObj); - } else { - this._clearInterval(); - } - } - - public disconnectedCallback(): void { - super.disconnectedCallback(); - this._clearInterval(); - } - - public connectedCallback(): void { - super.connectedCallback(); - if (this._config && this._config.entity) { - const stateObj = this.hass?.states[this._config!.entity]; - if (stateObj) { - this._startInterval(stateObj); - } - } } protected render() { @@ -70,61 +38,18 @@ class HuiTimerEntityRow extends LitElement { return html`
- ${computeDisplayTimer(this.hass, stateObj, this._timeRemaining)} +
`; } protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.has("_timeRemaining")) { - return true; - } - return hasConfigOrEntityChanged(this, changedProps); } - - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - - if (!this._config || !changedProps.has("hass")) { - return; - } - const stateObj = this.hass!.states[this._config!.entity]; - const oldHass = changedProps.get("hass") as this["hass"]; - const oldStateObj = oldHass - ? oldHass.states[this._config!.entity] - : undefined; - - if (oldStateObj !== stateObj) { - this._startInterval(stateObj); - } else if (!stateObj) { - this._clearInterval(); - } - } - - private _clearInterval(): void { - if (this._interval) { - window.clearInterval(this._interval); - this._interval = undefined; - } - } - - private _startInterval(stateObj: HassEntity): void { - this._clearInterval(); - this._calculateRemaining(stateObj); - - if (stateObj.state === "active") { - this._interval = window.setInterval( - () => this._calculateRemaining(stateObj), - 1000 - ); - } - } - - private _calculateRemaining(stateObj: HassEntity): void { - this._timeRemaining = timerTimeRemaining(stateObj); - } } declare global { diff --git a/src/panels/lovelace/entity-rows/types.ts b/src/panels/lovelace/entity-rows/types.ts index 9fe17e5fd5..d5c2053418 100644 --- a/src/panels/lovelace/entity-rows/types.ts +++ b/src/panels/lovelace/entity-rows/types.ts @@ -41,8 +41,12 @@ export interface TextConfig { text: string; } export interface CallServiceConfig extends EntityConfig { - type: "call-service"; - service: string; + type: "call-service" | "perform-action"; + /** @deprecated use "action" instead */ + service?: string; + action: string; + data?: Record; + /** @deprecated use "data" instead */ service_data?: Record; action_name?: string; } diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts index 47e33c7102..ea011df222 100644 --- a/src/panels/lovelace/sections/hui-grid-section.ts +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -214,7 +214,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { --column-count: 4; --row-gap: var(--ha-section-grid-row-gap, 8px); --column-gap: var(--ha-section-grid-column-gap, 8px); - --row-height: 66px; + --row-height: var(--ha-section-grid-row-height, 56px); display: flex; flex-direction: column; gap: var(--row-gap); @@ -246,7 +246,12 @@ export class GridSection extends LitElement implements LovelaceSectionElement { text-align: var(--ha-view-sections-title-text-align, start); min-height: 32px; display: block; - padding: 24px 10px 10px; + height: var(--row-height); + box-sizing: border-box; + padding: 0 10px 10px; + display: flex; + flex-direction: column; + justify-content: flex-end; } .title.placeholder { diff --git a/src/panels/lovelace/special-rows/hui-call-service-row.ts b/src/panels/lovelace/special-rows/hui-call-service-row.ts index 249debf095..e3a2f25f31 100644 --- a/src/panels/lovelace/special-rows/hui-call-service-row.ts +++ b/src/panels/lovelace/special-rows/hui-call-service-row.ts @@ -15,15 +15,16 @@ export class HuiCallServiceRow extends HuiButtonRow { throw new Error("No name specified"); } - if (!callServiceConfig.service) { - throw new Error("No service specified"); + if (!callServiceConfig.action && !callServiceConfig.service) { + throw new Error("No action specified"); } super.setConfig({ tap_action: { - action: "call-service", - service: callServiceConfig.service, - data: callServiceConfig.service_data, + action: "perform-action", + perform_action: (callServiceConfig.action || + callServiceConfig.service)!, + data: callServiceConfig.data || callServiceConfig.service_data, }, ...callServiceConfig, type: "button", diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index 4c7ae4d164..d99f52112f 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -12,6 +12,7 @@ import { Constructor, HomeAssistant } from "../../types"; import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types"; import { LovelaceCardFeatureConfig } from "./card-features/types"; +import { LovelaceElement, LovelaceElementConfig } from "./elements/types"; declare global { // eslint-disable-next-line @@ -51,7 +52,6 @@ export type LovelaceLayoutOptions = { export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; - isPanel?: boolean; preview?: boolean; layout?: string; getCardSize(): number | Promise; @@ -82,6 +82,16 @@ export interface LovelaceCardConstructor extends Constructor { getConfigForm?: () => LovelaceConfigForm; } +export interface LovelaceBadgeConstructor extends Constructor { + getStubConfig?: ( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ) => LovelaceBadgeConfig; + getConfigElement?: () => LovelaceBadgeEditor; + getConfigForm?: () => LovelaceConfigForm; +} + export interface LovelaceHeaderFooterConstructor extends Constructor { getStubConfig?: ( @@ -96,6 +106,11 @@ export interface LovelaceRowConstructor extends Constructor { getConfigElement?: () => LovelaceRowEditor; } +export interface LovelaceElementConstructor + extends Constructor { + getConfigElement?: () => LovelacePictureElementEditor; +} + export interface LovelaceHeaderFooter extends HTMLElement { hass?: HomeAssistant; type: "header" | "footer"; @@ -107,6 +122,10 @@ export interface LovelaceCardEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceCardConfig): void; } +export interface LovelaceBadgeEditor extends LovelaceGenericElementEditor { + setConfig(config: LovelaceBadgeConfig): void; +} + export interface LovelaceHeaderFooterEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceHeaderFooterConfig): void; @@ -116,6 +135,11 @@ export interface LovelaceRowEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceRowConfig): void; } +export interface LovelacePictureElementEditor + extends LovelaceGenericElementEditor { + setConfig(config: LovelaceElementConfig): void; +} + export interface LovelaceGenericElementEditor extends HTMLElement { hass?: HomeAssistant; lovelace?: LovelaceConfig; diff --git a/src/panels/lovelace/views/const.ts b/src/panels/lovelace/views/const.ts index fb68615fd8..1135d746b9 100644 --- a/src/panels/lovelace/views/const.ts +++ b/src/panels/lovelace/views/const.ts @@ -2,4 +2,3 @@ export const DEFAULT_VIEW_LAYOUT = "masonry"; export const PANEL_VIEW_LAYOUT = "panel"; export const SIDEBAR_VIEW_LAYOUT = "sidebar"; export const SECTION_VIEW_LAYOUT = "sections"; -export const VIEWS_NO_BADGE_SUPPORT = [PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT]; diff --git a/src/panels/lovelace/views/hui-masonry-view.ts b/src/panels/lovelace/views/hui-masonry-view.ts index 520856ad7b..500bc441cf 100644 --- a/src/panels/lovelace/views/hui-masonry-view.ts +++ b/src/panels/lovelace/views/hui-masonry-view.ts @@ -15,9 +15,11 @@ import "../../../components/ha-svg-icon"; import type { LovelaceViewElement } from "../../../data/lovelace"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../types"; +import { HuiBadge } from "../badges/hui-badge"; +import "../badges/hui-view-badges"; import { HuiCard } from "../cards/hui-card"; import { computeCardSize } from "../common/compute-card-size"; -import type { Lovelace, LovelaceBadge } from "../types"; +import type { Lovelace } from "../types"; // Find column with < 5 size, else smallest column const getColumnIndex = (columnSizes: number[], size: number) => { @@ -50,7 +52,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement { @property({ attribute: false }) public cards: HuiCard[] = []; - @property({ attribute: false }) public badges: LovelaceBadge[] = []; + @property({ attribute: false }) public badges: HuiBadge[] = []; @state() private _columns?: number; @@ -78,9 +80,12 @@ export class MasonryView extends LitElement implements LovelaceViewElement { protected render(): TemplateResult { return html` - ${this.badges.length > 0 - ? html`
${this.badges}
` - : ""} +
(); - private _getKey(sectionConfig: HuiSection) { - if (!this._sectionConfigKeys.has(sectionConfig)) { - this._sectionConfigKeys.set(sectionConfig, Math.random().toString()); + private _getSectionKey(section: HuiSection) { + if (!this._sectionConfigKeys.has(section)) { + this._sectionConfigKeys.set(section, Math.random().toString()); } - return this._sectionConfigKeys.get(sectionConfig)!; + return this._sectionConfigKeys.get(section)!; } private _computeSectionsCount() { @@ -60,11 +65,24 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ).length; } + private _sectionVisibilityChanged = () => { + this._computeSectionsCount(); + }; + connectedCallback(): void { super.connectedCallback(); - this.addEventListener("section-visibility-changed", () => { - this._computeSectionsCount(); - }); + this.addEventListener( + "section-visibility-changed", + this._sectionVisibilityChanged + ); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener( + "section-visibility-changed", + this._sectionVisibilityChanged + ); } willUpdate(changedProperties: PropertyValues): void { @@ -83,9 +101,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const maxColumnsCount = this._config?.max_columns; return html` - ${this.badges.length > 0 - ? html`
${this.badges}
` - : ""} + ${repeat( sections, - (section) => this._getKey(section), + (section) => this._getSectionKey(section), (section, idx) => { (section as any).itemPath = [idx]; return html` @@ -141,7 +162,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ${editMode ? html`