mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-22 13:44:54 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4851ff7b21 | ||
|
|
de2b32a33a | ||
|
|
ab5372c980 | ||
|
|
7883bee61d | ||
|
|
3bd5e99243 | ||
|
|
fa72f6545b | ||
|
|
a1f54064cf | ||
|
|
7ab69e7da0 | ||
|
|
246a88b142 | ||
|
|
71b7218b15 | ||
|
|
beb089822a | ||
|
|
693940448d | ||
|
|
0f628702d8 | ||
|
|
86980368a9 | ||
|
|
d9f7180aa5 | ||
|
|
66b084da01 |
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
@@ -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",
|
||||
|
||||
Generated
+9
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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) ?? [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+13
-16
@@ -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;
|
||||
}
|
||||
|
||||
+13
-16
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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"),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user