Compare commits

...
Author SHA1 Message Date
Aidan Timson 4851ff7b21 Use the data table settings controller on the tags page 2026-10-22 12:41:42 +01:00
Aidan Timson de2b32a33a Add query params to the tags My redirect 2026-10-22 12:41:42 +01:00
Aidan Timson ab5372c980 Keep the tags table search and sorting in the URL 2026-10-22 12:41:31 +01:00
Aidan Timson 7883bee61d Keep the logs page provider, search and raw view in the URL (#55098)
* Keep the logs page provider and search in the URL

* Link to the logs with search instead of filter

* Keep the app logs search in the URL

* Pass the logs search and raw view through My links

* Remount the app view when switching apps so the logs search follows the URL

* Keep the logs provider and edits in the URL while apps load

* Keep the trailing slash when switching log providers

* Format the logs page

* Keep expanded log lines as new lines arrive and show single hidden lines

* Note in the query params skill that search text is search

* Keep the search param note in the skill to the rule

* Only show single hidden lines around search matches
2026-10-22 14:38:30 +03:00
WendelinandClaude Opus 5.5 3bd5e99243 Add badge cut/copy toasts (#55177)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-22 13:38:04 +02:00
Aidan Timson fa72f6545b Add query params to the backups table (#55137)
* Keep backups search, filters, sorting and grouping in the URL

* Add query params to the backups My redirect

* Use the data table settings controller on the backups page

* Compare backup filter lists regardless of order

* Test the backups query params
2026-10-22 14:32:34 +03:00
Aidan Timson a1f54064cf Add query params to the dashboards and dashboard resources tables (#55136)
* Keep the dashboards and dashboard resources table search, sorting and grouping in the URL

* Add query params to the dashboards and resources My redirects

* Wait for dashboards to load before syncing the URL

* Use the data table settings controller on dashboards and resources
2026-10-22 14:30:26 +03:00
Aidan Timson 7ab69e7da0 Add query params to the labels table (#55134)
* Keep the labels table search and sorting in the URL

* Add query params to the labels My redirect

* Use the data table settings controller on the labels page
2026-10-22 14:29:30 +03:00
renovate[bot] 246a88b142 Update dependency core-js-compat to v3.51.0 (#55179) 2026-10-22 11:28:53 +00:00
Pradeep Peddineni 71b7218b15 Detect reordered keys in the automation and script YAML editors (#55161) 2026-10-22 13:27:54 +02:00
Aidan Timson beb089822a Compare helpers and Marketplace filter lists regardless of order (#55169) 2026-10-22 14:21:53 +03:00
Aidan Timson 693940448d Add query params to the Bluetooth monitor tables (#55174)
* Add query params to the Bluetooth monitor tables

* Pass Bluetooth monitor table params through My links

* Keep the Bluetooth monitors' search with the table settings
2026-10-22 14:21:26 +03:00
Aidan Timson 0f628702d8 Add query params to the voice assistants expose table (#55175) 2026-10-22 14:20:59 +03:00
Maarten Lakerveld 86980368a9 Add My links for the LoRaWAN panel (#55168)
config_lorawan opens the overview and lorawan_devices opens the devices
table, forwarding search, sort, direction, group and connection, the
params the devices page keeps in the URL. Both need the lorawan component,
like config_zha and config_zwave_js.
2026-10-22 13:11:50 +02:00
Lucas Mindêllo de AndradeandMaarten Lakerveld d9f7180aa5 Add problems to the Maintenance Dashboard (#54743)
* feat(maintenance): add problems to the dashboard

* test(maintenance): filterProblemEntities

* test(maintenance): additional tests

* Apply batched suggestions from code review

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Maarten Lakerveld <[email protected]>
2026-10-22 11:05:44 +00:00
Paul Bottein 66b084da01 Simplify card resize editor (#55120)
* Add recommended size switch to card layout editor

* Limit the estimated card height to the picker rows
2026-10-22 12:23:02 +02:00
56 changed files with 1463 additions and 608 deletions

No files matched your search

@@ -51,6 +51,7 @@ When a router reuses one element for different items, such as one app's tab afte
- A link to a page that is already open must apply the new URL. Handle `navigated()`, not only the first render. The router often keeps a page alive, so this is the common case, not an edge case.
- Use one comma-separated param for several values (`?status=new,installed`), which `list` params do.
- Use the names other pages already use for the same thing: `search`, `sort`, `direction` and `group` for the table, and `floor`, `area`, `device`, `label`, `config_entry`, `sub_entry` and `assistants` for the shared filters.
- A search box's text is always `search`, never `filter` or `q`.
- Keep existing param names so existing links and My Home Assistant links keep working. When renaming, read the old name as well for about six months, with a code comment naming the release it can be removed in, and write only the new name. Keep the old name in the config, use it only as a fallback when reading, and leave it out of the values you write, so the next write removes it from the URL.
- Pages opt in. Some use params for other things, such as `?domain=` starting the add integration flow on the integrations dashboard, so check what a page already reads before syncing it.
- Collapsed groups, column order and hidden columns stay in storage (`@storage`), not the URL.
+1 -1
View File
@@ -175,7 +175,7 @@
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
+9 -9
View File
@@ -581,8 +581,8 @@ importers:
specifier: 4.1.4
version: 4.1.4([email protected])
core-js-compat:
specifier: 3.50.0
version: 3.50.0
specifier: 3.51.0
version: 3.51.0
del:
specifier: 9.0.1
version: 9.0.1
@@ -4352,8 +4352,8 @@ packages:
resolution: {integrity: sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==}
engines: {node: '>= 10.13.0'}
[email protected]0.0:
resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==}
[email protected]1.0:
resolution: {integrity: sha512-52r6fkXgxv4GLMjmFcMWRx7X9bw071xTTdrDcdyPGRqU2oE+9pCG0bAOMifSBdLw0JRBZKEF0q3B55qPlLuE8g==}
engines: {node: '>=6.4.0'}
[email protected]:
@@ -8967,7 +8967,7 @@ snapshots:
babel-plugin-polyfill-corejs2: 0.4.17(@babel/[email protected]([email protected]))([email protected])
babel-plugin-polyfill-corejs3: 0.14.2(@babel/[email protected]([email protected]))([email protected])
babel-plugin-polyfill-regenerator: 0.6.8(@babel/[email protected]([email protected]))([email protected])
core-js-compat: 3.50.0
core-js-compat: 3.51.0
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -9038,7 +9038,7 @@ snapshots:
'@babel/plugin-transform-unicode-sets-regex': 8.0.1(@babel/[email protected])
'@babel/preset-modules': 0.2.0(@babel/[email protected])
babel-plugin-polyfill-corejs3: 1.0.0(@babel/[email protected])
core-js-compat: 3.50.0
core-js-compat: 3.51.0
verkit: 0.5.0
'@babel/[email protected](@babel/[email protected]([email protected]))':
@@ -11576,7 +11576,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.7([email protected])
'@babel/helper-define-polyfill-provider': 0.6.8(@babel/[email protected]([email protected]))([email protected])
core-js-compat: 3.50.0
core-js-compat: 3.51.0
transitivePeerDependencies:
- supports-color
@@ -11584,7 +11584,7 @@ snapshots:
dependencies:
'@babel/core': 8.0.7
'@babel/helper-define-polyfill-provider': 1.0.0(@babel/[email protected])
core-js-compat: 3.50.0
core-js-compat: 3.51.0
[email protected](@babel/[email protected]([email protected]))([email protected]):
dependencies:
@@ -11952,7 +11952,7 @@ snapshots:
each-props: 3.0.0
is-plain-object: 5.0.0
[email protected]0.0:
[email protected]1.0:
dependencies:
browserslist: 4.29.3
+87
View File
@@ -0,0 +1,87 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
createQueryString,
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filter-pane keys of the backups table's filters
export const BACKUP_TYPE_FILTER = "ha-filter-states";
export const BACKUP_LOCATIONS_FILTER = "backup-locations";
// The filters of the backups table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const backupsFilterQueryParamConfig = {
list: ["type", "location"],
} as const satisfies QueryParamConfig;
export type BackupsFilterQueryParams = QueryParamValues<
typeof backupsFilterQueryParamConfig
>;
const list = (value: DataTableFiltersValue): string[] | undefined =>
Array.isArray(value) ? value : undefined;
export const backupsFilterQueryParams = (
filters: DataTableFiltersValues
): BackupsFilterQueryParams => ({
type: list(filters[BACKUP_TYPE_FILTER]),
location: list(filters[BACKUP_LOCATIONS_FILTER]),
});
const sorted = (values?: string[]): string[] | undefined =>
values && [...values].sort();
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const backupsFilterQueryString = (
filters: DataTableFiltersValues
): string => {
const { type, location } = backupsFilterQueryParams(filters);
return createQueryString(
{ type: sorted(type), location: sorted(location) },
backupsFilterQueryParamConfig
);
};
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
// behind would hide part of what it shows. The table applies its sorting and
// grouping.
export interface BackupsLink {
search: string;
filters: DataTableFiltersValues;
}
export const backupsLinkFromUrl = (
searchParams: SearchParamsSource
): BackupsLink | undefined => {
if (
!hasQueryParams(searchParams, backupsFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, backupsFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
return {
search: search ?? "",
filters: {
// `type=all` asks for every type, as the backups overview links do
[BACKUP_TYPE_FILTER]:
params.type?.filter((type) => type && type !== "all") ?? [],
[BACKUP_LOCATIONS_FILTER]: params.location?.filter(Boolean) ?? [],
},
};
};
+12 -6
View File
@@ -51,14 +51,20 @@ export const helpersFilterQueryParams = (
});
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, compare equal
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const helpersFilterQueryString = (
filters: DataTableFiltersValues
): string =>
createQueryString(
helpersFilterQueryParams(filters),
helpersFilterQueryParamConfig
);
): string => {
const params = helpersFilterQueryParams(filters);
const sorted: HelpersFilterQueryParams = {};
for (const key of helpersFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
}
return createQueryString(sorted, helpersFilterQueryParamConfig);
};
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
+20 -6
View File
@@ -8,10 +8,15 @@ import {
export const DEFAULT_LOG_PROVIDER = "core";
// `filter` is the search, links from an integration's error count set it.
// `filter` is the name the search had before, still read until 2027.5. It is
// in the configs so writing the search replaces it.
export const logSearchQueryParamConfig = {
string: ["search", "filter"],
} as const satisfies QueryParamConfig;
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "filter"],
string: ["provider", "search", "filter"],
list: ["integration"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -20,7 +25,7 @@ export type LogsQueryParams = QueryParamValues<typeof logsQueryParamConfig>;
export interface LogsState {
provider: string;
filter: string;
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
raw: boolean;
@@ -28,15 +33,24 @@ export interface LogsState {
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
filter: "",
search: "",
integrations: [],
raw: false,
};
export const logSearchFromUrl = (searchParams: SearchParamsSource): string => {
const { search, filter } = decodeQueryParams(
searchParams,
logSearchQueryParamConfig
);
return search ?? filter ?? "";
};
export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
provider:
state.provider === DEFAULT_LOG_PROVIDER ? undefined : state.provider,
filter: state.filter,
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
@@ -55,7 +69,7 @@ export const logsLinkFromUrl = (
const core = provider === DEFAULT_LOG_PROVIDER;
return {
provider,
filter: params.filter ?? "",
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
raw: core && !!params.raw,
};
@@ -24,3 +24,18 @@ export const decodeMarketplaceFilterQueryParams = (
export const createMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
// The filters as the URL holds them with each list sorted, so the same filters
// in another order compare equal. A filter list sends back what it was given
// in its own order.
export const sortedMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => {
const sorted: MarketplaceFilterQueryParams = {};
for (const key of marketplaceFilterQueryParamConfig.list) {
sorted[key] = values[key] && [...values[key]].sort();
}
return createMarketplaceFilterQueryString(sorted);
};
+11 -1
View File
@@ -4,6 +4,8 @@
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
/** Treat objects with the same keys in a different order as not equal */
compareKeyOrder?: boolean;
}
export const deepEqual = (
@@ -90,10 +92,18 @@ export const deepEqual = (
}
const keys = Object.keys(a);
const keysB = Object.keys(b);
length = keys.length;
if (length !== Object.keys(b).length) {
if (length !== keysB.length) {
return false;
}
if (options?.compareKeyOrder) {
for (i = length; i-- !== 0;) {
if (keys[i] !== keysB[i]) {
return false;
}
}
}
for (i = length; i-- !== 0;) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
+25 -11
View File
@@ -159,7 +159,12 @@ export class HaAnsiToHtml extends LitElement {
*/
public parseLinesToColoredPre(lines: string[], top = false) {
for (const line of lines) {
this.parseLineToColoredPre(line, top);
this._addLine(line, top);
}
// Filter once all lines are added, keeping the hidden lines already shown
if (this._filter) {
this.filterLines(this._filter);
}
}
@@ -169,6 +174,10 @@ export class HaAnsiToHtml extends LitElement {
* @param top should the new line be added to the top of the log
*/
public parseLineToColoredPre(line, top = false) {
this.parseLinesToColoredPre([line], top);
}
private _addLine(line: string, top: boolean) {
const lineDiv = document.createElement("div");
// eslint-disable-next-line no-control-regex
@@ -329,19 +338,10 @@ export class HaAnsiToHtml extends LitElement {
} else {
this._pre?.appendChild(lineDiv);
}
// filter new lines if a filter is set
if (this._filter) {
this.filterLines(this._filter);
}
}
public parseTextToColoredPre(text) {
const lines = text.split("\n");
for (const line of lines) {
this.parseLineToColoredPre(line);
}
this.parseLinesToColoredPre(text.split("\n"));
}
/**
@@ -410,6 +410,20 @@ export class HaAnsiToHtml extends LitElement {
.some(Boolean)
);
// A button in place of a single hidden line takes as much room as the
// line, so show the line instead. Buttons only show around matches.
if (this.contextLines > 0 && matchedEntries.size) {
visible.forEach((shown, lineIndex) => {
if (
!shown &&
visible[lineIndex - 1] !== false &&
visible[lineIndex + 1] !== false
) {
visible[lineIndex] = true;
}
});
}
lineList.forEach((line, lineIndex) => {
line.classList.toggle(
"context",
+75 -57
View File
@@ -1,22 +1,27 @@
import { mdiRestore } from "@mdi/js";
import { mdiGrid, mdiGridLarge } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import type { CardGridSize } from "../panels/lovelace/common/compute-card-grid-size";
import { DEFAULT_GRID_SIZE } from "../panels/lovelace/common/compute-card-grid-size";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import "./ha-icon-button";
export const GRID_SIZE_PICKER_ROWS = 8;
@customElement("ha-grid-size-picker")
export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public value?: CardGridSize;
@property({ attribute: false }) public rows = 8;
@property({ attribute: false }) public autoRows?: number;
@property({ attribute: false }) public rows = GRID_SIZE_PICKER_ROWS;
@property({ attribute: false }) public columns = 12;
@@ -28,10 +33,10 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public columnMax?: number;
@property({ attribute: false }) public isDefault?: boolean;
@property({ attribute: false }) public step = 1;
@property({ attribute: false }) public precise = false;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
@state()
@@ -44,6 +49,24 @@ export class HaGridSizeEditor extends LitElement {
}
}
private _columnsText(columns: CardGridSize["columns"]): string {
if (columns === "full") {
return this._localize("ui.components.grid-size-picker.full_width");
}
return this._localize("ui.components.grid-size-picker.columns_value", {
count: columns,
});
}
private _rowsText(rows: CardGridSize["rows"]): string {
if (rows === "auto") {
return this._localize("ui.components.grid-size-picker.fit_content");
}
return this._localize("ui.components.grid-size-picker.rows_value", {
count: rows,
});
}
protected render() {
const autoHeight = this._localValue?.rows === "auto";
const fullWidth = this._localValue?.columns === "full";
@@ -59,11 +82,23 @@ export class HaGridSizeEditor extends LitElement {
const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
const columnMax =
Math.ceil((this.columnMax ?? this.columns) / this.step) * this.step;
const rowValue = autoHeight ? rowMin : this._localValue?.rows;
const autoRows = conditionalClamp(this.autoRows ?? rowMin, rowMin, rowMax);
const rowValue = autoHeight ? autoRows : this._localValue?.rows;
const columnValue = this._localValue?.columns;
return html`
<div class="grid">
<ha-icon-button
class="precise"
.label=${this._localize(
this.precise
? "ui.components.grid-size-picker.exit_precise_mode"
: "ui.components.grid-size-picker.enter_precise_mode"
)}
.path=${this.precise ? mdiGrid : mdiGridLarge}
.selected=${this.precise}
@click=${this._togglePrecise}
></ha-icon-button>
<ha-grid-layout-slider
aria-label=${this._localize("ui.components.grid-size-picker.columns")}
id="columns"
@@ -71,6 +106,11 @@ export class HaGridSizeEditor extends LitElement {
.max=${columnMax}
.range=${this.columns}
.value=${fullWidth ? this.columns : this.value?.columns}
.valueText=${
columnValue !== undefined
? this._columnsText(columnValue)
: undefined
}
.step=${this.step}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
@@ -85,30 +125,17 @@ export class HaGridSizeEditor extends LitElement {
.max=${rowMax}
.range=${this.rows}
vertical
.value=${autoHeight ? rowMin : this.value?.rows}
.value=${autoHeight ? autoRows : this.value?.rows}
.valueText=${
this._localValue?.rows !== undefined
? this._rowsText(this._localValue.rows)
: undefined
}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledRows}
tooltip-mode=${disabledRows ? "never" : "always"}
></ha-grid-layout-slider>
${
!this.isDefault
? html`
<ha-icon-button
@click=${this._reset}
class="reset"
.path=${mdiRestore}
label=${this._localize(
"ui.components.grid-size-picker.reset_default"
)}
title=${this._localize(
"ui.components.grid-size-picker.reset_default"
)}
>
</ha-icon-button>
`
: nothing
}
<div class="preview">
<table>
${Array(this.rows)
@@ -140,7 +167,7 @@ export class HaGridSizeEditor extends LitElement {
})}
</table>
<div
class="preview-card"
class=${classMap({ "preview-card": true, auto: autoHeight })}
style=${styleMap({
"--rows": rowValue,
"--columns": fullWidth ? this.columns : columnValue,
@@ -152,6 +179,10 @@ export class HaGridSizeEditor extends LitElement {
`;
}
private _togglePrecise() {
fireEvent(this, "precise-mode-changed", { value: !this.precise });
}
private _cellClick(ev) {
const cell = ev.currentTarget as HTMLElement;
const rows = Number(cell.getAttribute("data-row"));
@@ -179,32 +210,8 @@ export class HaGridSizeEditor extends LitElement {
private _valueChanged(ev) {
ev.stopPropagation();
const key = ev.currentTarget.id as "rows" | "columns";
const currentSize = this.value ?? DEFAULT_GRID_SIZE;
let value = ev.detail.value as CardGridSize[typeof key];
if (
key === "columns" &&
currentSize.columns === "full" &&
value === this.columns
) {
value = "full";
}
const newSize = {
...currentSize,
[key]: value,
};
fireEvent(this, "value-changed", { value: newSize });
}
private _reset(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
rows: undefined,
columns: undefined,
},
});
const value = ev.detail.value as number;
fireEvent(this, "value-changed", { value: { [key]: value } });
}
private _sliderMoved(ev) {
@@ -226,22 +233,27 @@ export class HaGridSizeEditor extends LitElement {
.grid {
display: grid;
grid-template-areas:
"reset column-slider"
"precise column-slider"
"row-slider preview";
grid-template-rows: auto auto;
grid-template-columns: auto 1fr;
gap: var(--ha-space-2);
}
.precise {
grid-area: precise;
--ha-icon-button-size: 36px;
}
.precise[selected] {
border-radius: 50%;
background-color: var(--primary-color);
color: var(--text-primary-color);
}
#columns {
grid-area: column-slider;
}
#rows {
grid-area: row-slider;
}
.reset {
grid-area: reset;
--ha-icon-button-size: 36px;
}
.preview {
position: relative;
grid-area: preview;
@@ -275,6 +287,9 @@ export class HaGridSizeEditor extends LitElement {
width ease-in-out 180ms,
height ease-in-out 180ms;
}
.preview-card.auto {
mask-image: linear-gradient(to bottom, black 60%, transparent);
}
`,
];
}
@@ -283,4 +298,7 @@ declare global {
interface HTMLElementTagNameMap {
"ha-grid-size-picker": HaGridSizeEditor;
}
interface HASSDomEvents {
"precise-mode-changed": { value: boolean };
}
}
+9 -3
View File
@@ -14,7 +14,11 @@ import {
import type { Constructor } from "../types";
export type CompareStrategy<State> =
| { type: "deep" }
| {
type: "deep";
/** Treat objects with the same keys in a different order as changed */
compareKeyOrder?: boolean;
}
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
@@ -203,10 +207,12 @@ export const DirtyStateProviderMixin =
): void {
this._effectiveNormalize = effectiveNormalize;
switch (strategy.type) {
case "deep":
this._dirtyCompareFn = (a, b) => deepEqual(a, b);
case "deep": {
const options = { compareKeyOrder: strategy.compareKeyOrder };
this._dirtyCompareFn = (a, b) => deepEqual(a, b, options);
this._dirtyCloneFn = (value) => deepClone(value);
break;
}
case "shallow":
this._dirtyCompareFn = (a, b) => shallowEqual(a, b);
this._dirtyCloneFn = (value) => value;
@@ -3,12 +3,19 @@ import {
type CSSResultGroup,
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../../../../common/url/search-params";
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
import {
logSearchFromUrl,
logSearchQueryParamConfig,
} from "../../../../../common/url/logs-query-params";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -21,7 +28,24 @@ class SupervisorAppLogDashboard extends LitElement {
@property({ attribute: false }) public addon?: HassioAddonDetails;
@state() private _filter = extractSearchParam("filter") || "";
@state() private _filter = "";
private _queryParams = new QueryParamsController(
this,
logSearchQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyUrl(),
}
);
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._applyUrl();
}
}
protected render(): TemplateResult {
if (!this.addon) {
@@ -32,6 +56,7 @@ class SupervisorAppLogDashboard extends LitElement {
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
@blur=${this._flushUrl}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
@@ -47,8 +72,25 @@ class SupervisorAppLogDashboard extends LitElement {
`;
}
private async _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HTMLInputElement).value;
private _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
this._queryParams.write({ search: this._filter }, true);
}
private _flushUrl() {
this._queryParams.flush();
}
// A link without a search clears it, like the logs page
private _applyUrl() {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
this._filter = logSearchFromUrl(searchParams);
this._queryParams.write({ search: this._filter });
}
static get styles(): CSSResultGroup {
@@ -7,6 +7,7 @@ import {
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { goBack, navigate } from "../../../common/navigate";
@@ -140,14 +141,17 @@ class HaConfigAppDashboard extends LitElement {
back-path=${this._fromStore ? "/config/apps/available" : "/config/apps"}
>
<span slot="header">${this._addon.name}</span>
<supervisor-app-router
.route=${route}
.narrow=${this.narrow}
.hass=${this.hass}
.addon=${this._addon}
.controlEnabled=${this._controlEnabled}
@system-managed-take-control=${this._enableControl}
></supervisor-app-router>
${keyed(
this._addon.slug,
html`<supervisor-app-router
.route=${route}
.narrow=${this.narrow}
.hass=${this.hass}
.addon=${this._addon}
.controlEnabled=${this._controlEnabled}
@system-managed-take-control=${this._enableControl}
></supervisor-app-router>`
)}
</hass-tabs-subpage>
`;
}
@@ -149,7 +149,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
protected domainHooks: EditorDomainHooks<AutomationConfig> = {
domain: "automation",
fetchFileConfig: fetchAutomationFileConfig,
normalizeConfig: normalizeAutomationConfig,
// The YAML editor hides the id and puts it back first, so load it first
// too. Otherwise the key order check marks unchanged YAML as dirty.
normalizeConfig: (config, report) => {
const { id, ...rest } = normalizeAutomationConfig(config, report);
return { id, ...rest };
},
checkValidation: () => this._checkValidation(),
};
@@ -711,7 +716,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: baseConfig as AutomationConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -729,7 +734,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -231,7 +231,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
this.readOnly = false;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: null as unknown as TConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -290,7 +290,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
// options were migrated.
this.deprecatedConfigMigrated = report.deprecated;
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -12,12 +12,22 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
BACKUP_LOCATIONS_FILTER,
BACKUP_TYPE_FILTER,
backupsFilterQueryParamConfig,
backupsFilterQueryParams,
backupsFilterQueryString,
backupsLinkFromUrl,
} from "../../../common/url/backups-query-params";
import type {
DataTableColumnContainer,
DataTableRowData,
@@ -55,7 +65,10 @@ import {
} from "../../../data/backup";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import type { DataTableFiltersValues } from "../../../data/data_table_filters";
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../../data/data_table_filters";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
showAlertDialog,
@@ -79,9 +92,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
agent_ids: string[];
}
const TYPE_FILTER = "ha-filter-states";
const LOCATIONS_FILTER = "backup-locations";
@customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -113,10 +123,11 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
})
private _storageFilters: DataTableFiltersValues = {};
private _fromUrl = false;
@storage({ key: "backups-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string = "formatted_type";
private _tableSettings = new DataTableSettingsController(
this,
{ grouping: "backups-table-grouping" },
{ defaultGrouping: "formatted_type" }
);
@storage({
key: "backups-table-collapsed",
@@ -134,38 +145,25 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
private _overflowBackup?: BackupRow;
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
backupsFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
);
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
this._applyLink();
}
}
public connectedCallback() {
super.connectedCallback();
// Re-apply the type filter from the URL when the page is (re)displayed,
// e.g. when navigating back to a cached instance of this page with a
// different `type` query param.
this._setFiltersFromUrl();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
this._setFiltersFromUrl();
};
private _popState = () => {
this._setFiltersFromUrl();
};
private _columns = memoizeOne(
(
localize: LocalizeFunc,
@@ -197,6 +195,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
filterable: true,
sortable: true,
groupable: true,
urlName: "type",
},
locations: {
title: localize("ui.panel.config.backup.locations"),
@@ -334,10 +333,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
this._overflowMenu.anchorElement = undefined;
};
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -355,16 +350,19 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
isHassio: boolean
): BackupRow[] => {
const typeFilter = filters[TYPE_FILTER] as string[] | undefined;
const locationFilter = filters[LOCATIONS_FILTER] as string[] | undefined;
const typeFilter = filters[BACKUP_TYPE_FILTER];
const locationFilter = filters[BACKUP_LOCATIONS_FILTER];
let filteredBackups = backups;
if (typeFilter?.length) {
if (Array.isArray(typeFilter) && typeFilter.length) {
filteredBackups = filteredBackups.filter((backup) => {
const type = computeBackupType(backup, isHassio);
return typeFilter.includes(type);
});
}
if (locationFilter?.length) {
if (Array.isArray(locationFilter) && locationFilter.length) {
filteredBackups = filteredBackups.filter((backup) =>
Object.keys(backup.agents).some((agentId) =>
locationFilter.includes(agentId)
@@ -450,18 +448,21 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
selectable
.selected=${this._selected.length}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.groupOrder=${this._groupOrder(
this._activeGrouping,
this._tableSettings.grouping,
this.hass.localize,
isHassio
)}
@grouping-changed=${this._handleGroupingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged}
@clear-filter=${this._clearFilter}
.route=${this.route}
sync-url
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
.data=${data}
@@ -518,7 +519,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
<ha-filter-states
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters[TYPE_FILTER]}
.value=${this._filters[BACKUP_TYPE_FILTER]}
.states=${this._states(this.hass.localize, isHassio)}
@data-table-filter-changed=${this._typeFilterChanged}
slot="filter-pane"
@@ -526,7 +527,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
></ha-filter-states>
<ha-filter-states
.label=${this.hass.localize("ui.panel.config.backup.locations")}
.value=${this._filters[LOCATIONS_FILTER]}
.value=${this._filters[BACKUP_LOCATIONS_FILTER]}
.states=${this._locations(
this.hass.localize,
this.agents,
@@ -589,38 +590,58 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
);
private _typeFilterChanged(ev) {
this._filters = { ...this._filters, [TYPE_FILTER]: ev.detail.value };
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._filterChanged(BACKUP_TYPE_FILTER, ev.detail.value);
}
private _locationsFilterChanged(ev) {
this._filters = { ...this._filters, [LOCATIONS_FILTER]: ev.detail.value };
if (!this._fromUrl) {
this._storageFilters = this._filters;
this._filterChanged(BACKUP_LOCATIONS_FILTER, ev.detail.value);
}
private _filterChanged(type: string, value: DataTableFiltersValue) {
const filters = { ...this._filters, [type]: value };
// Only a change to what the URL holds is the user's, so a link isn't stored
if (
backupsFilterQueryString(filters) !==
backupsFilterQueryString(this._filters)
) {
this._storageFilters = { ...this._storageFilters, [type]: value };
}
this._filters = filters;
this._writeFilters();
}
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._writeFilters();
}
private _setFiltersFromUrl() {
const searchParams = new URLSearchParams(window.location.search);
const type = searchParams.get("type");
// Links, like the ones on the backups overview, say in the URL what to show.
// The router keeps the page open, so a link can land on it.
private _applyLink() {
const searchParams = this._filterParams.searchParams();
const link = searchParams ? backupsLinkFromUrl(searchParams) : undefined;
// Shown, but not stored as the user's choice
if (link) {
this._tableSettings.showSearch(link.search);
this._filters = link.filters;
} else if (searchParams?.toString() === "") {
// Opened again without params, like from the sidebar. Written once the
// table has seen the URL without params, so it restores what it stored
this._filters = this._storageFilters;
queueMicrotask(() => this._writeFilters());
if (!type) {
return;
}
this._fromUrl = true;
this._filters = {
[TYPE_FILTER]: type === "all" ? [] : [type],
};
this._writeFilters();
}
private _writeFilters() {
this._filterParams.write(backupsFilterQueryParams(this._filters));
}
private get _needsOnboarding() {
@@ -261,16 +261,13 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
if (!ansiToHtml) {
return;
}
if (!this._search) {
ansiToHtml.parseLinesToColoredPre(lines);
return;
}
// ha-ansi-to-html re-filters all lines after every added line while a
// search is set, so search once after adding them
ansiToHtml.filterLines("");
ansiToHtml.parseLinesToColoredPre(lines);
this._noMatches =
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
if (this._search) {
this._noMatches =
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
}
}
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
@@ -321,7 +321,7 @@ export class HaConfigEntryRow extends LitElement {
ERROR_STATES.includes(item.state)
? html`
<a
href=${`/config/logs?filter=${encodeURIComponent(item.domain)}`}
href=${`/config/logs?search=${encodeURIComponent(item.domain)}`}
>
<ha-dropdown-item value="logs">
<ha-svg-icon
@@ -128,7 +128,7 @@ export const renderConfigEntryError = (
}
return html`
<br />
<a href=${`/config/logs?filter=${encodeURIComponent(entry.domain)}`}>
<a href=${`/config/logs?search=${encodeURIComponent(entry.domain)}`}>
${hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}
@@ -441,7 +441,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
${
this._logInfo
? html`<a
href=${`/config/logs?filter=${encodeURIComponent(this.domain)}`}
href=${`/config/logs?search=${encodeURIComponent(this.domain)}`}
>
<ha-dropdown-item>
<ha-svg-icon
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
@@ -35,8 +36,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public address?: string;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -54,12 +53,11 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@storage({
key: "bluetooth-advertisement-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "source";
private _tableSettings = new DataTableSettingsController(
this,
{ grouping: "bluetooth-advertisement-table-grouping" },
{ defaultGrouping: "source" }
);
@storage({
key: "bluetooth-advertisement-table-collapsed",
@@ -125,7 +123,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
const searchParams = extractSearchParamsObject();
const address = searchParams.address;
if (address) {
this.address = address;
this._tableSettings.showSearch(address);
}
}
@@ -159,6 +157,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
filterable: true,
sortable: true,
groupable: true,
urlName: "source",
},
source_address: {
title: localize("ui.panel.config.bluetooth.source_address"),
@@ -225,11 +224,13 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
"ui.panel.config.bluetooth.no_advertisements_found"
)}
@row-click=${this._handleRowClicked}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
@grouping-changed=${this._handleGroupingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
filter=${this.address || ""}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
clickable
.tabs=${this._tabs}
back-path="/config/bluetooth/dashboard"
@@ -237,10 +238,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
`;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../../../common/decorators/storage";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { extractSearchParamsObject } from "../../../../../common/url/search-params";
@@ -35,8 +36,6 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _filter?: string;
private _tabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.bluetooth.navigation.connections",
@@ -52,12 +51,11 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@storage({
key: "bluetooth-connection-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "source";
private _tableSettings = new DataTableSettingsController(
this,
{ grouping: "bluetooth-connection-table-grouping" },
{ defaultGrouping: "source" }
);
@storage({
key: "bluetooth-connection-table-collapsed",
@@ -162,7 +160,7 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
const searchParams = extractSearchParamsObject();
if (searchParams.source) {
this._filter = searchParams.source;
this._tableSettings.showSearch(searchParams.source);
}
}
@@ -196,6 +194,7 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
filterable: true,
sortable: true,
groupable: true,
urlName: "source",
},
source_address: {
title: localize("ui.panel.config.bluetooth.source_address"),
@@ -244,22 +243,20 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
back-path="/config/bluetooth/dashboard"
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.noDataText=${this.hass.localize(
"ui.panel.config.bluetooth.no_connections"
)}
@grouping-changed=${this._handleGroupingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
filter=${this._filter || ""}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
></hass-tabs-subpage-data-table>
`;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -628,7 +628,7 @@ class ZWaveJSConfigDashboard extends LitElement {
} else {
stateTextExtra = html`
<br />
<a href="/config/logs?filter=zwave_js"
<a href="/config/logs?search=zwave_js"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}</a
+10 -28
View File
@@ -15,6 +15,7 @@ import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
@@ -27,7 +28,6 @@ import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-dropdown";
@@ -115,21 +115,10 @@ export class HaConfigLabels extends LitElement {
@state() private _loading = true;
@state()
@storage({
storage: "sessionStorage",
key: "labels-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@storage({
key: "labels-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
private _tableSettings = new DataTableSettingsController(this, {
search: "labels-table-search",
sorting: "labels-table-sort",
});
@storage({
key: "labels-table-column-order",
@@ -273,13 +262,14 @@ export class HaConfigLabels extends LitElement {
.loading=${this._loading}
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
has-fab
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._tableSettings.sortingChanged}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
@row-click=${this._editLabel}
clickable
id="label_id"
@@ -499,14 +489,6 @@ export class HaConfigLabels extends LitElement {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
-5
View File
@@ -849,11 +849,6 @@ class ErrorLogCard extends LitElement {
0,
this._integrationLines.length - INTEGRATION_LOG_LINE_LIMIT
);
// ha-ansi-to-html re-filters all lines after every added line while a
// search is set, so search once after adding them
if (this.filter) {
this._ansiToHtmlElement?.filterLines("");
}
this._ansiToHtmlElement?.parseLinesToColoredPre(newLines);
this._ansiToHtmlElement?.trimLines(INTEGRATION_LOG_LINE_LIMIT);
if (this.filter) {
+50 -32
View File
@@ -27,6 +27,7 @@ import {
updateHistoryState,
} from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { currentPath } from "../../../common/url/current-path";
import { deepEqual } from "../../../common/util/deep-equal";
import {
DEFAULT_LOG_PROVIDER,
@@ -131,8 +132,8 @@ export class HaConfigLogs extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: ContextType<typeof manifestsContext>;
// The add-on providers are only known after loading them, links are applied
// once they are
// The add-on providers are only known after loading them, until then the
// provider a link asks for is kept in the URL
private _providersLoaded = false;
private _queryParams = new QueryParamsController(this, logsQueryParamConfig, {
@@ -148,6 +149,14 @@ export class HaConfigLogs extends LitElement {
}
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._applyUrl();
}
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._init();
@@ -308,6 +317,8 @@ export class HaConfigLogs extends LitElement {
this._selectedLogProvider = provider;
this._filter = "";
this._integrations = [];
// Keep the search typed so far on the entry Back returns to
this._queryParams.flush();
// Picking another provider is a navigation of its own, so Back returns
// to the previous one. Both entries are marked, so going back to one
// without params shows the default view instead of keeping the provider.
@@ -315,13 +326,14 @@ export class HaConfigLogs extends LitElement {
const search = createQueryString(
logsQueryParams({
provider,
filter: "",
search: "",
integrations: [],
raw: this._detail,
}),
logsQueryParamConfig
);
navigate(`/config/logs${search ? `?${search}` : ""}`, {
// From the current path, so a trailing slash still matches the controller
navigate(`${currentPath()}${search ? `?${search}` : ""}`, {
data: { [LOGS_HISTORY_STATE]: true },
});
}
@@ -361,8 +373,12 @@ export class HaConfigLogs extends LitElement {
private _writeUrl(debounced = false) {
this._queryParams.write(
logsQueryParams({
provider: this._selectedLogProvider,
filter: this._filter,
// Until the apps are loaded, keep the provider the URL asks for
provider: this._providersLoaded
? this._selectedLogProvider
: (logsLinkFromUrl(this._queryParams.searchParams() ?? "")
?.provider ?? DEFAULT_LOG_PROVIDER),
search: this._filter,
integrations: this._integrations,
raw: this._detail,
}),
@@ -374,14 +390,13 @@ export class HaConfigLogs extends LitElement {
if (isComponentLoaded(this.hass.config, "hassio")) {
await this._getInstalledAddons();
}
// Keep a search typed while the apps were loading
this._queryParams.flush();
this._providersLoaded = true;
this._applyUrl();
}
private _applyUrl() {
if (!this._providersLoaded) {
return;
}
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
@@ -391,32 +406,22 @@ export class HaConfigLogs extends LitElement {
(getHistoryState()?.[LOGS_HISTORY_STATE]
? DEFAULT_LOGS_STATE
: undefined);
let missingProvider: string | undefined;
if (link) {
if (
link.provider !== DEFAULT_LOG_PROVIDER &&
!(
isComponentLoaded(this.hass.config, "hassio") &&
this._logProviders.some((p) => p.key === link.provider)
)
) {
this._selectedLogProvider = DEFAULT_LOG_PROVIDER;
this._filter = "";
this._integrations = [];
this._writeUrl();
showAlertDialog(this, {
title:
this.hass.localize("ui.panel.config.logs.provider_not_found") ||
"Log provider not found",
text: this.hass.localize(
"ui.panel.config.logs.provider_not_available",
{ provider: link.provider }
),
});
return;
// The provider is checked once the apps are loaded, the rest of the link
// applies straight away
if (this._providersLoaded) {
const found =
link.provider === DEFAULT_LOG_PROVIDER ||
(isComponentLoaded(this.hass.config, "hassio") &&
this._logProviders.some((p) => p.key === link.provider));
missingProvider = found ? undefined : link.provider;
this._selectedLogProvider = found
? link.provider
: DEFAULT_LOG_PROVIDER;
}
const loaded = this._getLoadedIntegrations(this.hass.config.components);
this._selectedLogProvider = link.provider;
this._filter = link.filter;
this._filter = link.search;
if (link.provider === DEFAULT_LOG_PROVIDER) {
this._detail = link.raw;
}
@@ -428,7 +433,20 @@ export class HaConfigLogs extends LitElement {
this._integrations = integrations;
}
}
// Before the alert adds its history entry, so closing it doesn't go back
// to the missing provider
this._writeUrl();
if (missingProvider) {
showAlertDialog(this, {
title:
this.hass.localize("ui.panel.config.logs.provider_not_found") ||
"Log provider not found",
text: this.hass.localize(
"ui.panel.config.logs.provider_not_available",
{ provider: missingProvider }
),
});
}
}
private async _getInstalledAddons() {
@@ -11,6 +11,7 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoize from "memoize-one";
import { DataTableSettingsController } from "../../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
@@ -21,7 +22,6 @@ import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/ha-alert";
@@ -115,21 +115,15 @@ export class HaConfigLovelaceDashboards extends LitElement {
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@state()
@storage({
storage: "sessionStorage",
key: "lovelace-dashboards-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@storage({
key: "lovelace-dashboards-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
private _tableSettings = new DataTableSettingsController(
this,
{
search: "lovelace-dashboards-table-search",
sorting: "lovelace-dashboards-table-sort",
grouping: "lovelace-dashboards-table-grouping",
},
{ defaultGrouping: "localized_type" }
);
@storage({
key: "lovelace-dashboards-table-column-order",
@@ -145,13 +139,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
})
private _activeHiddenColumns?: string[];
@storage({
key: "lovelace-dashboards-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "localized_type";
@storage({
key: "lovelace-dashboards-table-collapsed",
state: false,
@@ -237,6 +224,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
sortable: true,
groupable: true,
filterable: true,
urlName: "type",
};
columns.mode = {
@@ -429,17 +417,18 @@ export class HaConfigLovelaceDashboards extends LitElement {
? []
: this._getItems(this._dashboards, defaultPanel, this.hass.panels)
}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@grouping-changed=${this._handleGroupingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._tableSettings.sortingChanged}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
?sync-url=${!this._loading && !this._loadFailed}
@row-click=${this._handleRowClicked}
selectable
.selected=${this._selected.length}
@@ -804,23 +793,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -3,12 +3,12 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one";
import { DataTableSettingsController } from "../../../../common/controllers/data-table-settings-controller";
import { stringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
SortingChangedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
@@ -55,21 +55,10 @@ export class HaConfigLovelaceResources extends LitElement {
@state() private _lovelaceInfo?: LovelaceInfo;
@state()
@storage({
storage: "sessionStorage",
key: "lovelace-resources-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@storage({
key: "lovelace-resources-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
private _tableSettings = new DataTableSettingsController(this, {
search: "lovelace-resources-table-search",
sorting: "lovelace-resources-table-sort",
});
@storage({
key: "lovelace-resources-table-column-order",
@@ -184,13 +173,14 @@ export class HaConfigLovelaceResources extends LitElement {
.noDataText=${this.hass.localize(
"ui.panel.config.lovelace.resources.picker.no_resources"
)}
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._tableSettings.sortingChanged}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
@row-click=${this._editResource}
has-fab
clickable
@@ -329,14 +319,6 @@ export class HaConfigLovelaceResources extends LitElement {
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
+2 -2
View File
@@ -605,7 +605,7 @@ export class HaScriptEditor extends SubscribeMixin(
initData && "sequence" in initData ? {} : baseConfig;
this.config = { ...initData, ...missingDefaults } as ScriptConfig;
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: baseConfig as ScriptConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -622,7 +622,7 @@ export class HaScriptEditor extends SubscribeMixin(
getScriptStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeScriptConfig(c.config);
this._initDirtyTracking(
{ type: "deep" },
{ type: "deep", compareKeyOrder: true },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
+9 -15
View File
@@ -9,7 +9,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
@@ -76,14 +76,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
return this.hass.auth.external?.config.canWriteTag;
}
@state()
@storage({
storage: "sessionStorage",
key: "tags-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _tableSettings = new DataTableSettingsController(this, {
search: "tags-table-search",
});
private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<TagRowData> = {
@@ -99,6 +94,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.tag.headers.name"),
main: true,
sortable: true,
urlName: "name",
filterable: true,
flex: 2,
},
@@ -111,6 +107,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
last_scanned_datetime: {
title: localize("ui.panel.config.tag.headers.last_scanned"),
sortable: true,
urlName: "last_scanned",
direction: "desc",
template: (tag) => html`
${
@@ -218,8 +215,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
: undefined
}
@retry-load=${this._retryFetchTags}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
@@ -417,10 +415,6 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
}
});
};
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
}
declare global {
@@ -9,6 +9,7 @@ import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoize from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -26,7 +27,6 @@ import type {
DataTableRowData,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
import "../../../components/ha-tooltip";
@@ -84,14 +84,11 @@ export class VoiceAssistantsExpose extends LitElement {
@state() private _extEntities?: Record<string, ExtEntityRegistryEntry>;
@state()
@storage({
storage: "sessionStorage",
key: "voice-expose-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _tableSettings = new DataTableSettingsController(this, {
search: "voice-expose-table-search",
sorting: "voice-expose-table-sort",
grouping: "voice-expose-table-grouping",
});
@state() private _searchParms = new URLSearchParams(window.location.search);
@@ -102,20 +99,6 @@ export class VoiceAssistantsExpose extends LitElement {
string[] | undefined
>;
@storage({
key: "voice-expose-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
@storage({
key: "voice-expose-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string;
@storage({
key: "voice-expose-table-collapsed",
state: false,
@@ -517,22 +500,23 @@ export class VoiceAssistantsExpose extends LitElement {
number: filteredEntities.length,
}
)}
.filter=${this._filter}
.filter=${this._tableSettings.search}
selectable
.selected=${this._selectedEntities.length}
clickable
.initialSorting=${this._activeSorting}
.initialGroupColumn=${this._activeGrouping}
.initialSorting=${this._tableSettings.sorting}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
@sorting-changed=${this._tableSettings.sortingChanged}
@selection-changed=${this._handleSelectionChanged}
@grouping-changed=${this._handleGroupingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@clear-filter=${this._clearFilter}
@search-changed=${this._handleSearchChange}
@search-changed=${this._tableSettings.searchChanged}
sync-url
@row-click=${this._openEditEntry}
id="entity_id"
has-fab
@@ -617,10 +601,6 @@ export class VoiceAssistantsExpose extends LitElement {
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
@@ -727,14 +707,6 @@ export class VoiceAssistantsExpose extends LitElement {
navigate(window.location.pathname, { replace: true });
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -49,6 +49,7 @@ import {
} from "../strategies/home/helpers/home-summaries";
import {
filterLowBatteryEntities,
filterProblemEntities,
filterUnavailableBatteryEntities,
} from "../../maintenance/strategies/maintenance-view-strategy";
import {
@@ -340,6 +341,11 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
maintenanceEntities
);
const problemEntities = filterProblemEntities(
states,
maintenanceEntities
);
const unavailableBatteryEntities = filterUnavailableBatteryEntities(
states,
maintenanceEntities
@@ -355,6 +361,16 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
)
: undefined;
const problemText =
problemEntities.length > 0
? localize(
"ui.card.home-summary.count_maintenance_problem_issues",
{
count: problemEntities.length,
}
)
: undefined;
const unavailableText =
unavailableBatteryEntities.length > 0
? localize(
@@ -365,16 +381,12 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
)
: undefined;
if (lowBatteryText && unavailableText) {
return `${lowBatteryText}, ${unavailableText}`;
}
const status = [lowBatteryText, problemText, unavailableText].filter(
Boolean
);
if (lowBatteryText) {
return lowBatteryText;
}
if (unavailableText) {
return unavailableText;
if (status.length > 0) {
return status.join(", ");
}
return localize("ui.card.home-summary.all_maintenance_good");
@@ -40,6 +40,18 @@ export interface CardGridSize {
export const isPreciseMode = (options: LovelaceGridOptions) =>
typeof options.columns === "number" && options.columns % 3 !== 0;
const CARD_SIZE_HEIGHT = 50;
const GRID_ROW_HEIGHT = 56;
const GRID_GAP = 8;
export const cardSizeToGridRows = (cardSize: number): number =>
Math.max(
1,
Math.ceil(
(cardSize * CARD_SIZE_HEIGHT + GRID_GAP) / (GRID_ROW_HEIGHT + GRID_GAP)
)
);
export const computeCardGridSize = (
options: LovelaceGridOptions
): CardGridSize => {
@@ -7,14 +7,12 @@ import {
mdiPencil,
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -54,14 +52,6 @@ export class HuiBadgeEditMode extends LitElement {
@state()
public _focused = false;
@storage({
key: "dashboardBadgeClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceBadgeConfig;
private get _badgeConfig() {
return getAtPath<LovelaceBadgeConfig>(this.lovelace.config, this.path)!;
}
@@ -206,12 +196,11 @@ export class HuiBadgeEditMode extends LitElement {
}
private _cutBadge(): void {
this._copyBadge();
fireEvent(this, "ll-delete-badge", { path: this.path, silent: true });
fireEvent(this, "ll-cut-badge", { path: this.path });
}
private _copyBadge(): void {
this._clipboard = deepClone(this._badgeConfig);
fireEvent(this, "ll-copy-badge", { path: this.path });
}
private _duplicateBadge(): void {
@@ -46,6 +46,7 @@ import {
import { showSelectViewDialog } from "../editor/select-view/show-select-view-dialog";
import type { Lovelace, LovelaceCard } from "../types";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
@customElement("hui-card-options")
export class HuiCardOptions extends LitElement {
@@ -385,6 +386,7 @@ export class HuiCardOptions extends LitElement {
moveCardToContainer(newConfig, this.path!, toPath)
);
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_card.success"
),
@@ -411,6 +413,7 @@ export class HuiCardOptions extends LitElement {
);
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_card.success"
),
@@ -422,6 +425,7 @@ export class HuiCardOptions extends LitElement {
});
} catch (_err: any) {
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_card.error"
),
@@ -37,6 +37,7 @@ import {
} from "../editor/lovelace-path";
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
@customElement("hui-section-edit-mode")
export class HuiSectionEditMode extends LitElement {
@@ -216,6 +217,7 @@ export class HuiSectionEditMode extends LitElement {
if (!section) return;
this._clipboard = deepClone(section);
this.lovelace!.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass.localize(
"ui.panel.lovelace.editor.section.copied_to_clipboard"
),
@@ -233,6 +235,7 @@ export class HuiSectionEditMode extends LitElement {
// The clipboard is only replaced once the section is gone, so a failed
// cut does not turn into a copy.
this.lovelace!.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass.localize(
"ui.panel.lovelace.editor.section.cut_error"
),
@@ -241,6 +244,7 @@ export class HuiSectionEditMode extends LitElement {
}
this._clipboard = deepClone(section);
this.lovelace!.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass.localize(
"ui.panel.lovelace.editor.section.cut_to_clipboard"
),
@@ -265,6 +269,7 @@ export class HuiSectionEditMode extends LitElement {
);
} catch (_err: unknown) {
this.lovelace!.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass.localize(
"ui.panel.lovelace.editor.section.paste_error"
),
@@ -54,6 +54,9 @@ export class HaGridLayoutSlider extends LitElement {
@property({ type: Number })
public range?: number;
@property({ attribute: false })
public valueText?: string;
@state()
public pressed = false;
@@ -94,10 +97,13 @@ export class HaGridLayoutSlider extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("value")) {
if (changedProps.has("value") || changedProps.has("valueText")) {
const valuenow = this._steppedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", valuenow.toString());
this.setAttribute(
"aria-valuetext",
this.valueText ?? valuenow.toString()
);
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
@@ -1,20 +1,16 @@
import { mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { conditionalClamp } from "../../../../common/number/clamp";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-grid-size-picker";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import { GRID_SIZE_PICKER_ROWS } from "../../../../components/ha-grid-size-picker";
import "../../../../components/ha-switch";
import "../../../../components/ha-yaml-editor";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import {
@@ -26,6 +22,7 @@ import type { HomeAssistant } from "../../../../types";
import type { HuiCard } from "../../cards/hui-card";
import type { CardGridSize } from "../../common/compute-card-grid-size";
import {
cardSizeToGridRows,
computeCardGridSize,
DEFAULT_GRID_SIZE,
GRID_COLUMN_MULTIPLIER,
@@ -44,9 +41,9 @@ export class HuiCardLayoutEditor extends LitElement {
@state() private _defaultGridOptions?: LovelaceGridOptions;
@state() private _yamlMode = false;
@state() private _customSize = false;
@state() private _uiAvailable = true;
@state() private _contentRows?: number;
@state() private _preciseMode = false;
@@ -62,6 +59,19 @@ export class HuiCardLayoutEditor extends LitElement {
private _computeCardGridSize = memoizeOne(computeCardGridSize);
private _recommendedSize = memoizeOne(
(options: LovelaceGridOptions, defaultOptions?: LovelaceGridOptions) => {
const limits = { ...options };
delete limits.columns;
delete limits.rows;
return computeCardGridSize({
...DEFAULT_GRID_SIZE,
...defaultOptions,
...limits,
});
}
);
private _isDefault = memoizeOne(
(options?: LovelaceGridOptions) =>
options?.columns === undefined && options?.rows === undefined
@@ -86,6 +96,10 @@ export class HuiCardLayoutEditor extends LitElement {
const gridOptions = options;
const gridValue = this._computeCardGridSize(gridOptions);
const recommended = this._recommendedSize(
configOptions,
this._defaultGridOptions
);
const columnSpan =
this.sectionConfig.column_span ?? DEFAULT_SECTION_COLUMN_SPAN;
@@ -104,44 +118,33 @@ export class HuiCardLayoutEditor extends LitElement {
`
: nothing
}
<div class="header">
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
placement="bottom-end"
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.recommended_size"
)}</span
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<ha-dropdown-item value="toggle_yaml" .disabled=${!this._uiAvailable}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
</div>
<span slot="supporting-text">${this._sizeText(recommended)}</span>
<ha-switch
slot="end"
@change=${this._recommendedSizeChanged}
.checked=${!this._customSize}
name="recommended-size"
></ha-switch>
</ha-row-item>
${
this._yamlMode
this._customSize
? html`
<ha-yaml-editor
.defaultValue=${configOptions}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
`
: html`
<ha-grid-size-picker
style=${styleMap({
"max-width": `${(this.sectionConfig.column_span ?? DEFAULT_SECTION_COLUMN_SPAN) * 250 + 40}px`,
})}
.columns=${gridTotalColumns}
.value=${gridValue}
.isDefault=${this._isDefault(configOptions)}
.autoRows=${this._contentRows}
.precise=${this._preciseMode}
@value-changed=${this._gridSizeChanged}
@precise-mode-changed=${this._preciseModeChanged}
.rowMin=${gridOptions.min_rows}
.rowMax=${gridOptions.max_rows}
.columnMin=${gridOptions.min_columns}
@@ -151,12 +154,12 @@ export class HuiCardLayoutEditor extends LitElement {
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.auto_height"
"ui.panel.lovelace.editor.edit_card.layout.fit_content"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.auto_height_helper"
"ui.panel.lovelace.editor.edit_card.layout.fit_content_helper"
)}</span
>
<ha-switch
@@ -184,29 +187,40 @@ export class HuiCardLayoutEditor extends LitElement {
name="full-width"
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.precise_mode"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.precise_mode_helper"
)}</span
>
<ha-switch
slot="end"
@change=${this._preciseModeChanged}
.checked=${this._preciseMode}
name="precise-mode"
></ha-switch>
</ha-row-item>
`
: nothing
}
`;
}
private _widthText(columns: CardGridSize["columns"]): string {
if (columns === "full") {
return this.hass.localize("ui.components.grid-size-picker.full_width");
}
return this.hass.localize("ui.components.grid-size-picker.columns_value", {
count: columns,
});
}
private _heightText(rows: CardGridSize["rows"]): string {
if (rows === "auto") {
return this.hass.localize("ui.components.grid-size-picker.fit_content");
}
return this.hass.localize("ui.components.grid-size-picker.rows_value", {
count: rows,
});
}
private _sizeText(size: CardGridSize): string {
return this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.size",
{
width: this._widthText(size.columns),
height: this._heightText(size.rows),
}
);
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
try {
@@ -232,6 +246,19 @@ export class HuiCardLayoutEditor extends LitElement {
return;
}
this._defaultGridOptions = this._cardElement.getElementGridOptions();
this._updateContentRows(this._cardElement);
}
private async _updateContentRows(cardElement: HuiCard) {
const config = this.config;
const cardSize = await cardElement.getCardSize();
if (config !== this.config) {
return;
}
this._contentRows = Math.min(
cardSizeToGridRows(cardSize),
GRID_SIZE_PICKER_ROWS
);
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -249,6 +276,10 @@ export class HuiCardLayoutEditor extends LitElement {
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
if (!this._isDefault(this._configGridOptions(this.config))) {
this._customSize = true;
}
const options = this.config.grid_options;
// Reset precise mode when grid options config is reset
@@ -267,36 +298,36 @@ export class HuiCardLayoutEditor extends LitElement {
}
}
private async _handleAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
if (action === "toggle_yaml") {
this._yamlMode = !this._yamlMode;
private _recommendedSizeChanged(
ev: HASSDomCurrentTargetEvent<HaSwitch>
): void {
ev.stopPropagation();
this._customSize = !ev.currentTarget.checked;
if (this._customSize) {
return;
}
this._updateGridOptions({
...this.config.grid_options,
columns: undefined,
rows: undefined,
});
}
private _gridSizeChanged(ev: CustomEvent): void {
ev.stopPropagation();
const value = ev.detail.value as CardGridSize;
const value = ev.detail.value as Partial<CardGridSize>;
this._updateGridOptions({
...this.config.grid_options,
columns: value.columns,
rows: value.rows,
...this._configGridOptions(this.config),
...value,
});
}
private _yamlChanged(ev: CustomEvent): void {
ev.stopPropagation();
const options = ev.detail.value as LovelaceGridOptions;
this._updateGridOptions(options);
}
private _fullWidthChanged(ev): void {
ev.stopPropagation();
const checked = ev.target.checked;
let columns: number | "full" | undefined;
let columns: number | "full";
const defaultGridOptions = {
...DEFAULT_GRID_SIZE,
...this._defaultGridOptions,
@@ -310,7 +341,7 @@ export class HuiCardLayoutEditor extends LitElement {
const gridTotalColumns = 12 * columnSpan;
columns = defaultGridOptions.max_columns ?? gridTotalColumns;
} else {
columns = undefined;
columns = defaultGridOptions.columns;
}
this._updateGridOptions({
@@ -323,7 +354,7 @@ export class HuiCardLayoutEditor extends LitElement {
ev.stopPropagation();
const checked = ev.target.checked;
let rows: number | "auto" | undefined;
let rows: number | "auto";
const defaultGridOptions = {
...DEFAULT_GRID_SIZE,
...this._defaultGridOptions,
@@ -332,9 +363,13 @@ export class HuiCardLayoutEditor extends LitElement {
rows = "auto";
} else if (defaultGridOptions.rows === "auto") {
// Default is auto height, so we need to set a specific value
rows = defaultGridOptions.min_rows ?? 1;
rows = conditionalClamp(
this._contentRows ?? 1,
defaultGridOptions.min_rows,
defaultGridOptions.max_rows
);
} else {
rows = undefined;
rows = defaultGridOptions.rows;
}
this._updateGridOptions({
@@ -345,7 +380,7 @@ export class HuiCardLayoutEditor extends LitElement {
private _preciseModeChanged(ev): void {
ev.stopPropagation();
this._preciseMode = ev.target.checked;
this._preciseMode = ev.detail.value;
if (this._preciseMode) return;
const columns = this.config.grid_options?.columns;
@@ -386,29 +421,18 @@ export class HuiCardLayoutEditor extends LitElement {
static styles = [
haStyle,
css`
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
}
.header ha-dropdown {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
margin-top: -8px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-row-item::part(supporting-text) {
white-space: normal;
}
ha-grid-size-picker {
display: block;
margin: 16px auto;
direction: ltr;
}
ha-yaml-editor {
display: block;
margin: 16px 0;
}
`,
];
}
@@ -0,0 +1,82 @@
import deepClone from "deep-clone-simple";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
import { performDeleteBadge } from "./delete-badge";
import { performDeleteCard } from "./delete-card";
import type { LovelacePath } from "./lovelace-path";
interface ClipboardItemConfigs {
card: LovelaceCardConfig;
badge: LovelaceBadgeConfig;
}
export type ClipboardItemType = keyof ClipboardItemConfigs;
type DeleteFunction = (
localize: LocalizeFunc,
lovelace: Lovelace,
params: { path: LovelacePath; silent: boolean }
) => Promise<boolean>;
const DELETE_FUNCTIONS: Record<ClipboardItemType, DeleteFunction> = {
card: performDeleteCard,
badge: performDeleteBadge,
};
// Stops a slow cut from overwriting a newer copy or cut.
let lastVersion = 0;
const clipboardVersions = { card: 0, badge: 0 };
export interface ClipboardActionParams<K extends ClipboardItemType> {
type: K;
path: LovelacePath;
config: ClipboardItemConfigs[K];
setClipboard: (config: ClipboardItemConfigs[K]) => void;
}
export function performCopy<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
{ type, config, setClipboard }: ClipboardActionParams<K>
): void {
clipboardVersions[type] = ++lastVersion;
setClipboard(deepClone(config));
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(
`ui.panel.lovelace.editor.edit_${type}.copied_to_clipboard`
),
});
}
export async function performCut<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
{ type, path, config, setClipboard }: ClipboardActionParams<K>
): Promise<void> {
const version = ++lastVersion;
const deleted = await DELETE_FUNCTIONS[type](localize, lovelace, {
path,
silent: true,
});
// The clipboard is only replaced once the item is gone, so a failed cut
// does not turn into a copy.
if (!deleted || clipboardVersions[type] > version) {
return;
}
clipboardVersions[type] = version;
setClipboard(deepClone(config));
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.cut_to_clipboard`),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: localize("ui.common.undo"),
},
});
}
+12 -7
View File
@@ -1,5 +1,6 @@
import type { HomeAssistant } from "../../../types";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -10,10 +11,10 @@ export interface DeleteBadgeParams {
}
export async function performDeleteBadge(
hass: HomeAssistant,
localize: LocalizeFunc,
lovelace: Lovelace,
params: DeleteBadgeParams
): Promise<void> {
): Promise<boolean> {
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
@@ -21,22 +22,26 @@ export async function performDeleteBadge(
await lovelace.saveConfig(newConfig);
if (silent) {
return;
return true;
}
lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"),
id: LOVELACE_EDIT_TOAST_ID,
message: localize("ui.common.successfully_deleted"),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
text: localize("ui.common.undo"),
},
});
return true;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
lovelace.showToast({
message: hass.localize("ui.common.deleting_failed"),
id: LOVELACE_EDIT_TOAST_ID,
message: localize("ui.common.deleting_failed"),
});
return false;
}
}
+12 -7
View File
@@ -1,5 +1,6 @@
import type { HomeAssistant } from "../../../types";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -10,10 +11,10 @@ export interface DeleteCardParams {
}
export async function performDeleteCard(
hass: HomeAssistant,
localize: LocalizeFunc,
lovelace: Lovelace,
params: DeleteCardParams
): Promise<void> {
): Promise<boolean> {
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
@@ -21,22 +22,26 @@ export async function performDeleteCard(
await lovelace.saveConfig(newConfig);
if (silent) {
return;
return true;
}
lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"),
id: LOVELACE_EDIT_TOAST_ID,
message: localize("ui.common.successfully_deleted"),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
text: localize("ui.common.undo"),
},
});
return true;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
lovelace.showToast({
message: hass.localize("ui.common.deleting_failed"),
id: LOVELACE_EDIT_TOAST_ID,
message: localize("ui.common.deleting_failed"),
});
return false;
}
}
@@ -54,6 +54,7 @@ import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
@@ -401,6 +402,7 @@ export class HuiDialogEditSection
])
);
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.success"
),
@@ -416,6 +418,7 @@ export class HuiDialogEditSection
this.closeDialog();
} catch (err: any) {
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.error"
),
@@ -444,6 +447,7 @@ export class HuiDialogEditSection
await this.lovelace.saveConfig(deleteAtPath(oldFromConfig, fromPath));
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.success"
),
@@ -460,6 +464,7 @@ export class HuiDialogEditSection
this.closeDialog();
} catch (err: any) {
this.lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.error"
),
@@ -59,6 +59,7 @@ import "./hui-view-editor";
import "./hui-view-visibility-editor";
import type { EditViewDialogParams } from "./show-edit-view-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
const TABS = ["tab-settings", "tab-background", "tab-visibility"] as const;
@@ -439,6 +440,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
});
} else {
this._lovelace!.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.editor.select_dashboard.success"
),
+2
View File
@@ -96,6 +96,7 @@ import "./views/hui-view";
import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background";
import "./views/hui-view-container";
import { LOVELACE_EDIT_TOAST_ID } from "./types";
interface ActionItem {
icon: string;
@@ -1287,6 +1288,7 @@ class HUIRoot extends LitElement {
} catch (err: any) {
this._configChangedByUndo = false;
showToast(this, {
id: LOVELACE_EDIT_TOAST_ID,
message: this.hass.localize(
"ui.panel.lovelace.editor.undo_redo_failed_to_apply_changes",
{
+4
View File
@@ -27,6 +27,10 @@ declare global {
}
}
// Shared ID for dashboard edit toasts (copy, cut, delete, move...), so a new
// toast replaces the previous one instead of stacking up.
export const LOVELACE_EDIT_TOAST_ID = "lovelace-edit";
export interface Lovelace {
config: LovelaceConfig;
rawConfig: LovelaceRawConfig;
+76 -44
View File
@@ -1,11 +1,9 @@
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
import { deepEqual } from "../../../common/util/deep-equal";
@@ -35,6 +33,8 @@ import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dial
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { getCardSectionConfig } from "../editor/config-util";
import type { ClipboardActionParams } from "../editor/clipboard-actions";
import { performCopy, performCut } from "../editor/clipboard-actions";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -69,11 +69,13 @@ declare global {
"ll-edit-card": { path: LovelacePath };
"ll-delete-card": DeleteCardParams;
"ll-duplicate-card": { path: LovelacePath };
"ll-copy-card": { path: LovelacePath; silent?: boolean };
"ll-copy-card": { path: LovelacePath };
"ll-cut-card": { path: LovelacePath };
"ll-create-badge": { path: LovelacePath };
"ll-edit-badge": { path: LovelacePath };
"ll-delete-badge": DeleteBadgeParams;
"ll-copy-badge": { path: LovelacePath };
"ll-cut-badge": { path: LovelacePath };
}
interface HTMLElementEventMap {
"ll-create-card": HASSDomEvent<HASSDomEvents["ll-create-card"]>;
@@ -85,6 +87,8 @@ declare global {
"ll-create-badge": HASSDomEvent<HASSDomEvents["ll-create-badge"]>;
"ll-edit-badge": HASSDomEvent<HASSDomEvents["ll-edit-badge"]>;
"ll-delete-badge": HASSDomEvent<HASSDomEvents["ll-delete-badge"]>;
"ll-copy-badge": HASSDomEvent<HASSDomEvents["ll-copy-badge"]>;
"ll-cut-badge": HASSDomEvent<HASSDomEvents["ll-cut-badge"]>;
}
}
@@ -129,6 +133,14 @@ export class HUIView extends ReactiveElement {
})
protected _clipboard?: LovelaceCardConfig;
@storage({
key: "dashboardBadgeClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _badgeClipboard?: LovelaceBadgeConfig;
constructor() {
super();
this.addEventListener(
@@ -174,7 +186,7 @@ export class HUIView extends ReactiveElement {
"ll-delete-card",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-card"]>) => {
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, {
performDeleteCard(this.hass.localize, this.lovelace, {
...ev.detail,
path: normalizeCardPath(ev.detail.path),
});
@@ -211,50 +223,19 @@ export class HUIView extends ReactiveElement {
this.addEventListener(
"ll-copy-card",
(ev: HASSDomEvent<HASSDomEvents["ll-copy-card"]>) => {
if (!this.lovelace) return;
const cardConfig = this._getCardConfig(
normalizeCardPath(ev.detail.path)
);
if (!cardConfig) {
return;
const params = this._getCardClipboardParams(ev.detail.path);
if (params) {
performCopy(this.hass.localize, this.lovelace, params);
}
this._clipboard = deepClone(cardConfig);
if (ev.detail.silent) {
return;
}
this.lovelace.showToast({
message: this.hass.localize(
"ui.panel.lovelace.editor.edit_card.copied_to_clipboard"
),
});
}
);
this.addEventListener(
"ll-cut-card",
async (ev: HASSDomEvent<HASSDomEvents["ll-cut-card"]>) => {
fireEvent(this, "ll-copy-card", {
path: ev.detail.path!,
silent: true,
});
await performDeleteCard(this.hass, this.lovelace, {
...ev.detail,
silent: true,
path: normalizeCardPath(ev.detail.path),
});
this.lovelace.showToast({
message: this.hass.localize(
"ui.panel.lovelace.editor.edit_card.cut_to_clipboard"
),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: this.hass.localize("ui.common.undo"),
},
});
(ev: HASSDomEvent<HASSDomEvents["ll-cut-card"]>) => {
const params = this._getCardClipboardParams(ev.detail.path);
if (params) {
performCut(this.hass.localize, this.lovelace, params);
}
}
);
this.addEventListener(
@@ -296,9 +277,60 @@ export class HUIView extends ReactiveElement {
"ll-delete-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-badge"]>) => {
if (!this.lovelace) return;
performDeleteBadge(this.hass, this.lovelace, ev.detail);
performDeleteBadge(this.hass.localize, this.lovelace, ev.detail);
}
);
this.addEventListener(
"ll-copy-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-copy-badge"]>) => {
const params = this._getBadgeClipboardParams(ev.detail.path);
if (params) {
performCopy(this.hass.localize, this.lovelace, params);
}
}
);
this.addEventListener(
"ll-cut-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-cut-badge"]>) => {
const params = this._getBadgeClipboardParams(ev.detail.path);
if (params) {
performCut(this.hass.localize, this.lovelace, params);
}
}
);
}
private _getCardClipboardParams(
path: LovelacePath
): ClipboardActionParams<"card"> | undefined {
if (!this.lovelace) return undefined;
const cardPath = normalizeCardPath(path);
const config = this._getCardConfig(cardPath);
if (!config) return undefined;
return {
type: "card",
path: cardPath,
config,
setClipboard: (clipboard) => {
this._clipboard = clipboard;
},
};
}
private _getBadgeClipboardParams(
path: LovelacePath
): ClipboardActionParams<"badge"> | undefined {
if (!this.lovelace) return undefined;
const config = getAtPath<LovelaceBadgeConfig>(this.lovelace.config, path);
if (!config) return undefined;
return {
type: "badge",
path,
config,
setClipboard: (clipboard) => {
this._badgeClipboard = clipboard;
},
};
}
private _getCardConfig(path: LovelacePath): LovelaceCardConfig | undefined {
@@ -29,7 +29,7 @@ export const maintenanceEntityFilters: EntityFilter[] = [
},
{
domain: "binary_sensor",
device_class: ["battery"],
device_class: ["battery", "problem"],
},
];
@@ -48,7 +48,8 @@ export const filterLowBatteryEntities = (
const state = states[entityId]?.state ?? "";
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
const device_class = states[entityId]?.attributes?.device_class ?? "";
return state === BINARY_STATE_ON && device_class === "battery";
}
const stateValue = parseFloat(state);
@@ -73,6 +74,16 @@ export const filterLowBatteryEntities = (
});
};
export const filterProblemEntities = (
states: HomeAssistant["states"],
entityIds: string[]
): string[] =>
entityIds.filter((entityId) => {
const state = states[entityId]?.state ?? "";
const device_class = states[entityId]?.attributes?.device_class ?? "";
return device_class === "problem" && state === BINARY_STATE_ON;
});
export const filterUnavailableBatteryEntities = (
states: HomeAssistant["states"],
entityIds: string[]
@@ -30,8 +30,8 @@ import { QueryParamsController } from "../../../common/controllers/query-params-
import { navigate } from "../../../common/navigate";
import { formatNumber } from "../../../common/number/format_number";
import {
createMarketplaceFilterQueryString,
marketplaceFilterQueryParamConfig,
sortedMarketplaceFilterQueryString,
} from "../../../common/url/marketplace-query-params";
import { shallowEqual } from "../../../common/util/shallow-equal";
import type {
@@ -1008,8 +1008,8 @@ export class HaMarketplaceDashboard extends LitElement {
// URL holds is the user's, so what a link applied isn't stored.
private _isUserChange(filters: RepositoryFilters): boolean {
return (
createMarketplaceFilterQueryString(filtersOfTab(filters, this.tab)) !==
createMarketplaceFilterQueryString(filtersOfTab(this._filters, this.tab))
sortedMarketplaceFilterQueryString(filtersOfTab(filters, this.tab)) !==
sortedMarketplaceFilterQueryString(filtersOfTab(this._filters, this.tab))
);
}
+73 -1
View File
@@ -29,6 +29,14 @@ const usersTableParams = {
group: "string?",
} satisfies Redirect["params"];
// The Bluetooth monitor tables read these from the link
const bluetoothTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
} satisfies Redirect["params"];
// The Marketplace tabs with a table read these from the link
const marketplaceTableParams = {
search: "string?",
@@ -40,6 +48,13 @@ const marketplaceTableParams = {
group: "string?",
} satisfies Redirect["params"];
// The labels table reads these from the link
const labelsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
} satisfies Redirect["params"];
// The helpers table reads these from the link
const helpersTableParams = {
search: "string?",
@@ -54,6 +69,22 @@ const helpersTableParams = {
assistants: "string?",
} satisfies Redirect["params"];
// The LoRaWAN devices table reads these from the link
const lorawanDevicesParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
connection: "string?",
} satisfies Redirect["params"];
// The tags table reads these from the link
const tagsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -173,6 +204,15 @@ export const getMyRedirects = (): Redirects => ({
component: "thread",
redirect: "/config/thread",
},
config_lorawan: {
component: "lorawan",
redirect: "/config/lorawan/overview",
},
lorawan_devices: {
component: "lorawan",
redirect: "/config/lorawan/devices",
params: lorawanDevicesParams,
},
add_zigbee_device: {
component: "zha",
redirect: "/config/zha/add",
@@ -188,10 +228,12 @@ export const getMyRedirects = (): Redirects => ({
bluetooth_advertisement_monitor: {
component: "bluetooth",
redirect: "/config/bluetooth/advertisement-monitor",
params: bluetoothTableParams,
},
bluetooth_connection_monitor: {
component: "bluetooth",
redirect: "/config/bluetooth/connection-monitor",
params: bluetoothTableParams,
},
bluetooth_visualization: {
component: "bluetooth",
@@ -249,6 +291,7 @@ export const getMyRedirects = (): Redirects => ({
},
labels: {
redirect: "/config/labels",
params: labelsTableParams,
},
energy: {
component: "energy",
@@ -294,6 +337,7 @@ export const getMyRedirects = (): Redirects => ({
tags: {
component: "tag",
redirect: "/config/tags",
params: tagsTableParams,
},
voice_assistants: {
redirect: "/config/voice-assistants",
@@ -301,10 +345,12 @@ export const getMyRedirects = (): Redirects => ({
lovelace_dashboards: {
component: "lovelace",
redirect: "/config/lovelace/dashboards",
params: dashboardsTableParams,
},
lovelace_resources: {
component: "lovelace",
redirect: "/config/lovelace/resources",
params: dashboardResourcesTableParams,
},
oauth: {
redirect: "/auth/external/callback",
@@ -334,7 +380,7 @@ export const getMyRedirects = (): Redirects => ({
redirect: "/config/logs",
params: {
provider: "string?",
filter: "string?",
search: "string?",
integration: "string?",
raw: "string?",
},
@@ -416,6 +462,7 @@ export const getMyRedirects = (): Redirects => ({
backup_list: {
component: "backup",
redirect: "/config/backup/backups",
params: backupsTableParams,
},
backup_config: {
component: "backup",
@@ -530,6 +577,16 @@ export const getMyRedirects = (): Redirects => ({
const getRedirect = (path: string): Redirect | undefined =>
getMyRedirects()?.[path];
// The backups table reads these from the link
const backupsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
type: "string?",
location: "string?",
} satisfies Redirect["params"];
export type ParamType = "url" | "string" | "string?";
export type Redirects = Record<string, Redirect>;
@@ -542,6 +599,21 @@ export interface Redirect {
optional_params?: Record<string, ParamType>;
}
// The dashboards table reads these from the link
const dashboardsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
} satisfies Redirect["params"];
// The dashboard resources table reads these from the link
const dashboardResourcesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
} satisfies Redirect["params"];
@customElement("ha-panel-my")
class HaPanelMy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
+16 -8
View File
@@ -240,6 +240,7 @@
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
"count_maintenance_low_battery_issues": "{count} {count, plural,\n one {low battery}\n other {low batteries}\n}",
"count_maintenance_issues_unavailable_battery_entities": "{count} {count, plural,\n one {unavailable device}\n other {unavailable devices}\n}",
"count_maintenance_problem_issues": "{count} {count, plural,\n one {problem}\n other {problems}\n}",
"all_maintenance_good": "All good",
"count_persons_home": "{count} {count, plural,\n one {person}\n other {people}\n} home",
"nobody_home": "No one home"
@@ -1205,9 +1206,14 @@
}
},
"grid-size-picker": {
"reset_default": "Reset to default size",
"columns": "Number of columns",
"rows": "Number of rows"
"columns": "Width",
"rows": "Height",
"columns_value": "{count, plural, one {# column} other {# columns}}",
"rows_value": "{count, plural, one {# row} other {# rows}}",
"full_width": "Full width",
"fit_content": "Fit content",
"enter_precise_mode": "Enter precise mode",
"exit_precise_mode": "Exit precise mode"
},
"relative_time": {
"never": "Never"
@@ -10300,12 +10306,12 @@
"tab_layout": "Layout",
"paste_condition": "Paste condition",
"layout": {
"recommended_size": "Use recommended size",
"size": "{width} × {height}",
"full_width": "Full width",
"full_width_helper": "Take up the full width of the section whatever its size",
"auto_height": "Auto height",
"auto_height_helper": "Adjust the card height based on its content",
"precise_mode": "Precise mode",
"precise_mode_helper": "Change the card width with more precision",
"full_width_helper": "Always fill the section, whatever its width",
"fit_content": "Fit height to content",
"fit_content_helper": "Make the card as tall as its content",
"no_grid_support": "This card does not fully support resizing yet and may not display correctly with custom sizes."
}
},
@@ -10328,6 +10334,8 @@
"delete": "[%key:ui::panel::lovelace::editor::edit_card::delete%]",
"copy": "[%key:ui::panel::lovelace::editor::edit_card::copy%]",
"cut": "[%key:ui::panel::lovelace::editor::edit_card::cut%]",
"copied_to_clipboard": "Badge copied to clipboard",
"cut_to_clipboard": "Badge cut to clipboard",
"duplicate": "[%key:ui::panel::lovelace::editor::edit_card::duplicate%]",
"tab_config": "[%key:ui::panel::lovelace::editor::edit_card::tab_config%]",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_card::tab_visibility%]"
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
BACKUP_LOCATIONS_FILTER,
BACKUP_TYPE_FILTER,
backupsFilterQueryParamConfig,
backupsFilterQueryParams,
backupsFilterQueryString,
backupsLinkFromUrl,
} from "../../../src/common/url/backups-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
describe("backups query params", () => {
it("reads nothing without table params", () => {
expect(backupsLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(backupsLinkFromUrl("?location=hassio.local")).toEqual({
search: "",
filters: {
[BACKUP_TYPE_FILTER]: [],
[BACKUP_LOCATIONS_FILTER]: ["hassio.local"],
},
});
});
it("reads type=all as every type", () => {
expect(
backupsLinkFromUrl("?type=all")?.filters[BACKUP_TYPE_FILTER]
).toEqual([]);
});
it("round trips the filters", () => {
const filters = {
[BACKUP_TYPE_FILTER]: ["automatic", "manual"],
[BACKUP_LOCATIONS_FILTER]: ["hassio.local", "cloud.cloud"],
};
expect(
backupsLinkFromUrl(
createQueryString(
backupsFilterQueryParams(filters),
backupsFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("compares the same filters in another order as equal", () => {
expect(
backupsFilterQueryString({
[BACKUP_TYPE_FILTER]: ["manual", "automatic"],
})
).toBe(
backupsFilterQueryString({
[BACKUP_TYPE_FILTER]: ["automatic", "manual"],
})
);
expect(backupsFilterQueryString({ [BACKUP_TYPE_FILTER]: [] })).toBe(
backupsFilterQueryString({})
);
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
helpersFilterQueryParamConfig,
helpersFilterQueryParams,
helpersFilterQueryString,
helpersLinkFromUrl,
} from "../../../src/common/url/helpers-query-params";
import {
@@ -71,4 +72,13 @@ describe("helpers query params", () => {
)
).toEqual({ search: "", filters });
});
it("compares the same filters in another order as equal", () => {
expect(helpersFilterQueryString({ "ha-filter-labels": ["b", "a"] })).toBe(
helpersFilterQueryString({ "ha-filter-labels": ["a", "b"] })
);
expect(helpersFilterQueryString({ "ha-filter-labels": [] })).toBe(
helpersFilterQueryString({})
);
});
});
+39 -6
View File
@@ -1,11 +1,24 @@
import { describe, expect, it } from "vitest";
import {
logSearchFromUrl,
logsLinkFromUrl,
logsQueryParamConfig,
logsQueryParams,
} from "../../../src/common/url/logs-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
import {
createQueryString,
updateQueryString,
} from "../../../src/common/url/query-params";
describe("logSearchFromUrl", () => {
it("reads search, then the old filter name", () => {
expect(logSearchFromUrl("?search=zwave_js")).toBe("zwave_js");
expect(logSearchFromUrl("?filter=zwave_js")).toBe("zwave_js");
expect(logSearchFromUrl("?search=new&filter=old")).toBe("new");
expect(logSearchFromUrl("")).toBe("");
});
});
describe("logs query params", () => {
it("reads nothing without logs params", () => {
@@ -13,21 +26,26 @@ describe("logs query params", () => {
});
it("reads the integrations, search and provider of a link", () => {
expect(logsLinkFromUrl("?integration=hue,mqtt&filter=timeout")).toEqual({
expect(logsLinkFromUrl("?integration=hue,mqtt&search=timeout")).toEqual({
provider: "core",
filter: "timeout",
search: "timeout",
integrations: ["hue", "mqtt"],
raw: false,
});
expect(logsLinkFromUrl("?provider=supervisor")).toEqual({
provider: "supervisor",
filter: "",
search: "",
integrations: [],
raw: false,
});
expect(logsLinkFromUrl("?integration=hue&raw=1")?.raw).toBe(true);
});
it("reads the old filter name as the search", () => {
expect(logsLinkFromUrl("?filter=zwave_js")?.search).toBe("zwave_js");
expect(logsLinkFromUrl("?search=new&filter=old")?.search).toBe("new");
});
it("ignores integrations and the raw view for other providers", () => {
const link = logsLinkFromUrl("?provider=supervisor&integration=hue&raw=1");
expect(link?.integrations).toEqual([]);
@@ -39,7 +57,7 @@ describe("logs query params", () => {
createQueryString(
logsQueryParams({
provider: "core",
filter: "",
search: "",
integrations: [],
raw: false,
}),
@@ -48,10 +66,25 @@ describe("logs query params", () => {
).toBe("");
});
it("replaces the old filter name with search", () => {
expect(
updateQueryString(
"?filter=old&other=1",
logsQueryParams({
provider: "core",
search: "old",
integrations: [],
raw: false,
}),
logsQueryParamConfig
)
).toBe("other=1&search=old");
});
it("round trips the state", () => {
const state = {
provider: "core",
filter: "timeout",
search: "timeout",
integrations: ["hue", "mqtt"],
raw: true,
};
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { deepEqual } from "../../../src/common/util/deep-equal";
describe("deepEqual", () => {
it("ignores key order by default", () => {
expect(
deepEqual({ a: 1, b: { c: 2, d: 3 } }, { b: { d: 3, c: 2 }, a: 1 })
).toBe(true);
});
it("detects reordered keys at any depth with compareKeyOrder", () => {
const options = { compareKeyOrder: true };
expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }, options)).toBe(false);
expect(
deepEqual(
{ list: [{ entity_id: "light.a", to: "on" }] },
{ list: [{ to: "on", entity_id: "light.a" }] },
options
)
).toBe(false);
expect(
deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, options)
).toBe(true);
});
});
@@ -0,0 +1,108 @@
import { describe, expect, test, vi } from "vitest";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import "../../../../src/panels/config/script/ha-script-editor";
import type { HaScriptEditor } from "../../../../src/panels/config/script/ha-script-editor";
import { createMockHass } from "../../../fixtures/hass";
const AUTOMATION = {
id: "kitchen",
alias: "Kitchen",
triggers: [{ trigger: "state", entity_id: "light.kitchen", to: "on" }],
conditions: [],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.hall" },
data: { brightness: 255 },
},
],
mode: "single",
};
const SCRIPT = {
alias: "Hall",
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.hall" },
data: { brightness: 255 },
},
],
mode: "single",
};
// Same action with the data block moved above target, like Alt+Up in YAML
const reorderAction = (action: (typeof SCRIPT.sequence)[number]) => ({
action: action.action,
data: action.data,
target: action.target,
});
const loadEditor = async <T extends HaAutomationEditor | HaScriptEditor>(
tag: "ha-automation-editor" | "ha-script-editor",
config: unknown
): Promise<T> => {
const el = document.createElement(tag) as T;
const hass = createMockHass();
(hass as any).callApi = vi.fn(async () => structuredClone(config));
el.hass = hass;
Object.defineProperty(el, "isConnected", {
configurable: true,
get: () => true,
});
await (el as any).loadConfig("kitchen");
return el;
};
const editYaml = (el: HaAutomationEditor | HaScriptEditor, value: unknown) =>
(el as any)._yamlChanged({
stopPropagation: () => undefined,
detail: { isValid: true, value },
});
describe("YAML editor dirty state", () => {
test("automation editor is dirty when keys are only reordered", async () => {
const el = await loadEditor<HaAutomationEditor>(
"ha-automation-editor",
AUTOMATION
);
const { id: _id, ...yaml } = structuredClone(AUTOMATION);
editYaml(el, yaml);
expect(el.isDirtyState).toBe(false);
editYaml(el, { ...yaml, actions: [reorderAction(yaml.actions[0])] });
expect(el.isDirtyState).toBe(true);
});
test("automation editor ignores where the hidden id was in the file", async () => {
const { id, ...rest } = AUTOMATION;
const el = await loadEditor<HaAutomationEditor>("ha-automation-editor", {
...rest,
id,
});
const yaml = structuredClone(rest);
editYaml(el, yaml);
expect(el.isDirtyState).toBe(false);
editYaml(el, { ...yaml, actions: [reorderAction(yaml.actions[0])] });
expect(el.isDirtyState).toBe(true);
editYaml(el, yaml);
expect(el.isDirtyState).toBe(false);
});
test("script editor is dirty when keys are only reordered", async () => {
const el = await loadEditor<HaScriptEditor>("ha-script-editor", SCRIPT);
const yaml = structuredClone(SCRIPT);
editYaml(el, yaml);
expect(el.isDirtyState).toBe(false);
editYaml(el, { ...yaml, sequence: [reorderAction(yaml.sequence[0])] });
expect(el.isDirtyState).toBe(true);
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import {
performCopy,
performCut,
} from "../../../../src/panels/lovelace/editor/clipboard-actions";
import type { Lovelace } from "../../../../src/panels/lovelace/types";
const CARD_A: LovelaceCardConfig = { type: "card-a" };
const CARD_B: LovelaceCardConfig = { type: "card-b" };
const localize = (key: string) => key;
const createLovelace = (saveConfig: Lovelace["saveConfig"]) => {
const config: LovelaceConfig = { views: [{ cards: [CARD_A, CARD_B] }] };
return {
config,
rawConfig: config,
saveConfig,
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
};
const cardParams = (
config: LovelaceCardConfig,
index: number,
setClipboard: (config: LovelaceCardConfig) => void
) => ({
type: "card" as const,
path: [0, index] as [number, number],
config,
setClipboard,
});
describe("performCut", () => {
it("only fills the clipboard when the delete succeeds", async () => {
const setClipboard = vi.fn();
await performCut(
localize,
createLovelace(() => Promise.resolve()),
cardParams(CARD_A, 0, setClipboard)
);
expect(setClipboard).toHaveBeenCalledWith(CARD_A);
setClipboard.mockClear();
vi.spyOn(console, "error").mockImplementation(() => undefined);
await performCut(
localize,
createLovelace(() => Promise.reject(new Error("save failed"))),
cardParams(CARD_A, 0, setClipboard)
);
expect(setClipboard).not.toHaveBeenCalled();
});
it("does not overwrite a newer copy when a slow cut finishes", async () => {
let finishSave!: () => void;
const lovelace = createLovelace(
() =>
new Promise<void>((resolve) => {
finishSave = resolve;
})
);
let clipboard: LovelaceCardConfig | undefined;
const setClipboard = (config: LovelaceCardConfig) => {
clipboard = config;
};
const cut = performCut(
localize,
lovelace,
cardParams(CARD_A, 0, setClipboard)
);
performCopy(localize, lovelace, cardParams(CARD_B, 1, setClipboard));
finishSave();
await cut;
expect(clipboard).toEqual(CARD_B);
});
it("does not let a newer failed cut block an older successful one", async () => {
let finishSaveA!: () => void;
let failSaveB!: () => void;
const lovelaceA = createLovelace(
() =>
new Promise<void>((resolve) => {
finishSaveA = resolve;
})
);
const lovelaceB = createLovelace(
() =>
new Promise<void>((_resolve, reject) => {
failSaveB = () => reject(new Error("save failed"));
})
);
let clipboard: LovelaceCardConfig | undefined;
const setClipboard = (config: LovelaceCardConfig) => {
clipboard = config;
};
vi.spyOn(console, "error").mockImplementation(() => undefined);
const cutA = performCut(
localize,
lovelaceA,
cardParams(CARD_A, 0, setClipboard)
);
const cutB = performCut(
localize,
lovelaceB,
cardParams(CARD_B, 1, setClipboard)
);
failSaveB();
await cutB;
finishSaveA();
await cutA;
expect(clipboard).toEqual(CARD_A);
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import {
filterLowBatteryEntities,
filterProblemEntities,
} from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
@@ -98,6 +101,28 @@ describe("filterLowBatteryEntities", () => {
).toEqual(["binary_sensor.low_battery"]);
});
it("binary sensor problems filtered", () => {
const hass = createMockHass({
"binary_sensor.has_problem": createMockEntityState(
"binary_sensor.has_problem",
"on",
{ device_class: "problem" }
),
"binary_sensor.no_problem": createMockEntityState(
"binary_sensor.no_problem",
"off",
{ device_class: "problem" }
),
});
expect(
filterLowBatteryEntities(hass.states, hass.entities, [
"binary_sensor.has_problem",
"binary_sensor.no_problem",
])
).toEqual([]);
});
it("updates the device lookup when the entity registry changes", () => {
const states = {
"sensor.battery": createMockEntityState("sensor.battery", "10", {
@@ -149,3 +174,38 @@ describe("filterLowBatteryEntities", () => {
).toEqual(["sensor.battery"]);
});
});
describe("filterProblemEntities", () => {
it("filters only problems", () => {
const hass = createMockHass({
"binary_sensor.has_problem": createMockEntityState(
"binary_sensor.has_problem",
"on",
{ device_class: "problem" }
),
"binary_sensor.no_problem": createMockEntityState(
"binary_sensor.no_problem",
"off",
{ device_class: "problem" }
),
"binary_sensor.other_on": createMockEntityState(
"binary_sensor.other_on",
"on",
{ device_class: "other" }
),
"binary_sensor.other_off": createMockEntityState(
"binary_sensor.other_off",
"off",
{ device_class: "other" }
),
});
expect(
filterProblemEntities(hass.states, [
"binary_sensor.has_problem",
"binary_sensor.no_problem",
"binary_sensor.other_on",
"binary_sensor.other_off",
])
).toEqual(["binary_sensor.has_problem"]);
});
});