diff --git a/.browserslistrc b/.browserslistrc index 516fec9cec5e..f500a9360514 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/release.yaml b/.github/workflows/release.yaml index 17278c6123f1..8975ef3c4258 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -76,7 +76,7 @@ jobs: - name: Build wheels 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/.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 000000000000..81e93fcd1079 --- /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 9c292c792101..84e1490f995b 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/entry-html.js b/build-scripts/gulp/entry-html.js index dd6285c3103a..3afb7510393c 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 f9134da76c0e..ff4d5c2b7e03 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(); }); - ` - ); - done(); -}); + `.trim() + "\n"; -gulp.task("gen-service-worker-app-prod", async () => { - // Read bundled source file - const bundleManifestLatest = fs.readJsonSync( - path.resolve(paths.app_output_latest, "manifest.json") +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", + }) + ) ); - 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 5ba0f35d2383..94ca35b8f553 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 80fc487bef21..ba22de72849d 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/html/index.html.template b/demo/src/html/index.html.template index 6b044e44a355..4f4353d63363 100644 --- a/demo/src/html/index.html.template +++ b/demo/src/html/index.html.template @@ -83,15 +83,6 @@ <%= 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/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index 0e4c9019f6c7..722bfefe8e78 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -53,6 +53,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, @@ -72,6 +73,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, @@ -91,6 +93,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, diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 0fb6d8326304..8657bc4e7b78 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -53,6 +53,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, @@ -72,6 +73,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, @@ -91,6 +93,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, diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index 72a99c7c28e7..3144d99aa6e5 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -215,6 +215,7 @@ const createDeviceRegistryEntries = ( connections: [], manufacturer: "ESPHome", model: "Mock Device", + model_id: "ABC-001", name: "Tag Reader", sw_version: null, hw_version: "1.0.0", diff --git a/hassio/src/entrypoint.js.template b/hassio/src/entrypoint.js.template index c1c4e5831ced..8cc7ba82fdc4 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 8a7457e9f1e5..75b57f7d165a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -149,7 +149,7 @@ "xss": "1.0.15" }, "devDependencies": { - "@babel/core": "7.24.8", + "@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", @@ -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.16.0", - "@typescript-eslint/parser": "7.16.0", + "@typescript-eslint/eslint-plugin": "7.16.1", + "@typescript-eslint/parser": "7.16.1", "@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", @@ -224,14 +225,13 @@ "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", "terser-webpack-plugin": "5.3.10", @@ -244,7 +244,7 @@ "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 +253,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/pyproject.toml b/pyproject.toml index 91ae7718f848..28e4ce2b29b7 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 = "20240719.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 86c61173fb13..53a9497bd621 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/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index c70711ae64a0..0129ef5ca8a6 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 b3f8c5108759..ef751acc4a6c 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`) { this._opened = ev.detail.value; + this._comboBox.filteredItems = this._comboBox.items; } private _filterChanged(ev?: CustomEvent): void { diff --git a/src/components/entity/ha-state-label-badge.ts b/src/components/entity/ha-state-label-badge.ts index 91a90673e8ca..b4d11875223e 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-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 68f5f9cbadea..3d9571caa714 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-sidebar.ts b/src/components/ha-sidebar.ts index da4b698616ce..bd705917aa6e 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -210,6 +210,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { private _editStyleLoaded = false; + private _unsubPersistentNotifications: UnsubscribeFunc | undefined; + @storage({ key: "sidebarPanelOrder", state: true, @@ -283,15 +285,26 @@ class HaSidebar extends SubscribeMixin(LitElement) { hass.localize !== oldHass.localize || hass.locale !== oldHass.locale || hass.states !== oldHass.states || - hass.defaultPanel !== oldHass.defaultPanel + hass.defaultPanel !== oldHass.defaultPanel || + hass.connected !== oldHass.connected ); } protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - subscribeNotifications(this.hass.connection, (notifications) => { - 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/map/ha-map.ts b/src/components/map/ha-map.ts index d9592abfd4de..929114dee9e6 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/data/cover.ts b/src/data/cover.ts index 046da65aa3b3..4916792feed4 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_registry.ts b/src/data/device_registry.ts index 90bd894e8a62..7772febaf250 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -20,6 +20,7 @@ export interface DeviceRegistryEntry { 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/lovelace.ts b/src/data/lovelace.ts index bfd794ef8de0..1b4f47aa3803 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/badge.ts b/src/data/lovelace/config/badge.ts index 661464a9352d..b6b5d7c207f4 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 db0385173da2..4bbf67c5a6cd 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 0e4a7d79ae8c..c79cfb4584e8 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 8af577efd34d..ec831bf67184 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/entrypoints/custom-panel.ts b/src/entrypoints/custom-panel.ts index c12674a98e0a..767855459bf4 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 4b7e262f3bd6..3b67fee9821d 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 c4d56f8aacf1..13a0663221d5 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 62787c4fef93..692d975cd3b4 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 690cd0cd7aa9..aaa55cbfdcf0 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 cb72d05e5ae7..5ea3f23ac476 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 7cfa93ea97b1..ef34659178e9 100644 --- a/src/html/index.html.template +++ b/src/html/index.html.template @@ -60,8 +60,7 @@ <%= renderTemplate("_js_base.html.template") %> <%= renderTemplate("_preload_roboto.html.template") %> - <%= renderTemplate("_script_load_es5.html.template") %> 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 fc43b6a3ca26..0b15bc8acf0d 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"; @@ -253,6 +254,7 @@ export default class HaAutomationActionRow extends LitElement { slot="icons" @action=${this._handleAction} @click=${preventDefault} + @closed=${stopPropagation} fixed > - +
${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 1a9809b9b68a..fb691cc7e031 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/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index 37402301fbb9..b7289a7eed79 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -157,7 +157,7 @@ class HaConfigInfo extends LitElement { )} - ${JS_VERSION}${JS_TYPE !== "latest" ? ` ⸱ ${JS_TYPE}` : ""} + ${JS_VERSION}${JS_TYPE !== "modern" ? ` ⸱ ${JS_TYPE}` : ""} diff --git a/src/panels/config/integrations/ha-config-integrations-dashboard.ts b/src/panels/config/integrations/ha-config-integrations-dashboard.ts index 8c1dacbf3e9c..d103cbf3bc28 100644 --- a/src/panels/config/integrations/ha-config-integrations-dashboard.ts +++ b/src/panels/config/integrations/ha-config-integrations-dashboard.ts @@ -593,6 +593,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/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 3d99a4f9e369..aa4a98a867f0 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/repairs/dialog-repairs-issue.ts b/src/panels/config/repairs/dialog-repairs-issue.ts index 7baf58f32db7..ece7a219f7fd 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 db112f0d0b5f..4e46d47741ab 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/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts new file mode 100644 index 000000000000..9b362fd173a5 --- /dev/null +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -0,0 +1,200 @@ +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-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 (!this._element) { + return; + } + this._element.setConfig(config); + this._elementConfig = config; + fireEvent(this, "badge-updated"); + } + + private _loadElement(config: LovelaceBadgeConfig) { + 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); + } + + 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 000000000000..05a6839da0c1 --- /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 e6a889febe87..3b5de69b3845 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 aa02366f2632..19c6e9582623 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 000000000000..64bfa106b82e --- /dev/null +++ b/src/panels/lovelace/badges/hui-view-badges.ts @@ -0,0 +1,177 @@ +import { mdiPlus } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } 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 _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` + .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 345c1c3d8e41..682308d39a81 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/hui-cover-position-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-position-card-feature.ts index 43fadb8bda2f..cb3a5cf85b9b 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 @@ -61,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); 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 9ebc5b3ebb6f..93314761d26f 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 @@ -74,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) { 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 95944eff0a8b..954ccd9d1a8c 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 5e2d832a4a95..e8347d0854d7 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; @@ -178,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-entity-filter-card.ts b/src/panels/lovelace/cards/hui-entity-filter-card.ts index d79e88ad0daf..804801cc3fae 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 48113e02d6d8..cc2cdeade595 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 0c810280c29c..17e6df279fa7 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-stack-card.ts b/src/panels/lovelace/cards/hui-stack-card.ts index 93bc5c4931f9..76239b6b118f 100644 --- a/src/panels/lovelace/cards/hui-stack-card.ts +++ b/src/panels/lovelace/cards/hui-stack-card.ts @@ -29,8 +29,7 @@ export abstract class HuiStackCard @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 50d283814339..19ece5430b57 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -244,7 +244,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { const color = this._computeStateColor(stateObj, this._config.color); const domain = computeDomain(stateObj.entity_id); - const localizedState = this._config.hide_state + const stateDisplay = this._config.hide_state ? nothing : html`
${this._config.features @@ -383,9 +383,9 @@ export class HuiTileCard extends LitElement implements LovelaceCard { display: flex; flex-direction: row; align-items: center; - padding: 0 10px; - min-height: var(--row-height, 56px); + padding: 10px; flex: 1; + box-sizing: border-box; pointer-events: none; } .vertical { diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 6f75cf3559c5..f065215ce959 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", @@ -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/components/hui-badge-edit-mode.ts b/src/panels/lovelace/components/hui-badge-edit-mode.ts new file mode 100644 index 000000000000..bf57aae9b6b1 --- /dev/null +++ b/src/panels/lovelace/components/hui-badge-edit-mode.ts @@ -0,0 +1,304 @@ +import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; +import { + mdiContentCopy, + mdiContentCut, + mdiContentDuplicate, + mdiDelete, + mdiDotsVertical, + mdiPencil, +} from "@mdi/js"; +import deepClone from "deep-clone-simple"; +import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { storage } from "../../../common/decorators/storage"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-list-item"; +import "../../../components/ha-svg-icon"; +import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; +import { haStyle } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog"; +import { + LovelaceCardPath, + findLovelaceItems, + getLovelaceContainerPath, + parseLovelaceCardPath, +} from "../editor/lovelace-path"; +import { Lovelace } from "../types"; +import { ensureBadgeConfig } from "../../../data/lovelace/config/badge"; + +@customElement("hui-badge-edit-mode") +export class HuiBadgeEditMode extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace!: Lovelace; + + @property({ type: Array }) public path!: LovelaceCardPath; + + @property({ type: Boolean }) public hiddenOverlay = false; + + @state() + public _menuOpened: boolean = false; + + @state() + public _hover: boolean = false; + + @state() + public _focused: boolean = false; + + @storage({ + key: "dashboardBadgeClipboard", + state: false, + subscribe: false, + storage: "sessionStorage", + }) + protected _clipboard?: LovelaceCardConfig; + + private get _badges() { + const containerPath = getLovelaceContainerPath(this.path!); + return findLovelaceItems("badges", this.lovelace!.config, containerPath)!; + } + + private _touchStarted = false; + + protected firstUpdated(): void { + this.addEventListener("focus", () => { + this._focused = true; + }); + this.addEventListener("blur", () => { + this._focused = false; + }); + this.addEventListener("touchstart", () => { + this._touchStarted = true; + }); + this.addEventListener("touchend", () => { + setTimeout(() => { + this._touchStarted = false; + }, 10); + }); + this.addEventListener("mouseenter", () => { + if (this._touchStarted) return; + this._hover = true; + }); + this.addEventListener("mouseout", () => { + this._hover = false; + }); + this.addEventListener("click", () => { + this._hover = true; + document.addEventListener("click", this._documentClicked); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener("click", this._documentClicked); + } + + _documentClicked = (ev) => { + this._hover = ev.composedPath().includes(this); + document.removeEventListener("click", this._documentClicked); + }; + + protected render(): TemplateResult { + const showOverlay = + (this._hover || this._menuOpened || this._focused) && !this.hiddenOverlay; + + return html` +
+
+
+
+ +
+ + + + + + ${this.hass.localize( + "ui.panel.lovelace.editor.edit_card.duplicate" + )} + + + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")} + + + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")} + +
  • + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.delete")} + + +
    +
    + `; + } + + private _handleOpened() { + this._menuOpened = true; + } + + private _handleClosed() { + this._menuOpened = false; + } + + private _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this._duplicateBadge(); + break; + case 1: + this._copyBadge(); + break; + case 2: + this._cutBadge(); + break; + case 3: + this._deleteBadge(); + break; + } + } + + private _cutBadge(): void { + this._copyBadge(); + this._deleteBadge(); + } + + private _copyBadge(): void { + const { cardIndex } = parseLovelaceCardPath(this.path!); + const cardConfig = this._badges[cardIndex]; + this._clipboard = deepClone(cardConfig); + } + + private _duplicateBadge(): void { + const { cardIndex } = parseLovelaceCardPath(this.path!); + const containerPath = getLovelaceContainerPath(this.path!); + const badgeConfig = ensureBadgeConfig(this._badges![cardIndex]); + showEditBadgeDialog(this, { + lovelaceConfig: this.lovelace!.config, + saveConfig: this.lovelace!.saveConfig, + path: containerPath, + badgeConfig, + }); + } + + private _editBadge(ev): void { + if (ev.defaultPrevented) { + return; + } + if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + fireEvent(this, "ll-edit-badge", { path: this.path! }); + } + + private _deleteBadge(): void { + fireEvent(this, "ll-delete-badge", { path: this.path! }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .badge-overlay { + position: absolute; + opacity: 0; + pointer-events: none; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 180ms ease-in-out; + } + + .badge-overlay.visible { + opacity: 1; + pointer-events: auto; + } + + .badge-wrapper { + position: relative; + height: 100%; + z-index: 0; + } + + .edit { + outline: none !important; + cursor: pointer; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ha-card-border-radius, 12px); + z-index: 0; + } + .edit-overlay { + position: absolute; + inset: 0; + opacity: 0.8; + background-color: var(--primary-background-color); + border-radius: var(--ha-card-border-radius, 12px); + z-index: 0; + } + .edit ha-svg-icon { + display: flex; + position: relative; + color: var(--primary-text-color); + border-radius: 50%; + padding: 4px; + background: var(--secondary-background-color); + --mdc-icon-size: 16px; + } + .more { + position: absolute; + right: -8px; + top: -8px; + inset-inline-end: -10px; + inset-inline-start: initial; + } + .more ha-icon-button { + cursor: pointer; + border-radius: 50%; + background: var(--secondary-background-color); + --mdc-icon-button-size: 24px; + --mdc-icon-size: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-edit-mode": HuiBadgeEditMode; + } +} diff --git a/src/panels/lovelace/components/hui-card-edit-mode.ts b/src/panels/lovelace/components/hui-card-edit-mode.ts index a3f611cefe0d..3fd2e1c0574f 100644 --- a/src/panels/lovelace/components/hui-card-edit-mode.ts +++ b/src/panels/lovelace/components/hui-card-edit-mode.ts @@ -24,7 +24,7 @@ import { HomeAssistant } from "../../../types"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { LovelaceCardPath, - findLovelaceCards, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, } from "../editor/lovelace-path"; @@ -50,7 +50,7 @@ export class HuiCardEditMode extends LitElement { public _focused: boolean = false; @storage({ - key: "lovelaceClipboard", + key: "dashboardCardClipboard", state: false, subscribe: false, storage: "sessionStorage", @@ -59,7 +59,7 @@ export class HuiCardEditMode extends LitElement { private get _cards() { const containerPath = getLovelaceContainerPath(this.path!); - return findLovelaceCards(this.lovelace!.config, containerPath)!; + return findLovelaceItems("cards", this.lovelace!.config, containerPath)!; } private _touchStarted = false; diff --git a/src/panels/lovelace/components/hui-card-options.ts b/src/panels/lovelace/components/hui-card-options.ts index db87e8d291ce..261c95775865 100644 --- a/src/panels/lovelace/components/hui-card-options.ts +++ b/src/panels/lovelace/components/hui-card-options.ts @@ -46,7 +46,7 @@ import { } from "../editor/config-util"; import { LovelaceCardPath, - findLovelaceCards, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, } from "../editor/lovelace-path"; @@ -67,7 +67,7 @@ export class HuiCardOptions extends LitElement { @property({ type: Boolean }) public hidePosition = false; @storage({ - key: "lovelaceClipboard", + key: "dashboardCardClipboard", state: false, subscribe: false, storage: "sessionStorage", @@ -91,7 +91,7 @@ export class HuiCardOptions extends LitElement { private get _cards() { const containerPath = getLovelaceContainerPath(this.path!); - return findLovelaceCards(this.lovelace!.config, containerPath)!; + return findLovelaceItems("cards", this.lovelace!.config, containerPath)!; } protected render(): TemplateResult { diff --git a/src/panels/lovelace/components/hui-generic-entity-row.ts b/src/panels/lovelace/components/hui-generic-entity-row.ts index 262459b2bee8..ce683a6a686b 100644 --- a/src/panels/lovelace/components/hui-generic-entity-row.ts +++ b/src/panels/lovelace/components/hui-generic-entity-row.ts @@ -161,7 +161,7 @@ export class HuiGenericEntityRow extends LitElement { : ""}
    ` : nothing} - ${this.catchInteraction ?? !DOMAINS_INPUT_ROW.includes(domain) + ${(this.catchInteraction ?? !DOMAINS_INPUT_ROW.includes(domain)) ? html`
    import("../badges/hui-entity-filter-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, + undefined + ); + 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 5ba763733cf2..65f3ecbcc5d8 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -12,13 +12,13 @@ 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, @@ -39,7 +39,7 @@ interface CreateElementConfigTypes { badge: { config: LovelaceBadgeConfig; element: LovelaceBadge; - constructor: unknown; + constructor: LovelaceBadgeConstructor; }; element: { config: LovelaceElementConfig; @@ -87,16 +87,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 +126,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 +146,7 @@ const _customCreate = ( } const element = _createErrorElement( + tagSuffix, `Custom element doesn't exist: ${tag}.`, config ); @@ -175,7 +203,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 +216,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 +231,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 +248,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/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 000000000000..dad6679e8c7b --- /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 000000000000..81edb16c9f3e --- /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 000000000000..f7f1612d9fe5 --- /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 000000000000..77bbfaead836 --- /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 000000000000..b469ae9d189b --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts @@ -0,0 +1,520 @@ +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; + } + + 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 000000000000..c02195022258 --- /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 000000000000..ab2313d10bd7 --- /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 000000000000..e7640e988780 --- /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 000000000000..06bb94e1863f --- /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-picker.ts b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts index 3537955479a5..c8686a4dfe10 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 eb9294f9e555..9b3c3aa9d151 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/conditions/types/ha-card-condition-state.ts b/src/panels/lovelace/editor/conditions/types/ha-card-condition-state.ts index e0594af3f614..30d24a259255 100644 --- a/src/panels/lovelace/editor/conditions/types/ha-card-condition-state.ts +++ b/src/panels/lovelace/editor/conditions/types/ha-card-condition-state.ts @@ -127,8 +127,8 @@ export class HaCardConditionState extends LitElement { const condition: StateCondition = { condition: "state", ...content, - state: invert === "false" ? state ?? "" : undefined, - state_not: invert === "true" ? state ?? "" : undefined, + state: invert === "false" ? (state ?? "") : undefined, + state_not: invert === "true" ? (state ?? "") : undefined, }; fireEvent(this, "value-changed", { value: condition }); 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 b217bdc57702..dbc5d2fb2616 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-entity-badge-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts new file mode 100644 index 000000000000..73f5b65c0708 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts @@ -0,0 +1,236 @@ +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), + }) +); + +@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.actions`), + 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-stack-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts index c5e81164e134..896ea2129685 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-util.ts b/src/panels/lovelace/editor/config-util.ts index 0207a90e974d..7d467d9aaa4b 100644 --- a/src/panels/lovelace/editor/config-util.ts +++ b/src/panels/lovelace/editor/config-util.ts @@ -1,3 +1,7 @@ +import { + ensureBadgeConfig, + LovelaceBadgeConfig, +} from "../../../data/lovelace/config/badge"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section"; import { LovelaceConfig } from "../../../data/lovelace/config/types"; @@ -9,13 +13,13 @@ import type { HomeAssistant } from "../../../types"; import { LovelaceCardPath, LovelaceContainerPath, - findLovelaceCards, findLovelaceContainer, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, parseLovelaceContainerPath, - updateLovelaceCards, updateLovelaceContainer, + updateLovelaceItems, } from "./lovelace-path"; export const addCard = ( @@ -23,9 +27,9 @@ export const addCard = ( path: LovelaceContainerPath, cardConfig: LovelaceCardConfig ): LovelaceConfig => { - 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/get-badge-stub-config.ts b/src/panels/lovelace/editor/get-badge-stub-config.ts new file mode 100644 index 000000000000..63c64e57a82a --- /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 e312463d4b76..aada4e5978e3 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 ac8125abd1e8..000000000000 --- 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 ab18788d9951..58c03373345a 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -32,6 +32,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 +40,8 @@ export interface ConfigChangedEvent { | LovelaceRowConfig | LovelaceHeaderFooterConfig | LovelaceCardFeatureConfig - | LovelaceStrategyConfig; + | LovelaceStrategyConfig + | LovelaceBadgeConfig; error?: string; guiModeAvailable?: boolean; } diff --git a/src/panels/lovelace/editor/lovelace-badges.ts b/src/panels/lovelace/editor/lovelace-badges.ts new file mode 100644 index 000000000000..c4b17856bf56 --- /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 d4527126ba23..22f58dfbd5ea 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/structs/base-badge-struct.ts b/src/panels/lovelace/editor/structs/base-badge-struct.ts new file mode 100644 index 000000000000..b738119cef9a --- /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 44c42beac8b9..aaa8f4225f0b 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -7,6 +7,7 @@ import { import { EntityConfig, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceCardFeatureConfig } from "../card-features/types"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; export interface YamlChangedEvent extends Event { detail: { @@ -65,6 +66,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,6 +84,10 @@ export interface CardPickTarget extends EventTarget { config: LovelaceCardConfig; } +export interface BadgePickTarget extends EventTarget { + config: LovelaceBadgeConfig; +} + export interface SubElementEditorConfig { index?: number; elementConfig?: 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 e08e70a383d8..3454ea3a15de 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 @@ -38,16 +38,9 @@ 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"; @@ -165,38 +158,6 @@ export class HuiDialogEditView extends LitElement { > `; 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` - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_badges" - )} ${this.hass!.localize( "ui.panel.lovelace.editor.edit_view.tab_visibility" @@ -437,10 +393,6 @@ export class HuiDialogEditView extends LitElement { viewConf.cards = []; } - if (!viewConf.badges?.length) { - delete viewConf.badges; - } - const lovelace = this._params.lovelace!; try { @@ -495,17 +447,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) { @@ -580,12 +521,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 f38b6c6e6e9d..ff0c8465b389 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/types.ts b/src/panels/lovelace/types.ts index 4c7ae4d164bc..ab452b891f9a 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -51,7 +51,6 @@ export type LovelaceLayoutOptions = { export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; - isPanel?: boolean; preview?: boolean; layout?: string; getCardSize(): number | Promise; @@ -82,6 +81,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?: ( @@ -107,6 +116,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; diff --git a/src/panels/lovelace/views/const.ts b/src/panels/lovelace/views/const.ts index fb68615fd81c..1135d746b9d5 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 520856ad7ba5..500bc441cf56 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() { @@ -83,9 +88,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 +149,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ${editMode ? html`