Compare commits

...

38 Commits

Author SHA1 Message Date
Wendelin 188e82fa02 Merge branch 'dev' of github.com:home-assistant/frontend into user-siderbar 2025-02-24 09:58:13 +01:00
Joakim Sørensen a438fc5e41 Add connection check and dialog with results for cloud login (#24301) 2025-02-24 09:37:17 +01:00
karwosts 783132ae46 Fix solar order in compare stack for usage graph (#24360)
* Fix solar order in compare stack for usage graph

* remove accidental commit
2025-02-24 09:08:55 +02:00
renovate[bot] 680d81001c Update rspack monorepo to v1.2.5 (#24353)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 09:00:19 +02:00
dependabot[bot] a917383d7a Bump actions/cache from 4.2.0 to 4.2.1 (#24366)
Bumps [actions/cache](https://github.com/actions/cache) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4.2.0...v4.2.1)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 08:59:27 +02:00
dependabot[bot] 455a6761cd Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#24365)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 08:58:42 +02:00
renovate[bot] acf42d7637 Update dependency globals to v16 (#24359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 08:56:48 +02:00
renovate[bot] 3857c7321a Update dependency eslint-plugin-wc to v2.2.1 (#24362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 07:08:27 +01:00
puddly 5eec814988 Hide hardware integrations from the "add integration" dialog (#24345) 2025-02-22 08:43:18 +02:00
renovate[bot] edd37565a6 Update vitest monorepo to v3.0.6 (#24344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 19:03:37 +01:00
renovate[bot] fb3f779121 Update rspack monorepo to v1.2.4 (#24343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 19:03:00 +01:00
Wendelin 4d7634ac67 Landing-page: ping supervisor before get network infos (#24330)
* Ping supervisor before get network infos

* Rename supervisor proxy prefix
2025-02-21 08:14:10 +02:00
renovate[bot] ba5c1133c6 Lock file maintenance (#24306)
* Lock file maintenance

* Bump codemirror view to 6.36.3

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-02-20 19:36:32 +00:00
Wendelin 0a05dd8f71 Add more tests for common/entity (#24336)
* Use substring instead of deprecated substr

* Add more common entity tests
2025-02-20 20:11:53 +01:00
J. Nick Koston 400106ec09 Adjust WebSocket ping timeout to 15 seconds (#24339)
* Adjust WebSocket ping timeout to 15 seconds

5 seconds was too low to prevent the UI from reloading
when connecting the WebSocket during startup or on
a high latancy connection

This problem presented as the UI reloading over
and over again because it could never respond
to the ping in time on high latancy connections.

At startup it usually only did this once so it
went unnoticed in most cases.

This ping was added in #18934

* Update connection-mixin.ts

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>

---------

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2025-02-20 20:09:51 +01:00
Jan-Philipp Benecke a7a4194e09 Add tile card feature for counter actions (#24340)
* Add tile card feature for counter actions

* Format

* Change icon

* Disable buttons when hit limit

* Change increment/decrement icons
2025-02-20 19:09:44 +00:00
renovate[bot] 0bd7d27c57 Update dependency @lokalise/node-api to v13.2.0 (#24335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-20 16:14:54 +01:00
Jan-Philipp Benecke 8175e45921 Rename switch-toggle feature to toggle and improve (#24333)
* Rename `switch-toggle` feature to `toggle` and improve

* Format
2025-02-20 14:51:49 +01:00
Jan-Philipp Benecke cae36b393b Focus alarm control panel PIN input on wider screens (#24324)
* Focus alarm control panel PIN input on wider screens

* Also apply on textfield
2025-02-20 15:20:28 +02:00
Paul Bottein f84ad92356 Extract saving card config from card editor (#24319)
* Extract saving card config from card editor

* Await

* Add try/catch

* Remove unused translations

* Remove duration
2025-02-20 12:27:39 +01:00
Wendelin fb1ee2ed1d Remove toggles from ha-icon-button (#24331) 2025-02-20 12:14:40 +01:00
Paul Bottein 9073282174 Add text only style to markdown card (#24329) 2025-02-20 11:40:39 +01:00
Jan-Philipp Benecke 91bd5cba08 Add switch toggle feature to tile card (#24325)
* Add tile switch toggle feature

* Remove _currentState
2025-02-20 10:16:14 +02:00
karwosts a68bdbfe08 Fix siren advanced controls (#24318) 2025-02-20 08:50:00 +01:00
Jan-Philipp Benecke f3d614b0d3 Make quick bar more keyboard accessible (#24321) 2025-02-20 08:44:49 +01:00
karwosts f3c9e4a4a0 Fix catching errors in alarm-control-panel more-info (#24328) 2025-02-20 08:42:17 +01:00
karwosts d22a82c4a6 Teardown and rebuild element editor when switching stack cards (#24065) 2025-02-20 07:57:34 +01:00
Jan-Philipp Benecke 5cddc6e5c6 Decrease max cluster radius (#24322) 2025-02-19 21:34:49 +02:00
Jan-Philipp Benecke c5c067ef19 Create copyable textfield component (#24247) 2025-02-19 15:56:29 +01:00
Paul Bottein 694bb3088c Improve margin for inline tile card feature (#24316) 2025-02-19 16:07:27 +02:00
Petar Petrov ad487470fd Enable downsampling in echarts (#24311)
* Enable downsampling in echarts

* remove unneeded symbol set
2025-02-19 16:05:32 +02:00
Wendelin 2fba41b8ca Update translations 2025-02-19 14:06:34 +01:00
Wendelin 25a14c87a5 Delay skeleton loading to prevent flickering 2025-02-18 15:18:53 +01:00
Wendelin 18b2360e46 Fix memoize function 2025-02-18 06:29:17 +01:00
Wendelin c1a214d1af Update code format 2025-02-17 15:56:23 +01:00
Wendelin 6123d932e1 Add loading skeleton 2025-02-17 15:49:05 +01:00
Wendelin a3dcf77f2a Add profile settings for device sidebar 2025-02-17 15:16:34 +01:00
Wendelin 7d7f8a9bc2 Use user preferences for sidebar 2025-02-17 12:56:20 +01:00
58 changed files with 2355 additions and 619 deletions
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: |
node_modules/.cache/prettier
@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.6.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.6.1
with:
name: supervisor-bundle-stats
path: build/stats/*.json
+2 -2
View File
@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.6.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.6.1
with:
name: translations
path: translations.tar.gz
@@ -17,6 +17,7 @@ import "../../../src/components/ha-alert";
import {
ALTERNATIVE_DNS_SERVERS,
getSupervisorNetworkInfo,
pingSupervisor,
setSupervisorNetworkDns,
} from "../data/supervisor";
import { fireEvent } from "../../../src/common/dom/fire_event";
@@ -85,7 +86,28 @@ class LandingPageNetwork extends LitElement {
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._fetchSupervisorInfo();
this._pingSupervisor();
}
private _schedulePingSupervisor() {
setTimeout(
() => this._pingSupervisor(),
SCHEDULE_FETCH_NETWORK_INFO_SECONDS * 1000
);
}
private async _pingSupervisor() {
try {
const response = await pingSupervisor();
if (!response.ok) {
throw new Error("Failed to ping supervisor, assume update in progress");
}
this._fetchSupervisorInfo();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
this._schedulePingSupervisor();
}
}
private _scheduleFetchSupervisorInfo() {
+8 -4
View File
@@ -18,7 +18,7 @@ export const ALTERNATIVE_DNS_SERVERS: {
];
export async function getSupervisorLogs(lines = 100) {
return fetch(`/supervisor/supervisor/logs?lines=${lines}`, {
return fetch(`/supervisor-api/supervisor/logs?lines=${lines}`, {
headers: {
Accept: "text/plain",
},
@@ -26,22 +26,26 @@ export async function getSupervisorLogs(lines = 100) {
}
export async function getSupervisorLogsFollow(lines = 500) {
return fetch(`/supervisor/supervisor/logs/follow?lines=${lines}`, {
return fetch(`/supervisor-api/supervisor/logs/follow?lines=${lines}`, {
headers: {
Accept: "text/plain",
},
});
}
export async function pingSupervisor() {
return fetch("/supervisor-api/supervisor/ping");
}
export async function getSupervisorNetworkInfo() {
return fetch("/supervisor/network/info");
return fetch("/supervisor-api/network/info");
}
export const setSupervisorNetworkDns = async (
dnsServerIndex: number,
primaryInterface: string
) =>
fetch(`/supervisor/network/interface/${primaryInterface}/update`, {
fetch(`/supervisor-api/network/interface/${primaryInterface}/update`, {
method: "POST",
body: JSON.stringify({
ipv4: {
+10 -8
View File
@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.4.3",
"@codemirror/search": "6.5.9",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.2",
"@codemirror/view": "6.36.3",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.3",
"@formatjs/intl-displaynames": "6.8.10",
@@ -90,6 +90,7 @@
"@polymer/paper-tabs": "3.1.0",
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@shoelace-style/shoelace": "2.20.0",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.5",
"@vaadin/vaadin-themable-mixin": "24.6.5",
@@ -161,13 +162,13 @@
"@babel/preset-env": "7.26.9",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.1.0",
"@lokalise/node-api": "13.2.0",
"@octokit/auth-oauth-device": "7.1.3",
"@octokit/plugin-retry": "7.1.4",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.2.3",
"@rspack/core": "1.2.3",
"@rspack/cli": "1.2.5",
"@rspack/core": "1.2.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
@@ -186,7 +187,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.0.5",
"@vitest/coverage-v8": "3.0.6",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -199,7 +200,7 @@
"eslint-plugin-lit": "1.15.0",
"eslint-plugin-lit-a11y": "4.1.4",
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "2.2.0",
"eslint-plugin-wc": "2.2.1",
"fancy-log": "2.0.0",
"fs-extra": "11.3.0",
"glob": "11.0.1",
@@ -226,7 +227,8 @@
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.0.6",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -240,7 +242,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.15.0",
"globals": "16.0.0",
"tslib": "2.8.1"
},
"packageManager": "yarn@4.6.0"
+1 -1
View File
@@ -1,2 +1,2 @@
export const computeDomain = (entityId: string): string =>
entityId.substr(0, entityId.indexOf("."));
entityId.substring(0, entityId.indexOf("."));
+1 -5
View File
@@ -120,11 +120,6 @@ export const computeStateDisplayFromEntityAttributes = (
return value;
}
if (domain === "datetime") {
const time = new Date(state);
return formatDateTime(time, locale, config);
}
if (["date", "input_datetime", "time"].includes(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
@@ -181,6 +176,7 @@ export const computeStateDisplayFromEntityAttributes = (
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" && attributes.device_class === "timestamp")
) {
+5 -5
View File
@@ -521,14 +521,14 @@ export class HaChartBase extends LitElement {
0
);
if (dataSize > 10000) {
// for large datasets zr.flush takes 30-40% of the render time
// so we delay it a bit to avoid blocking the main thread
// delay the last bit of the render to avoid blocking the main thread
// this is not that impactful with sampling enabled but it doesn't hurt to have it
const zr = this.chart.getZr();
this._originalZrFlush = zr.flush.bind(zr);
this._originalZrFlush = zr.flush;
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.();
}, 10);
this._originalZrFlush?.call(zr);
}, 5);
};
}
}
@@ -354,9 +354,10 @@ export class StateHistoryChartLine extends LitElement {
name: nameY,
color,
symbol: "circle",
step: "end",
animationDurationUpdate: 0,
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: fill ? 0 : 1.5,
},
+2 -3
View File
@@ -492,8 +492,8 @@ export class StatisticsChart extends LitElement {
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "circle",
symbolSize: 0,
symbol: "none",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
@@ -511,7 +511,6 @@ export class StatisticsChart extends LitElement {
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
+110
View File
@@ -0,0 +1,110 @@
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import "./ha-button";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HomeAssistant } from "../types";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { showToast } from "../util/toast";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-copy-textfield")
export class HaCopyTextfield extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "value" }) public value!: string;
@property({ attribute: "masked-value" }) public maskedValue?: string;
@property({ attribute: "label" }) public label?: string;
@state() private _showMasked = true;
public render() {
return html`
<div class="container">
<div class="textfield-container">
<ha-textfield
.value=${this._showMasked && this.maskedValue
? this.maskedValue
: this.value}
readonly
.suffix=${this.maskedValue
? html`<div style="width: 24px"></div>`
: nothing}
@click=${this._focusInput}
></ha-textfield>
${this.maskedValue
? html`<ha-icon-button
class="toggle-unmasked"
.label=${this.hass.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
.path=${this._showMasked ? mdiEye : mdiEyeOff}
></ha-icon-button>`
: nothing}
</div>
<ha-button @click=${this._copy} unelevated>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.hass.localize("ui.common.copy")}
</ha-button>
</div>
`;
}
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private _toggleMasked(): void {
this._showMasked = !this._showMasked;
}
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.container {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-copy-textfield": HaCopyTextfield;
}
}
-1
View File
@@ -80,7 +80,6 @@ export class HaFormString extends LitElement implements HaFormElement {
if (!this.isPassword) return nothing;
return html`
<ha-icon-button
toggles
.label=${this.localize?.(
`${this.localizeBaseKey}.${
this.unmaskedPassword ? "hide_password" : "show_password"
-1
View File
@@ -132,7 +132,6 @@ export class HaPasswordField extends LitElement {
@change=${this._handleChangeEvent}
></ha-textfield>
<ha-icon-button
toggles
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
@@ -95,7 +95,6 @@ export class HaTextSelector extends LitElement {
></ha-textfield>
${this.selector.text?.type === "password"
? html`<ha-icon-button
toggles
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
+198 -51
View File
@@ -1,4 +1,5 @@
import "@material/mwc-button/mwc-button";
import "@shoelace-style/shoelace/dist/components/skeleton/skeleton";
import {
mdiBell,
mdiCalendar,
@@ -49,6 +50,10 @@ import "./ha-sortable";
import "./ha-svg-icon";
import "./user/ha-user-badge";
import { preventDefault } from "../common/dom/prevent_default";
import {
saveSidebarPreferences,
subscribeSidebarPreferences,
} from "../data/sidebar";
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
@@ -207,30 +212,40 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _unsubPersistentNotifications: UnsubscribeFunc | undefined;
@storage({
key: "sidebarPanelOrder",
state: true,
subscribe: true,
})
private _panelOrder: string[] = [];
@storage({ key: "sidebarPanelOrder", state: true, subscribe: true })
private _devicePanelOrder?: string[];
@storage({
key: "sidebarHiddenPanels",
state: true,
subscribe: true,
})
private _hiddenPanels: string[] = [];
@storage({ key: "sidebarHiddenPanels", state: true, subscribe: true })
private _deviceHiddenPanels?: string[];
@state()
private _userPanelOrder: string[] = [];
@state()
private _userHiddenPanels: string[] = [];
@state()
private _loadingUserPreferences = true;
public hassSubscribe(): UnsubscribeFunc[] {
return this.hass.user?.is_admin
? [
subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
this._issuesCount = repairs.issues.filter(
(issue) => !issue.ignored
).length;
}),
]
: [];
const subscribeFunctions = [
subscribeSidebarPreferences(this.hass, (sidebar) => {
this._userPanelOrder = sidebar?.panelOrder || [];
this._userHiddenPanels = sidebar?.hiddenPanels || [];
this._loadingUserPreferences = false;
}),
];
if (this.hass.user?.is_admin) {
subscribeFunctions.push(
subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
this._issuesCount = repairs.issues.filter(
(issue) => !issue.ignored
).length;
})
);
}
return subscribeFunctions;
}
protected render() {
@@ -260,8 +275,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") ||
changedProps.has("_notifications") ||
changedProps.has("_hiddenPanels") ||
changedProps.has("_panelOrder")
changedProps.has("_devicePanelOrder") ||
changedProps.has("_deviceHiddenPanels") ||
changedProps.has("_userPanelOrder") ||
changedProps.has("_userHiddenPanels")
) {
return true;
}
@@ -381,12 +398,51 @@ class HaSidebar extends SubscribeMixin(LitElement) {
</div>`;
}
private _getPanelPreferencesMemoized = memoizeOne(
(
userPanelOrder: string[],
userHiddenPanels: string[],
userPreferencesLoading: boolean,
devicePanelOrder?: string[],
deviceHiddenPanels?: string[]
): { panelOrder: string[]; hiddenPanels: string[]; loading: boolean } => {
let panelOrder = userPanelOrder ?? [];
let hiddenPanels = userHiddenPanels ?? [];
let loading = userPreferencesLoading;
if (devicePanelOrder || deviceHiddenPanels) {
panelOrder = devicePanelOrder ?? [];
hiddenPanels = deviceHiddenPanels ?? [];
loading = false;
}
return {
panelOrder,
hiddenPanels,
loading,
};
}
);
private _getPanelPreferences() {
return this._getPanelPreferencesMemoized(
this._userPanelOrder,
this._userHiddenPanels,
this._loadingUserPreferences,
this._devicePanelOrder,
this._deviceHiddenPanels
);
}
private _renderAllPanels() {
const { panelOrder, hiddenPanels, loading } = this._getPanelPreferences();
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
panelOrder,
hiddenPanels,
this.hass.locale
);
@@ -407,12 +463,21 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@keydown=${this._listboxKeydown}
@iron-activate=${preventDefault}
>
${this.editMode
? this._renderPanelsEdit(beforeSpacer)
: this._renderPanels(beforeSpacer)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer)}
${this._renderExternalConfiguration()}
${loading ? html`
<div class="loading">
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
</div>
` : html`
${this.editMode
? this._renderPanelsEdit(beforeSpacer)
: this._renderPanels(beforeSpacer)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer)}
${this._renderExternalConfiguration()}
`}
</paper-listbox>
`;
}
@@ -474,23 +539,49 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`;
}
private async _setPanelOrder(panelOrder: string[]) {
if (this._devicePanelOrder || this._deviceHiddenPanels) {
this._devicePanelOrder = [...panelOrder];
} else {
this._userPanelOrder = [...panelOrder];
await saveSidebarPreferences(this.hass, {
panelOrder: panelOrder,
hiddenPanels: this._userHiddenPanels,
});
}
}
private async _setHiddenPanels(hiddenPanels: string[]) {
if (this._devicePanelOrder || this._deviceHiddenPanels) {
this._deviceHiddenPanels = hiddenPanels;
} else {
this._userHiddenPanels = hiddenPanels;
await saveSidebarPreferences(this.hass, {
panelOrder: this._userPanelOrder,
hiddenPanels: hiddenPanels,
});
}
}
private _panelMoved(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const { panelOrder, hiddenPanels } = this._getPanelPreferences();
const [beforeSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
panelOrder,
hiddenPanels!,
this.hass.locale
);
const panelOrder = beforeSpacer.map((panel) => panel.url_path);
const panel = panelOrder.splice(oldIndex, 1)[0];
panelOrder.splice(newIndex, 0, panel);
const panelOrderNew = beforeSpacer.map((panel) => panel.url_path);
const panel = panelOrderNew.splice(oldIndex, 1)[0];
panelOrderNew.splice(newIndex, 0, panel);
this._panelOrder = panelOrder;
this._setPanelOrder(panelOrderNew);
}
private _renderPanelsEdit(beforeSpacer: PanelInfo[]) {
@@ -507,8 +598,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _renderHiddenPanels() {
return html`${this._hiddenPanels.length
? html`${this._hiddenPanels.map((url) => {
const { hiddenPanels } = this._getPanelPreferences();
return html`${hiddenPanels.length
? html`${hiddenPanels.map((url) => {
const panel = this.hass.panels[url];
if (!panel) {
return "";
@@ -690,9 +783,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _handleExternalAppConfiguration(ev: Event) {
ev.preventDefault();
this.hass.auth.external!.fireMessage({
type: "config_screen/show",
});
this.hass.auth.external!.fireMessage({ type: "config_screen/show" });
}
private get _tooltip() {
@@ -730,21 +821,25 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private async _hidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
if (this._hiddenPanels.includes(panel)) {
if ((this._deviceHiddenPanels || this._userHiddenPanels).includes(panel)) {
return;
}
const { panelOrder, hiddenPanels } = this._getPanelPreferences();
// Make a copy for Memoize
this._hiddenPanels = [...this._hiddenPanels, panel];
this._setHiddenPanels([...hiddenPanels, panel]);
// Remove it from the panel order
this._panelOrder = this._panelOrder.filter((order) => order !== panel);
this._setPanelOrder(panelOrder.filter((order) => order !== panel));
}
private async _unhidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
this._hiddenPanels = this._hiddenPanels.filter(
(hidden) => hidden !== panel
);
const { hiddenPanels } = this._getPanelPreferences();
this._setHiddenPanels(hiddenPanels.filter((hidden) => hidden !== panel));
}
private _itemMouseEnter(ev: MouseEvent) {
@@ -784,9 +879,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this._hideTooltip();
}
@eventOptions({
passive: true,
})
@eventOptions({ passive: true })
private _listboxScroll() {
// On keypresses on the listbox, we're going to ignore scroll events
// for 100ms so that if pressing down arrow scrolls the sidebar, the tooltip
@@ -1117,6 +1210,60 @@ class HaSidebar extends SubscribeMixin(LitElement) {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));
}
@keyframes skeletonAnimate {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes contentAnimate {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.loading {
opacity: 0;
animation-name: skeletonAnimate;
animation-duration: 2000ms;
animation-delay: 0;
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation-timing-function: ease-out;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
sl-skeleton {
--border-radius: 8px;
height: 24px;
--color: var(--outline-color);
--sheen-color: var(--outline-hover-color);
}
sl-skeleton:nth-child(2) {
width: 70%;
}
sl-skeleton:nth-child(3) {
width: 30%;
}
sl-skeleton:nth-child(4) {
width: 90%;
}
`,
];
}
+1
View File
@@ -581,6 +581,7 @@ export class HaMap extends ReactiveElement {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: 40,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
+1
View File
@@ -73,6 +73,7 @@ export interface CloudWebhook {
interface CloudLoginBase {
hass: HomeAssistant;
email: string;
check_connection?: boolean;
}
export interface CloudLoginPassword extends CloudLoginBase {
+33
View File
@@ -0,0 +1,33 @@
import type { HomeAssistant } from "../types";
import {
fetchFrontendUserData,
saveFrontendUserData,
subscribeFrontendUserData,
} from "./frontend";
export const SIDEBAR_PREFERENCES_KEY = "sidebar";
export interface SidebarPreferences {
panelOrder?: string[];
hiddenPanels?: string[];
}
declare global {
interface FrontendUserData {
sidebar?: SidebarPreferences;
}
}
export const fetchSidebarPreferences = (hass: HomeAssistant) =>
fetchFrontendUserData(hass.connection, SIDEBAR_PREFERENCES_KEY);
export const saveSidebarPreferences = (
hass: HomeAssistant,
data: SidebarPreferences
) => saveFrontendUserData(hass.connection, SIDEBAR_PREFERENCES_KEY, data);
export const subscribeSidebarPreferences = (
hass: HomeAssistant,
callback: (sidebar?: SidebarPreferences | null) => void
) =>
subscribeFrontendUserData(hass.connection, SIDEBAR_PREFERENCES_KEY, callback);
+7 -1
View File
@@ -40,8 +40,13 @@ export class DialogEnterCode
@state() private _showClearButton = false;
@state() private _narrow = false;
public async showDialog(dialogParams: EnterCodeDialogParams): Promise<void> {
this._dialogParams = dialogParams;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
await this.updateComplete;
}
@@ -96,7 +101,7 @@ export class DialogEnterCode
>
<ha-textfield
class="input"
dialogInitialFocus
?dialogInitialFocus=${!this._narrow}
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
@@ -134,6 +139,7 @@ export class DialogEnterCode
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
inputmode="numeric"
?dialogInitialFocus=${!this._narrow}
></ha-textfield>
<div class="keypad">
${BUTTONS.map((value) =>
@@ -99,7 +99,12 @@ class MoreInfoSirenAdvancedControls extends LitElement {
this._stateObj.attributes.available_tones
).map(
([toneId, toneName]) => html`
<ha-list-item .value=${toneId}
<ha-list-item
.value=${Array.isArray(
this._stateObj!.attributes.available_tones
)
? toneName
: toneId}
>${toneName}</ha-list-item
>
`
@@ -179,7 +184,7 @@ class MoreInfoSirenAdvancedControls extends LitElement {
await this.hass.callService("siren", "turn_on", {
entity_id: this._stateObj!.entity_id,
tone: this._tone,
volume: this._volume,
volume_level: this._volume,
duration: this._duration,
});
}
+4
View File
@@ -251,6 +251,7 @@ export class QuickBar extends LitElement {
<mwc-list>
${this._opened
? html`<lit-virtualizer
tabindex="-1"
scroller
@keydown=${this._handleListItemKeyDown}
@rangechange=${this._handleRangeChanged}
@@ -326,6 +327,7 @@ export class QuickBar extends LitElement {
.twoline=${Boolean(item.area)}
.item=${item}
index=${ifDefined(index)}
tabindex="0"
>
<span>${item.primaryText}</span>
${item.area
@@ -346,6 +348,7 @@ export class QuickBar extends LitElement {
.item=${item}
index=${ifDefined(index)}
graphic="icon"
tabindex="0"
>
${item.iconPath
? html`
@@ -375,6 +378,7 @@ export class QuickBar extends LitElement {
index=${ifDefined(index)}
class="command-item"
hasMeta
tabindex="0"
>
<span>
<ha-label
@@ -10,6 +10,7 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import { cloudLogin } from "../../../data/cloud";
import { showCloudAlreadyConnectedDialog } from "../../../panels/config/cloud/dialog-cloud-already-connected/show-dialog-cloud-already-connected";
import type { HomeAssistant } from "../../../types";
import {
showAlertDialog,
@@ -25,6 +26,8 @@ export class CloudStepSignin extends LitElement {
@state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
@@ -115,6 +118,7 @@ export class CloudStepSignin extends LitElement {
hass: this.hass,
email: username,
...(code ? { code } : { password }),
check_connection: this._checkConnection,
});
} catch (err: any) {
const errCode = err && err.body && err.body.code;
@@ -139,6 +143,20 @@ export class CloudStepSignin extends LitElement {
}
}
if (errCode === "alreadyconnectederror") {
showCloudAlreadyConnectedDialog(this, {
details: JSON.parse(err.body.message),
logInHereAction: () => {
this._checkConnection = false;
doLogin(username);
},
closeDialog: () => {
this._requestInProgress = false;
},
});
return;
}
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doLogin(username.toLowerCase());
return;
@@ -1,17 +1,13 @@
import { mdiContentCopy, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js";
import { mdiHelpCircle } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -25,6 +21,7 @@ import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { obfuscateUrl } from "../../../../util/url";
import "../../../../components/ha-copy-textfield";
@customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement {
@@ -34,8 +31,6 @@ export class CloudRemotePref extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _unmaskedUrl = false;
protected render() {
if (!this.cloudStatus) {
return nothing;
@@ -139,37 +134,13 @@ export class CloudRemotePref extends LitElement {
)}
</p>
`}
<div class="url-container">
<div class="textfield-container">
<ha-textfield
.value=${this._unmaskedUrl
? `https://${remote_domain}`
: obfuscateUrl(`https://${remote_domain}`)}
readonly
.suffix=${
// reserve some space for the icon.
html`<div style="width: 24px"></div>`
}
></ha-textfield>
<ha-icon-button
class="toggle-unmasked-url"
toggles
.label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedUrl ? "hide" : "show"}_url`
)}
@click=${this._toggleUnmaskedUrl}
.path=${this._unmaskedUrl ? mdiEyeOff : mdiEye}
></ha-icon-button>
</div>
<ha-button
.url=${`https://${remote_domain}`}
@click=${this._copyURL}
unelevated
>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.config.common.copy_link")}
</ha-button>
</div>
<ha-copy-textfield
.hass=${this.hass}
.value=${`https://${remote_domain}`}
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
></ha-copy-textfield>
<ha-expansion-panel
outlined
@@ -234,10 +205,6 @@ export class CloudRemotePref extends LitElement {
});
}
private _toggleUnmaskedUrl(): void {
this._unmaskedUrl = !this._unmaskedUrl;
}
private async _toggleChanged(ev) {
const toggle = ev.target as HaSwitch;
@@ -268,14 +235,6 @@ export class CloudRemotePref extends LitElement {
}
}
private async _copyURL(ev): Promise<void> {
const url = ev.currentTarget.url;
await copyToClipboard(url);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.preparing {
padding: 0 16px 16px;
@@ -335,30 +294,6 @@ export class CloudRemotePref extends LitElement {
display: block;
margin-bottom: 16px;
}
.url-container {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked-url {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
hr {
border: none;
height: 1px;
@@ -0,0 +1,171 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { CloudAlreadyConnectedParams as CloudAlreadyConnectedDialogParams } from "./show-dialog-cloud-already-connected";
import { obfuscateUrl } from "../../../../util/url";
@customElement("dialog-cloud-already-connected")
class DialogCloudAlreadyConnected extends LitElement {
public hass!: HomeAssistant;
@state() private _params?: CloudAlreadyConnectedDialogParams;
@state() private _obfuscateIp = true;
public showDialog(params: CloudAlreadyConnectedDialogParams) {
this._params = params;
}
public closeDialog() {
this._params?.closeDialog();
this._params = undefined;
this._obfuscateIp = true;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const { details } = this._params;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.heading"
)
)}
>
<div class="intro">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.description"
)}
</span>
<b>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.other_home_assistant"
)}
</b>
</div>
<div class="instance-details">
<div class="instance-detail">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.ip_address"
)}:
</span>
<div class="obfuscated">
<span>
${this._obfuscateIp
? obfuscateUrl(details.remote_ip_address)
: details.remote_ip_address}
</span>
<ha-icon-button
class="toggle-unmasked-url"
.label=${this.hass.localize(
`ui.panel.config.cloud.dialog_already_connected.obfuscated_ip.${this._obfuscateIp ? "hide" : "show"}`
)}
@click=${this._toggleObfuscateIp}
.path=${this._obfuscateIp ? mdiEye : mdiEyeOff}
></ha-icon-button>
</div>
</div>
<div class="instance-detail">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.connected_at"
)}:
</span>
<span>
${formatDateTime(
new Date(details.connected_at),
this.hass.locale,
this.hass.config
)}
</span>
</div>
</div>
<ha-alert
alert-type="info"
.title=${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.info_backups.title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.info_backups.description"
)}
</ha-alert>
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._logInHere} slot="primaryAction">
${this.hass!.localize(
"ui.panel.config.cloud.dialog_already_connected.login_here"
)}
</ha-button>
</ha-dialog>
`;
}
private _toggleObfuscateIp() {
this._obfuscateIp = !this._obfuscateIp;
}
private _logInHere() {
this._params?.logInHereAction();
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 535px;
}
.intro b {
display: block;
margin-top: 16px;
}
.instance-details {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.instance-detail {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.obfuscated {
align-items: center;
display: flex;
flex-direction: row;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-already-connected": DialogCloudAlreadyConnected;
}
}
@@ -0,0 +1,21 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface CloudAlreadyConnectedParams {
details: {
remote_ip_address: string;
connected_at: string;
};
logInHereAction: () => void;
closeDialog: () => void;
}
export const showCloudAlreadyConnectedDialog = (
element: HTMLElement,
webhookDialogParams: CloudAlreadyConnectedParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-cloud-already-connected",
dialogImport: () => import("./dialog-cloud-already-connected"),
dialogParams: webhookDialogParams,
});
};
@@ -1,27 +1,23 @@
import "@material/mwc-button";
import { mdiContentCopy, mdiOpenInNew } from "@mdi/js";
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { query, state } from "lit/decorators";
import { state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-textfield";
import type { HaTextField } from "../../../../components/ha-textfield";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showToast } from "../../../../util/toast";
import type { WebhookDialogParams } from "./show-dialog-manage-cloudhook";
import "../../../../components/ha-copy-textfield";
export class DialogManageCloudhook extends LitElement {
protected hass?: HomeAssistant;
@state() private _params?: WebhookDialogParams;
@query("ha-textfield") _input!: HaTextField;
public showDialog(params: WebhookDialogParams) {
this._params = params;
}
@@ -82,21 +78,12 @@ export class DialogManageCloudhook extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
<ha-textfield
.label=${this.hass!.localize(
"ui.panel.config.cloud.dialog_cloudhook.public_url"
)}
<ha-copy-textfield
.hass=${this.hass}
.value=${cloudhook.cloudhook_url}
iconTrailing
readOnly
@click=${this._focusInput}
>
<ha-icon-button
@click=${this._copyUrl}
slot="trailingIcon"
.path=${mdiContentCopy}
></ha-icon-button>
</ha-textfield>
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
></ha-copy-textfield>
</div>
<a
@@ -137,24 +124,6 @@ export class DialogManageCloudhook extends LitElement {
}
}
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private async _copyUrl(ev): Promise<void> {
if (!this.hass) return;
ev.stopPropagation();
const inputElement = ev.target.parentElement as HaTextField;
inputElement.select();
const url = this.hass.hassUrl(inputElement.value);
await copyToClipboard(url);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -163,13 +132,6 @@ export class DialogManageCloudhook extends LitElement {
ha-dialog {
width: 650px;
}
ha-textfield {
display: block;
}
ha-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
button.link {
color: var(--primary-color);
text-decoration: none;
@@ -28,6 +28,7 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@@ -47,6 +48,8 @@ export class CloudLogin extends LitElement {
@state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
@@ -244,6 +247,7 @@ export class CloudLogin extends LitElement {
hass: this.hass,
email: username,
...(code ? { code } : { password }),
check_connection: this._checkConnection,
});
this.email = "";
this._password = "";
@@ -283,6 +287,21 @@ export class CloudLogin extends LitElement {
return;
}
}
if (errCode === "alreadyconnectederror") {
showCloudAlreadyConnectedDialog(this, {
details: JSON.parse(err.body.message),
logInHereAction: () => {
this._checkConnection = false;
doLogin(username);
},
closeDialog: () => {
this._requestInProgress = false;
this.email = "";
this._password = "";
},
});
return;
}
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
@@ -185,6 +185,14 @@ class AddIntegrationDialog extends LitElement {
const yamlIntegrations: IntegrationListItem[] = [];
Object.entries(i).forEach(([domain, integration]) => {
if (
"integration_type" in integration &&
integration.integration_type === "hardware"
) {
// Ignore hardware integrations, they cannot be added via UI
return;
}
if (
"integration_type" in integration &&
(integration.config_flow ||
@@ -153,7 +153,6 @@ class ConfigUrlForm extends LitElement {
? html`
<ha-icon-button
class="toggle-unmasked-url"
toggles
.label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedExternalUrl ? "hide" : "show"}_url`
)}
@@ -254,7 +253,6 @@ class ConfigUrlForm extends LitElement {
? html`
<ha-icon-button
class="toggle-unmasked-url"
toggles
.label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedInternalUrl ? "hide" : "show"}_url`
)}
@@ -0,0 +1,134 @@
import { mdiRestore, mdiPlus, mdiMinus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import { COUNTER_ACTIONS, type CounterActionsCardFeatureConfig } from "./types";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-button";
export const supportsCounterActionsCardFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "counter";
};
interface CounterButton {
translationKey: string;
icon: string;
serviceName: string;
disabled: boolean;
}
export const COUNTER_ACTIONS_BUTTON: Record<
string,
(stateObj: HassEntity) => CounterButton
> = {
increment: (stateObj) => ({
translationKey: "increment",
icon: mdiPlus,
serviceName: "increment",
disabled: parseInt(stateObj.state) === stateObj.attributes.maximum,
}),
reset: () => ({
translationKey: "reset",
icon: mdiRestore,
serviceName: "reset",
disabled: false,
}),
decrement: (stateObj) => ({
translationKey: "decrement",
icon: mdiMinus,
serviceName: "decrement",
disabled: parseInt(stateObj.state) === stateObj.attributes.minimum,
}),
};
@customElement("hui-counter-actions-card-feature")
class HuiCounterActionsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() private _config?: CounterActionsCardFeatureConfig;
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import(
"../editor/config-elements/hui-counter-actions-card-feature-editor"
);
return document.createElement("hui-counter-actions-card-feature-editor");
}
static getStubConfig(): CounterActionsCardFeatureConfig {
return {
type: "counter-actions",
actions: COUNTER_ACTIONS.map((action) => action),
};
}
public setConfig(config: CounterActionsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.stateObj ||
!supportsCounterActionsCardFeature(this.stateObj)
) {
return null;
}
return html`
<ha-control-button-group>
${this._config?.actions
?.filter((action) => COUNTER_ACTIONS.includes(action))
.map((action) => {
const button = COUNTER_ACTIONS_BUTTON[action](this.stateObj!);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.card.counter.actions.${button.translationKey}`
)}
@click=${this._onActionTap}
.disabled=${button.disabled ||
this.stateObj?.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
private _onActionTap(ev): void {
ev.stopPropagation();
const entry = (ev.target! as any).entry as CounterButton;
this.hass!.callService("counter", entry.serviceName, {
entity_id: this.stateObj!.entity_id,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-counter-actions-card-feature": HuiCounterActionsCardFeature;
}
}
@@ -0,0 +1,111 @@
import { mdiPowerOff, mdiPower } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type { ToggleCardFeatureConfig } from "./types";
import { showToast } from "../../../util/toast";
export const supportsToggleCardFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return ["switch", "input_boolean"].includes(domain);
};
@customElement("hui-toggle-card-feature")
class HuiToggleCardFeature extends LitElement implements LovelaceCardFeature {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() private _config?: ToggleCardFeatureConfig;
static getStubConfig(): ToggleCardFeatureConfig {
return {
type: "toggle",
};
}
public setConfig(config: ToggleCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.stateObj ||
!supportsToggleCardFeature(this.stateObj)
) {
return null;
}
const color = stateColorCss(this.stateObj);
const options = ["on", "off"].map<ControlSelectOption>((entityState) => ({
value: entityState,
label: this.hass!.formatEntityState(this.stateObj!, entityState),
path: entityState === "on" ? mdiPower : mdiPowerOff,
}));
return html`
<ha-control-select
.options=${options}
.value=${this.stateObj.state}
@value-changed=${this._valueChanged}
hide-label
.ariaLabel=${this.hass.localize("ui.card.humidifier.state")}
style=${styleMap({
"--control-select-color": color,
})}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
private async _valueChanged(ev: CustomEvent) {
const newState = (ev.detail as any).value;
if (
newState === this.stateObj!.state &&
!this.stateObj!.attributes.assumed_state
)
return;
const service = newState === "on" ? "turn_on" : "turn_off";
const domain = computeDomain(this.stateObj!.entity_id);
try {
await this.hass!.callService(domain, service, {
entity_id: this.stateObj!.entity_id,
});
} catch (_err) {
showToast(this, {
message: this.hass!.localize("ui.notification_toast.action_failed", {
service: domain + "." + service,
}),
duration: 5000,
dismissable: true,
});
}
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-toggle-card-feature": HuiToggleCardFeature;
}
}
@@ -83,6 +83,15 @@ export interface ClimatePresetModesCardFeatureConfig {
preset_modes?: string[];
}
export const COUNTER_ACTIONS = ["increment", "reset", "decrement"] as const;
export type CounterActions = (typeof COUNTER_ACTIONS)[number];
export interface CounterActionsCardFeatureConfig {
type: "counter-actions";
actions?: CounterActions[];
}
export interface SelectOptionsCardFeatureConfig {
type: "select-options";
options?: string[];
@@ -101,6 +110,10 @@ export interface TargetTemperatureCardFeatureConfig {
type: "target-temperature";
}
export interface ToggleCardFeatureConfig {
type: "toggle";
}
export interface WaterHeaterOperationModesCardFeatureConfig {
type: "water-heater-operation-modes";
operation_modes?: OperationMode[];
@@ -152,6 +165,7 @@ export type LovelaceCardFeatureConfig =
| ClimateSwingHorizontalModesCardFeatureConfig
| ClimateHvacModesCardFeatureConfig
| ClimatePresetModesCardFeatureConfig
| CounterActionsCardFeatureConfig
| CoverOpenCloseCardFeatureConfig
| CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig
@@ -170,6 +184,7 @@ export type LovelaceCardFeatureConfig =
| SelectOptionsCardFeatureConfig
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig;
@@ -291,20 +291,19 @@ export class HuiEnergyUsageGraphCard
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: usage` so it doesn't take up space yet
const firstId = statIds.from_grid?.[0] ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "usage",
data: [],
// @ts-expect-error
order: 0,
});
}
// add empty dataset so compare bars are first
// `stack: usage` so it doesn't take up space yet
datasets.push({
id: "compare-placeholder",
type: "bar",
stack: energyData.statsCompare ? "compare" : "usage",
data: [],
// @ts-expect-error
order: 0,
});
datasets.push(
...this._processDataSet(
energyData.stats,
+27 -11
View File
@@ -107,18 +107,26 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
return html`
${this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
? html`
<ha-alert
.alertType=${(this._errorLevel?.toLowerCase() as
| "error"
| "warning") || "error"}
>
${this._error}
</ha-alert>
`
: nothing}
<ha-card .header=${this._config.title}>
<ha-card
.header=${!this._config.text_only ? this._config.title : undefined}
class=${classMap({
"with-header": !!this._config.title,
"text-only": this._config.text_only ?? false,
})}
>
<ha-markdown
cache
breaks
class=${classMap({
"no-header": !this._config.title,
})}
.content=${this._templateResult?.result}
></ha-markdown>
</ha-card>
@@ -228,11 +236,19 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
margin-bottom: 8px;
}
ha-markdown {
padding: 0 16px 16px;
padding: 16px;
word-wrap: break-word;
}
ha-markdown.no-header {
padding-top: 16px;
.with-header ha-markdown {
padding: 0 16px 16px;
}
.text-only {
background: none;
box-shadow: none;
border: none;
}
.text-only ha-markdown {
padding: 2px 4px;
}
`;
}
+4 -6
View File
@@ -416,15 +416,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
align-items: center;
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.container.horizontal .content {
width: 50%;
}
.vertical {
flex-direction: column;
text-align: center;
@@ -458,9 +455,10 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: 50%;
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: 36px;
padding: 10px;
padding: 0 12px;
padding-inline-start: 0;
}
+1
View File
@@ -336,6 +336,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
export interface MarkdownCardConfig extends LovelaceCardConfig {
type: "markdown";
content: string;
text_only?: boolean;
title?: string;
card_size?: number;
entity_ids?: string | string[];
@@ -23,8 +23,10 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { addCard } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceContainer,
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
@@ -253,14 +255,24 @@ export class HuiCardEditMode extends LitElement {
}
private _duplicateCard(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const { cardIndex, sectionIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const sectionConfig =
sectionIndex !== undefined
? findLovelaceContainer(this.lovelace!.config, containerPath)
: undefined;
const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath,
saveCardConfig: async (config) => {
const newConfig = addCard(this.lovelace!.config, containerPath, config);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
sectionConfig,
isNew: true,
});
}
@@ -278,9 +278,12 @@ export class HuiCardOptions extends LitElement {
const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath,
saveCardConfig: async (config) => {
const newConfig = addCard(this.lovelace!.config, containerPath, config);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
isNew: true,
});
}
@@ -4,6 +4,7 @@ import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-hvac-modes-card-feature";
import "../card-features/hui-climate-preset-modes-card-feature";
import "../card-features/hui-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-tilt-card-feature";
@@ -22,6 +23,7 @@ import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
@@ -39,6 +41,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"climate-swing-horizontal-modes",
"climate-hvac-modes",
"climate-preset-modes",
"counter-actions",
"cover-open-close",
"cover-position",
"cover-tilt-position",
@@ -57,6 +60,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"select-options",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"water-heater-operation-modes",
@@ -3,10 +3,10 @@ import "@material/mwc-tab/mwc-tab";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoize from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
@@ -24,6 +24,7 @@ import {
computeCards,
computeSection,
} from "../../common/generate-lovelace-config";
import { addCard } from "../config-util";
import {
findLovelaceContainer,
parseLovelaceContainerPath,
@@ -241,11 +242,24 @@ export class HuiCreateDialogCard
}
}
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const sectionConfig =
containerPath.length === 2
? findLovelaceContainer(lovelaceConfig, containerPath)
: undefined;
showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path,
lovelaceConfig,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(lovelaceConfig, containerPath, newCardConfig);
await saveConfig(newConfig);
},
cardConfig: config,
sectionConfig,
isNew: true,
});
this.closeDialog();
@@ -13,7 +13,6 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomCardEntry,
isCustomType,
@@ -23,13 +22,12 @@ import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
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-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";
@@ -59,9 +57,7 @@ export class HuiDialogEditCard
@state() private _cardConfig?: LovelaceCardConfig;
@state() private _containerConfig!:
| LovelaceViewConfig
| LovelaceSectionConfig;
@state() private _sectionConfig?: LovelaceSectionConfig;
@state() private _saving = false;
@@ -85,23 +81,10 @@ export class HuiDialogEditCard
this._GUImode = true;
this._guiModeAvailable = true;
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
this._sectionConfig = this._params.sectionConfig;
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
if ("cardConfig" in params) {
this._cardConfig = params.cardConfig;
this._dirty = true;
} else {
this._cardConfig = this._containerConfig.cards?.[params.cardIndex];
}
this._cardConfig = params.cardConfig;
this._dirty = Boolean(this._params.isNew);
this.large = false;
if (this._cardConfig && !Object.isFrozen(this._cardConfig)) {
@@ -156,12 +139,12 @@ export class HuiDialogEditCard
};
protected render() {
if (!this._params) {
if (!this._params || !this._cardConfig) {
return nothing;
}
let heading: string;
if (this._cardConfig && this._cardConfig.type) {
if (this._cardConfig.type) {
let cardName: string | undefined;
if (isCustomType(this._cardConfig.type)) {
// prettier-ignore
@@ -181,13 +164,6 @@ export class HuiDialogEditCard
"ui.panel.lovelace.editor.edit_card.typed_header",
{ type: cardName }
);
} else if (!this._cardConfig) {
heading = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.pick_card_view_title",
{ name: this._containerConfig.title }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_card.pick_card");
} else {
heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.header"
@@ -230,10 +206,8 @@ export class HuiDialogEditCard
<div class="content">
<div class="element-editor">
<hui-card-element-editor
.showVisibilityTab=${this._cardConfig?.type !== "conditional"}
.sectionConfig=${this._isInSection
? this._containerConfig
: undefined}
.showVisibilityTab=${this._cardConfig.type !== "conditional"}
.sectionConfig=${this._sectionConfig}
.hass=${this.hass}
.lovelace=${this._params.lovelaceConfig}
.value=${this._cardConfig}
@@ -244,7 +218,7 @@ export class HuiDialogEditCard
></hui-card-element-editor>
</div>
<div class="element-preview">
${this._isInSection
${this._sectionConfig
? html`
<hui-section
.hass=${this.hass}
@@ -345,14 +319,10 @@ export class HuiDialogEditCard
this._cardEditorEl?.focusYamlEditor();
}
private get _isInSection() {
return this._params!.path.length === 2;
}
private _cardConfigInSection = memoizeOne(
(cardConfig?: LovelaceCardConfig) => {
(cardConfig: LovelaceCardConfig) => {
const { cards, title, ...containerConfig } = this
._containerConfig as LovelaceSectionConfig;
._sectionConfig as LovelaceSectionConfig;
return {
...containerConfig,
@@ -411,20 +381,18 @@ export class HuiDialogEditCard
return;
}
this._saving = true;
const path = this._params!.path;
await this._params!.saveConfig(
"cardConfig" in this._params!
? addCard(this._params!.lovelaceConfig, path, this._cardConfig!)
: replaceCard(
this._params!.lovelaceConfig,
[...path, this._params!.cardIndex],
this._cardConfig!
)
);
this._saving = false;
this._dirty = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
try {
await this._params!.saveCardConfig(this._cardConfig!);
this._saving = false;
this._dirty = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
} catch (err: any) {
showToast(this, {
message: err.message,
});
this._saving = false;
}
}
static get styles(): CSSResultGroup {
@@ -1,20 +1,15 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
export type EditCardDialogParams = {
export interface EditCardDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelaceContainerPath;
} & (
| {
cardIndex: number;
}
| {
cardConfig: LovelaceCardConfig;
}
);
saveCardConfig: (config: LovelaceCardConfig) => void;
cardConfig: LovelaceCardConfig;
sectionConfig?: LovelaceSectionConfig;
isNew?: boolean;
}
export const importEditCardDialog = () => import("./hui-dialog-edit-card");
@@ -24,6 +24,7 @@ import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-cli
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature";
import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-features/hui-climate-swing-horizontal-modes-card-feature";
import { supportsCounterActionsCardFeature } from "../../card-features/hui-counter-actions-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
@@ -42,6 +43,7 @@ import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
@@ -58,6 +60,7 @@ const UI_FEATURE_TYPES = [
"climate-preset-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close",
"cover-position",
"cover-tilt-position",
@@ -76,6 +79,7 @@ const UI_FEATURE_TYPES = [
"select-options",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"water-heater-operation-modes",
@@ -90,6 +94,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"climate-preset-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"fan-preset-modes",
"humidifier-modes",
"lawn-mower-commands",
@@ -111,6 +116,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
supportsClimateSwingHorizontalModesCardFeature,
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
"climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position": supportsCoverPositionCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
@@ -129,6 +135,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"select-options": supportsSelectOptionsCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
@@ -0,0 +1,91 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import {
COUNTER_ACTIONS,
type LovelaceCardFeatureContext,
type CounterActionsCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-counter-actions-card-feature-editor")
export class HuiCounterActionsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CounterActionsCardFeatureConfig;
public setConfig(config: CounterActionsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "actions",
selector: {
select: {
multiple: true,
mode: "list",
reorder: true,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: `${localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions.${action}`
)}`,
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-counter-actions-card-feature-editor": HuiCounterActionsCardFeatureEditor;
}
}
@@ -1,29 +1,28 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { MarkdownCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
text_only: optional(boolean()),
title: optional(string()),
content: string(),
theme: optional(string()),
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "content", required: true, selector: { template: {} } },
{ name: "theme", selector: { theme: {} } },
] as const;
@customElement("hui-markdown-card-editor")
export class HuiMarkdownCardEditor
extends LitElement
@@ -38,16 +37,51 @@ export class HuiMarkdownCardEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc, text_only: boolean) =>
[
{
name: "style",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["card", "text-only"].map((style) => ({
label: localize(
`ui.panel.lovelace.editor.card.markdown.style_options.${style}`
),
value: style,
})),
},
},
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{ name: "content", required: true, selector: { template: {} } },
] as const satisfies HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = {
...this._config,
style: this._config.text_only ? "text-only" : "card",
};
const schema = this._schema(
this.hass.localize,
this._config.text_only || false
);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
@@ -55,17 +89,23 @@ export class HuiMarkdownCardEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = { ...ev.detail.value };
if (config.style === "text-only") {
config.text_only = true;
} else {
delete config.text_only;
}
delete config.style;
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "theme":
return `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "style":
case "content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.markdown.${schema.name}`
@@ -21,6 +21,7 @@ import {
optional,
string,
} from "superstruct";
import { keyed } from "lit/directives/keyed";
import type {
HaFormSchema,
SchemaUnion,
@@ -84,6 +85,8 @@ export class HuiStackCardEditor
@state() protected _guiModeAvailable? = true;
protected _keys = new WeakMap<LovelaceCardConfig, string>();
protected _schema: readonly HaFormSchema[] = SCHEMA;
@query("hui-card-element-editor")
@@ -199,14 +202,16 @@ export class HuiStackCardEditor
@click=${this._handleDeleteCard}
></ha-icon-button>
</div>
<hui-card-element-editor
.hass=${this.hass}
.value=${this._config.cards[selected]}
.lovelace=${this.lovelace}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-card-element-editor>
${keyed(
this._getKey(this._config.cards[selected]),
html`<hui-card-element-editor
.hass=${this.hass}
.value=${this._config.cards[selected]}
.lovelace=${this.lovelace}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-card-element-editor>`
)}
`
: html`
<hui-card-picker
@@ -220,6 +225,14 @@ export class HuiStackCardEditor
`;
}
private _getKey(card: LovelaceCardConfig) {
if (!this._keys.has(card)) {
this._keys.set(card, Math.random().toString());
}
return this._keys.get(card)!;
}
protected _handleSelectedCard(ev) {
if (ev.target.id === "add-card") {
this._selectedCard = this._config!.cards.length;
@@ -236,7 +249,10 @@ export class HuiStackCardEditor
return;
}
const cards = [...this._config.cards];
cards[this._selectedCard] = ev.detail.config as LovelaceCardConfig;
const key = this._getKey(cards[this._selectedCard]);
const newCard = ev.detail.config as LovelaceCardConfig;
cards[this._selectedCard] = newCard;
this._keys.set(newCard, key);
this._config = { ...this._config, cards };
this._guiModeAvailable = ev.detail.guiModeAvailable;
fireEvent(this, "config-changed", { config: this._config });
+16 -3
View File
@@ -21,6 +21,7 @@ import {
import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceCard } from "../editor/config-util";
import { performDeleteCard } from "../editor/delete-card";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import { generateLovelaceSectionStrategy } from "../strategies/get-strategy";
@@ -253,11 +254,23 @@ export class HuiSection extends ReactiveElement {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex, this.index],
cardIndex,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.viewIndex, this.index, cardIndex],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
sectionConfig,
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
+15 -3
View File
@@ -21,6 +21,7 @@ import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceCard } from "../editor/config-util";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -270,11 +271,22 @@ export class HUIView extends ReactiveElement {
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
cardIndex,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.index, cardIndex],
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
+123 -11
View File
@@ -1,10 +1,11 @@
import "@material/mwc-button";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-card";
import "../../components/ha-button";
import "../../components/ha-expansion-panel";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import { isExternal } from "../../data/external";
@@ -27,6 +28,9 @@ import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
import { storage } from "../../common/decorators/storage";
import type { HaSwitch } from "../../components/ha-switch";
import { fetchSidebarPreferences } from "../../data/sidebar";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@@ -38,6 +42,20 @@ class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public route!: Route;
@storage({
key: "sidebarPanelOrder",
state: true,
subscribe: true,
})
private _devicePanelOrder?: string[];
@storage({
key: "sidebarHiddenPanels",
state: true,
subscribe: true,
})
private _deviceHiddenPanels?: string[];
private _unsubCoreData?: UnsubscribeFunc;
private _getCoreData() {
@@ -71,6 +89,9 @@ class HaProfileSectionGeneral extends LitElement {
}
protected render(): TemplateResult {
const deviceSidebarSettingsEnabled =
!!this._devicePanelOrder || !!this._deviceHiddenPanels;
return html`
<hass-tabs-subpage
main-page
@@ -91,9 +112,9 @@ class HaProfileSectionGeneral extends LitElement {
: ""}
</div>
<div class="card-actions">
<mwc-button class="warning" @click=${this._handleLogOut}>
<ha-button class="warning" @click=${this._handleLogOut}>
${this.hass.localize("ui.panel.profile.logout")}
</mwc-button>
</ha-button>
</div>
</ha-card>
<ha-card
@@ -128,6 +149,29 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}
</span>
<span
slot="description"
class=${deviceSidebarSettingsEnabled ? "device-info" : ""}
>
${this.hass.localize(
`ui.panel.profile.customize_sidebar.${!deviceSidebarSettingsEnabled ? "description" : "overwritten_by_device"}`
)}
</span>
<ha-button
.disabled=${deviceSidebarSettingsEnabled}
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-settings-row>
${this.hass.user!.is_admin
? html`
<ha-advanced-mode-row
@@ -159,20 +203,48 @@ class HaProfileSectionGeneral extends LitElement {
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
"ui.panel.profile.customize_sidebar.device_specific_header"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
"ui.panel.profile.customize_sidebar.device_description"
)}
</span>
<mwc-button @click=${this._customizeSidebar}>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</mwc-button>
<ha-switch
.checked=${deviceSidebarSettingsEnabled}
@change=${this._toggleDeviceSidebarPreferences}
></ha-switch>
</ha-settings-row>
${deviceSidebarSettingsEnabled
? html`
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.profile.customize_sidebar.device_specific_header"
)}
expanded
>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}
</span>
<ha-button @click=${this._customizeSidebar}>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-settings-row>
</ha-expansion-panel>
`
: nothing}
${this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
@@ -215,6 +287,38 @@ class HaProfileSectionGeneral extends LitElement {
fireEvent(this, "hass-edit-sidebar", { editMode: true });
}
private async _toggleDeviceSidebarPreferences(ev: Event) {
const switchElement = ev.target as HaSwitch;
const enabled = switchElement.checked;
if (!enabled) {
if (this._devicePanelOrder || this._deviceHiddenPanels) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.profile.customize_sidebar.delete_device_preferences_header"
),
text: this.hass.localize(
"ui.panel.profile.customize_sidebar.delete_device_preferences_description"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (confirm) {
this._devicePanelOrder = undefined;
this._deviceHiddenPanels = undefined;
} else {
// revert switch
switchElement.click();
}
}
} else {
const sidebarPreferences = await fetchSidebarPreferences(this.hass);
this._devicePanelOrder = sidebarPreferences?.panelOrder ?? [];
this._deviceHiddenPanels = sidebarPreferences?.hiddenPanels ?? [];
}
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
@@ -251,6 +355,14 @@ class HaProfileSectionGeneral extends LitElement {
text-align: center;
color: var(--secondary-text-color);
}
ha-expansion-panel {
margin: 0 8px 8px;
}
.device-info {
color: var(--warning-color);
}
`,
];
}
@@ -47,7 +47,12 @@ export class HaStateControlAlarmControlPanelModes extends LitElement {
}
private async _setMode(mode: AlarmMode) {
setProtectedAlarmControlPanelMode(this, this.hass!, this.stateObj!, mode);
await setProtectedAlarmControlPanelMode(
this,
this.hass!,
this.stateObj!,
mode
);
}
private async _valueChanged(ev: CustomEvent) {
+5 -2
View File
@@ -286,7 +286,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
promiseTimeout(5000, this.hass?.connection.ping()).catch(() => {
// If the backend is busy, or the connection is latent,
// it can take more than 10 seconds for the ping to return.
// We give it a 15 second timeout to be safe.
promiseTimeout(15000, this.hass?.connection.ping()).catch(() => {
if (!this.hass?.connected) {
return;
}
@@ -296,7 +299,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this.hass?.connection.reconnect(true);
});
}
}, 10000);
}, 30000);
}
protected hassReconnected() {
+46 -4
View File
@@ -370,7 +370,10 @@
"name": "Name",
"optional": "optional",
"default": "Default",
"dont_save": "Don't save"
"dont_save": "Don't save",
"copy": "Copy",
"show": "Show",
"hide": "Hide"
},
"components": {
"selectors": {
@@ -4727,6 +4730,23 @@
"fingerprint": "Certificate fingerprint:",
"close": "Close"
},
"dialog_already_connected": {
"heading": "Account linked to other Home Assistant",
"description": "We noticed that another instance is currently connected to your Home Assistant Cloud account. Your Home Assistant Cloud account can only be signed into one Home Assistant instance at a time. If you log in here, the other instance will be disconnected along with its Cloud services.",
"other_home_assistant": "Other Home Assistant",
"ip_address": "IP Address",
"connected_at": "Connected at",
"obfuscated_ip": {
"show": "Show IP address",
"hide": "Hide IP address"
},
"info_backups": {
"title": "Home Assistant Cloud backups",
"description": "Your Cloud backup may be overwritten if you proceed. We strongly recommend downloading your current backup from your Nabu Casa account page before continuing."
},
"close": "Close",
"login_here": "Log in here"
},
"dialog_cloudhook": {
"webhook_for": "Webhook for {name}",
"managed_by_integration": "This webhook is managed by an integration and cannot be disabled.",
@@ -6998,7 +7018,8 @@
"suggested_cards": "Suggested cards",
"other_cards": "Other cards",
"custom_cards": "Custom cards",
"features": "Features"
"features": "Features",
"actions": "Actions"
},
"heading": {
"name": "Heading",
@@ -7045,6 +7066,11 @@
"markdown": {
"name": "Markdown",
"content": "Content",
"style": "Style",
"style_options": {
"card": "Card",
"text-only": "Text only"
},
"description": "The Markdown card is used to render Markdown."
},
"media-control": {
@@ -7312,6 +7338,14 @@
"customize_modes": "Customize preset modes",
"preset_modes": "Preset modes"
},
"counter-actions": {
"label": "Counter actions",
"actions": {
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
}
},
"fan-preset-modes": {
"label": "Fan preset modes",
"style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]",
@@ -7340,6 +7374,9 @@
"options": "Options",
"customize_options": "Customize options"
},
"toggle": {
"label": "Toggle"
},
"numeric-input": {
"label": "Numeric input",
"style": "Style",
@@ -7489,9 +7526,14 @@
"description": "This will hide the sidebar by default, similar to the mobile experience."
},
"customize_sidebar": {
"header": "Change the order and hide items from the sidebar",
"header": "Edit sidebar",
"description": "You can also press and hold the header of the sidebar to activate edit mode.",
"button": "Edit"
"overwritten_by_device": "Your user sidebar preferences are overwritten by device-specific preferences.",
"device_description": "Enable specific sidebar preferences for this device.",
"button": "Edit",
"delete_device_preferences_header": "Delete device preferences",
"delete_device_preferences_description": "This will delete sidebar order and hidden items on this device and will show user-defined preferences again.",
"device_specific_header": "Device-specific sidebar"
},
"vibrate": {
"header": "Vibrate",
@@ -63,4 +63,28 @@ describe("canToggleState", () => {
};
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects group with missing entity", () => {
const stateObj: any = {
entity_id: "group.bla",
state: "on",
attributes: {
entity_id: ["light.non_existing"],
},
};
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects group with off state", () => {
const stateObj: any = {
entity_id: "group.bla",
state: "off",
attributes: {
entity_id: ["light.test"],
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
});
@@ -0,0 +1,371 @@
import type {
HassConfig,
HassEntity,
HassEntityBase,
} from "home-assistant-js-websocket";
import { describe, it, expect } from "vitest";
import {
computeAttributeValueDisplay,
computeAttributeNameDisplay,
} from "../../../src/common/entity/compute_attribute_display";
import type { FrontendLocaleData } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
export const localizeMock = (key: string) => {
const translations = {
"state.default.unknown": "Unknown",
"component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.42":
"42",
"component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.attributeValue":
"Localized Attribute Name",
"component.media_player.entity_component.media_player.state_attributes.attribute.state.attributeValue":
"Localized Media Player Attribute Name",
"component.media_player.entity_component._.state_attributes.attribute.state.attributeValue":
"Media Player Attribute Name",
};
return translations[key] || "";
};
export const stateObjMock = {
entity_id: "sensor.test",
attributes: {
device_class: "temperature",
},
} as HassEntityBase;
export const localeMock = {
language: "en",
} as FrontendLocaleData;
export const configMock = {
unit_system: {
temperature: "°C",
},
} as HassConfig;
export const entitiesMock = {
"sensor.test": {
platform: "test_platform",
translation_key: "test_translation_key",
},
"media_player.test": {
platform: "media_player",
},
} as unknown as HomeAssistant["entities"];
describe("computeAttributeValueDisplay", () => {
it("should return unknown state for null value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
null
);
expect(result).toBe("Unknown");
});
it("should return formatted number for numeric value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
42
);
expect(result).toBe("42");
});
it("should return number from formatter", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
device_class: "media_player",
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"volume_level"
);
expect(result).toBe("42%");
});
it("should return formatted date for date string", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"2023-10-10"
);
expect(result).toBe("October 10, 2023");
});
it("should return formatted datetime for timestamp", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"2023-10-10T10:10:10"
);
expect(result).toBe("October 10, 2023 at 10:10:10");
});
it("should return JSON string for object value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
{ key: "value" }
);
expect(result).toBe('{"key":"value"}');
});
it("should return concatenated values for array", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
[1, 2, 3]
);
expect(result).toBe("1, 2, 3");
});
it("should set special unit for weather domain", () => {
const stateObj = {
entity_id: "weather.test",
attributes: {
temperature: 42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"temperature"
);
expect(result).toBe("42 °C");
});
it("should set temperature unit for temperature attribute", () => {
const stateObj = {
entity_id: "sensor.test",
attributes: {
temperature: 42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"temperature"
);
expect(result).toBe("42 °C");
});
it("should return translation from translation key", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Localized Attribute Name");
});
it("should return device class translation", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
device_class: "media_player",
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Localized Media Player Attribute Name");
});
it("should return attribute value translation", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Media Player Attribute Name");
});
it("should return attribute value", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue2"
);
expect(result).toBe("attributeValue2");
});
});
describe("computeAttributeNameDisplay", () => {
it("should return localized name for attribute", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity.light.entity_translation_key.state_attributes.updated_at.name"
) {
return "Updated at";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {
device_class: "light",
},
} as HassEntity;
const entities = {
"light.test": {
translation_key: "entity_translation_key",
platform: "light",
},
} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"updated_at"
);
expect(result).toBe("Updated at");
});
it("should return device class translation", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity_component.light.state_attributes.brightness.name"
) {
return "Brightness";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {
device_class: "light",
},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness"
);
expect(result).toBe("Brightness");
});
it("should return default attribute name", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity_component._.state_attributes.brightness.name"
) {
return "Brightness";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness"
);
expect(result).toBe("Brightness");
});
it("should return capitalized attribute name", () => {
const localize = () => "";
const stateObj = {
entity_id: "light.test",
attributes: {},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness__ip_id_mac_gps_GPS"
);
expect(result).toBe("Brightness IP ID MAC GPS GPS");
});
});
@@ -1,5 +1,9 @@
import { assert, describe, it, beforeEach } from "vitest";
import { computeStateDisplay } from "../../../src/common/entity/compute_state_display";
import type { HassConfig } from "home-assistant-js-websocket";
import { assert, describe, it, beforeEach, expect } from "vitest";
import {
computeStateDisplay,
computeStateDisplayFromEntityAttributes,
} from "../../../src/common/entity/compute_state_display";
import { UNKNOWN } from "../../../src/data/entity";
import type { FrontendLocaleData } from "../../../src/data/translation";
import {
@@ -10,6 +14,7 @@ import {
TimeZone,
} from "../../../src/data/translation";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity_registry";
let localeData: FrontendLocaleData;
@@ -617,3 +622,85 @@ describe("computeStateDisplay", () => {
);
});
});
describe("computeStateDisplayFromEntityAttributes with numeric device classes", () => {
it("Should format duration sensor", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
{
display_precision: 2,
} as EntityRegistryDisplayEntry,
"number.test",
{
device_class: "duration",
unit_of_measurement: "min",
},
"12"
);
expect(result).toBe("12.00 min");
});
it("Should format duration sensor with seconds", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"number.test",
{
device_class: "duration",
unit_of_measurement: "s",
},
"12"
);
expect(result).toBe("12 s");
});
it("Should format monetary device_class", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"number.test",
{
device_class: "monetary",
unit_of_measurement: "$",
},
"12"
);
expect(result).toBe("12 $");
});
});
describe("computeStateDisplayFromEntityAttributes datetime device calss", () => {
it("Should format datetime sensor", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"button.test",
{},
"2020-01-01T12:00:00+00:00"
);
expect(result).toBe("January 1, 2020 at 12:00");
});
});
+2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "jsdom", // to run in browser-like environment
env: {
+31 -1
View File
@@ -49,6 +49,36 @@
"./node_modules/@lrnwebcomponents/simple-tooltip/custom-elements.json"
]
}
]
],
"paths": {
"lit/static-html": ["./node_modules/lit/static-html.js"],
"lit/decorators": ["./node_modules/lit/decorators.js"],
"lit/directive": ["./node_modules/lit/directive.js"],
"lit/directives/until": ["./node_modules/lit/directives/until.js"],
"lit/directives/class-map": [
"./node_modules/lit/directives/class-map.js"
],
"lit/directives/style-map": [
"./node_modules/lit/directives/style-map.js"
],
"lit/directives/if-defined": [
"./node_modules/lit/directives/if-defined.js"
],
"lit/directives/guard": ["./node_modules/lit/directives/guard.js"],
"lit/directives/cache": ["./node_modules/lit/directives/cache.js"],
"lit/directives/repeat": ["./node_modules/lit/directives/repeat.js"],
"lit/directives/live": ["./node_modules/lit/directives/live.js"],
"lit/directives/keyed": ["./node_modules/lit/directives/keyed.js"],
"lit/polyfill-support": ["./node_modules/lit/polyfill-support.js"],
"@lit-labs/virtualizer/layouts/grid": [
"./node_modules/@lit-labs/virtualizer/layouts/grid.js"
],
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": [
"./node_modules/@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js"
],
"@lit-labs/observers/resize-controller": [
"./node_modules/@lit-labs/observers/resize-controller.js"
]
}
}
}
+394 -243
View File
File diff suppressed because it is too large Load Diff