Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47329f348f | ||
|
|
ce801c9057 | ||
|
|
2f06c77961 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,118 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} 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 filters of the scripts table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const scriptsFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
"blueprint",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type ScriptsFilterQueryParams = QueryParamValues<
|
||||
typeof scriptsFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const scriptsFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): ScriptsFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
blueprint: list(filters["ha-filter-blueprints"]),
|
||||
});
|
||||
|
||||
// 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 scriptsFilterQueryString = (filters: DataTableFilters): string => {
|
||||
const params = scriptsFilterQueryParams(filters);
|
||||
const sorted: ScriptsFilterQueryParams = {};
|
||||
|
||||
for (const key of scriptsFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, scriptsFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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. The filters work out their items from the values.
|
||||
export interface ScriptsLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const scriptsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): ScriptsLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, scriptsFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, scriptsFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof ScriptsFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -60,6 +60,11 @@ export class HaAnsiToHtml extends LitElement {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
CSS.highlights?.delete("search-results");
|
||||
}
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiArrowCollapseDown } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import "./ha-ansi-to-html";
|
||||
import type { HaAnsiToHtml } from "./ha-ansi-to-html";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const SCROLL_FOLLOW_MARGIN = 40;
|
||||
|
||||
@customElement("ha-log-viewer")
|
||||
export class HaLogViewer extends LitElement {
|
||||
@property({ type: Number, attribute: "max-lines" }) public maxLines = 5000;
|
||||
|
||||
@property() public filter = "";
|
||||
|
||||
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
|
||||
false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state() private _newLogsIndicator = false;
|
||||
|
||||
@state() private _noMatches = false;
|
||||
|
||||
@query(".log") private _logElement!: HTMLElement;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtml!: HaAnsiToHtml;
|
||||
|
||||
private _lines: string[] = [];
|
||||
|
||||
private _followFrame?: number;
|
||||
|
||||
private _ready = false;
|
||||
|
||||
public get lines(): string[] {
|
||||
return this._lines;
|
||||
}
|
||||
|
||||
public addLines(lines: string[]) {
|
||||
const newLines = lines
|
||||
.flatMap((line) => line.split("\n"))
|
||||
.slice(-this.maxLines);
|
||||
if (!newLines.length) {
|
||||
return;
|
||||
}
|
||||
const overflow = this._lines.length + newLines.length > this.maxLines;
|
||||
this._lines = this._lines.concat(newLines).slice(-this.maxLines);
|
||||
if (!this._ready) {
|
||||
return;
|
||||
}
|
||||
const follow = this._isScrolledToBottom();
|
||||
if (overflow) {
|
||||
this._trimLines(this._lines.length - newLines.length);
|
||||
}
|
||||
this._renderLines(newLines);
|
||||
if (follow) {
|
||||
this._followBottom();
|
||||
} else {
|
||||
this._newLogsIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this._cancelFollowBottom();
|
||||
this._lines = [];
|
||||
this._newLogsIndicator = false;
|
||||
this._noMatches = false;
|
||||
if (this._ready) {
|
||||
this._ansiToHtml.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public scrollToBottom() {
|
||||
this._newLogsIndicator = false;
|
||||
if (this._ready) {
|
||||
this._logElement.scrollTo(0, this._logElement.scrollHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._cancelFollowBottom();
|
||||
this._debouncedFilter.cancel();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="log" @scroll=${this._handleScroll}>
|
||||
<slot></slot>
|
||||
${
|
||||
this._noMatches
|
||||
? html`<div class="no-matches">
|
||||
${this._i18n.localize("ui.components.log_viewer.no_matches")}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-ansi-to-html ?wrap-disabled=${this.wrapDisabled}></ha-ansi-to-html>
|
||||
</div>
|
||||
<ha-button
|
||||
class="new-logs-indicator ${classMap({
|
||||
visible: this._newLogsIndicator,
|
||||
})}"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${!this._newLogsIndicator}
|
||||
@click=${this.scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
|
||||
${this._i18n.localize("ui.components.log_viewer.scroll_down_button")}
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
await this._ansiToHtml.updateComplete;
|
||||
this._renderLines(this._lines);
|
||||
this._ready = true;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("filter") &&
|
||||
changedProps.get("filter") !== undefined
|
||||
) {
|
||||
this._debouncedFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private _renderLines(lines: string[]) {
|
||||
this._ansiToHtml.parseLinesToColoredPre(lines);
|
||||
if (this.filter) {
|
||||
this._applyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private _debouncedFilter = debounce(() => {
|
||||
this._applyFilter();
|
||||
if (!this.filter) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
private _applyFilter() {
|
||||
this._noMatches =
|
||||
this._lines.length > 0 && !this._ansiToHtml.filterLines(this.filter);
|
||||
}
|
||||
|
||||
private _trimLines(keptLines: number) {
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeTrim = logElement.scrollTop;
|
||||
const scrollHeightBeforeTrim = logElement.scrollHeight;
|
||||
this._ansiToHtml.trimLines(keptLines);
|
||||
const removedHeight = scrollHeightBeforeTrim - logElement.scrollHeight;
|
||||
logElement.scrollTop = Math.max(0, scrollTopBeforeTrim - removedHeight);
|
||||
}
|
||||
|
||||
private _isScrolledToBottom(): boolean {
|
||||
const log = this._logElement;
|
||||
return (
|
||||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
private _handleScroll() {
|
||||
if (this._newLogsIndicator && this._isScrolledToBottom()) {
|
||||
this._newLogsIndicator = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _followBottom() {
|
||||
this._cancelFollowBottom();
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeFrame = logElement.scrollTop;
|
||||
this._followFrame = requestAnimationFrame(() => {
|
||||
this._followFrame = undefined;
|
||||
if (logElement.scrollTop === scrollTopBeforeFrame) {
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelFollowBottom() {
|
||||
if (this._followFrame !== undefined) {
|
||||
cancelAnimationFrame(this._followFrame);
|
||||
this._followFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
direction: ltr;
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.no-matches {
|
||||
color: var(--secondary-text-color);
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
.new-logs-indicator {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
inset-inline-start: 4px;
|
||||
height: 0;
|
||||
transition: height 0.4s ease-out;
|
||||
}
|
||||
.new-logs-indicator.visible {
|
||||
height: 32px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-logs-indicator {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-log-viewer": HaLogViewer;
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,18 @@ import {
|
||||
type UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import {
|
||||
mdiArrowCollapseDown,
|
||||
mdiCircle,
|
||||
mdiWrap,
|
||||
mdiWrapDisabled,
|
||||
} from "@mdi/js";
|
||||
import { mdiCircle, mdiWrap, mdiWrapDisabled } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-ansi-to-html";
|
||||
import type { HaAnsiToHtml } from "../../../components/ha-ansi-to-html";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-log-viewer";
|
||||
import type { HaLogViewer } from "../../../components/ha-log-viewer";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
@@ -40,12 +33,6 @@ import type { LiveLogDialogParams } from "./show-dialog-live-log";
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
// Stay close to the bottom to keep following new lines
|
||||
const SCROLL_FOLLOW_MARGIN = 40;
|
||||
|
||||
// Keep only the newest lines so memory stays bounded
|
||||
const DEFAULT_LOG_LINE_LIMIT = 5000;
|
||||
|
||||
@customElement("dialog-live-log")
|
||||
class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
@state()
|
||||
@@ -64,29 +51,16 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
|
||||
@state() private _live = false;
|
||||
|
||||
@state() private _newLogsIndicator = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state() private _wrapLines = true;
|
||||
|
||||
@state() private _noMatches = false;
|
||||
|
||||
@query(".log") private _logElement?: HTMLElement;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtml?: HaAnsiToHtml;
|
||||
|
||||
private _lines: string[] = [];
|
||||
|
||||
// Number of stored lines already rendered in the view
|
||||
private _shownCount = 0;
|
||||
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _resubscribeOnReady = false;
|
||||
|
||||
private _followFrame?: number;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const connection = this._connection.connection;
|
||||
@@ -147,150 +121,39 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
}
|
||||
this._unsub = undefined;
|
||||
this._resubscribeOnReady = false;
|
||||
this._cancelFollowBottom();
|
||||
this._lines = [];
|
||||
this._shownCount = 0;
|
||||
this._lineCount = 0;
|
||||
this._newLogsIndicator = false;
|
||||
this._noMatches = false;
|
||||
this._ansiToHtml?.clear();
|
||||
this._logViewer?.clear();
|
||||
this._subscribe();
|
||||
};
|
||||
|
||||
private _unsubscribe() {
|
||||
this._cancelFollowBottom();
|
||||
this._debounceSearch.cancel();
|
||||
// _subscribe reports subscription errors, so only ignore them here
|
||||
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
this._live = false;
|
||||
}
|
||||
|
||||
private _isScrolledToBottom(): boolean {
|
||||
const log = this._logElement;
|
||||
return (
|
||||
!log ||
|
||||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
private _handleScroll() {
|
||||
if (this._newLogsIndicator && this._isScrolledToBottom()) {
|
||||
this._newLogsIndicator = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _scrollToBottom() {
|
||||
this._newLogsIndicator = false;
|
||||
this._logElement?.scrollTo(0, this._logElement.scrollHeight);
|
||||
}
|
||||
|
||||
private _followBottom() {
|
||||
this._cancelFollowBottom();
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeFrame = logElement ? logElement.scrollTop : 0;
|
||||
this._followFrame = requestAnimationFrame(() => {
|
||||
this._followFrame = undefined;
|
||||
if (logElement && logElement.scrollTop === scrollTopBeforeFrame) {
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelFollowBottom() {
|
||||
if (this._followFrame !== undefined) {
|
||||
cancelAnimationFrame(this._followFrame);
|
||||
this._followFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _addLines(lines: string[]) {
|
||||
const newLines = lines
|
||||
.flatMap((line) => line.split("\n"))
|
||||
.slice(-DEFAULT_LOG_LINE_LIMIT);
|
||||
if (!newLines.length) {
|
||||
const logViewer = this._logViewer;
|
||||
if (!logViewer) {
|
||||
return;
|
||||
}
|
||||
const follow = this._isScrolledToBottom();
|
||||
this._lines = this._lines.concat(newLines);
|
||||
this._trimLines();
|
||||
this._lineCount = this._lines.length;
|
||||
this._showNewLines(follow);
|
||||
}
|
||||
|
||||
private _trimLines() {
|
||||
const excessLineCount = this._lines.length - DEFAULT_LOG_LINE_LIMIT;
|
||||
if (excessLineCount <= 0) {
|
||||
return;
|
||||
}
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeTrimming = logElement ? logElement.scrollTop : 0;
|
||||
const scrollHeightBeforeTrimming = logElement ? logElement.scrollHeight : 0;
|
||||
|
||||
this._lines.splice(0, excessLineCount);
|
||||
this._shownCount = Math.max(0, this._shownCount - excessLineCount);
|
||||
if (this._ansiToHtml) {
|
||||
this._ansiToHtml.trimLines(this._shownCount);
|
||||
}
|
||||
if (logElement) {
|
||||
const removedHeight =
|
||||
scrollHeightBeforeTrimming - logElement.scrollHeight;
|
||||
logElement.scrollTop = Math.max(
|
||||
0,
|
||||
scrollTopBeforeTrimming - removedHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _showNewLines(follow: boolean) {
|
||||
const newLines = this._lines.slice(this._shownCount);
|
||||
if (!newLines.length) {
|
||||
return;
|
||||
}
|
||||
this._shownCount = this._lines.length;
|
||||
this._renderLines(newLines);
|
||||
if (follow) {
|
||||
this._followBottom();
|
||||
} else {
|
||||
this._newLogsIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderLines(lines: string[]) {
|
||||
const ansiToHtml = this._ansiToHtml;
|
||||
if (!ansiToHtml) {
|
||||
return;
|
||||
}
|
||||
|
||||
ansiToHtml.parseLinesToColoredPre(lines);
|
||||
|
||||
if (this._search) {
|
||||
this._noMatches =
|
||||
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
|
||||
}
|
||||
logViewer.addLines(lines);
|
||||
this._lineCount = logViewer.lines.length;
|
||||
}
|
||||
|
||||
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._search = ev.target.value ?? "";
|
||||
this._debounceSearch();
|
||||
}
|
||||
|
||||
private _debounceSearch = debounce(() => {
|
||||
if (this._ansiToHtml) {
|
||||
this._noMatches =
|
||||
this._shownCount > 0 && !this._ansiToHtml.filterLines(this._search);
|
||||
}
|
||||
if (!this._search) {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
private _toggleLineWrap() {
|
||||
this._wrapLines = !this._wrapLines;
|
||||
}
|
||||
|
||||
private _download() {
|
||||
const text = this._lines.join("\n").replace(ANSI_ESCAPE, "");
|
||||
const text = (this._logViewer?.lines ?? [])
|
||||
.join("\n")
|
||||
.replace(ANSI_ESCAPE, "");
|
||||
const timeString = new Date().toISOString().replace(/:/g, "-");
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
||||
fileDownload(
|
||||
@@ -339,7 +202,10 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div class="log-container">
|
||||
<div class="log" @scroll=${this._handleScroll}>
|
||||
<ha-log-viewer
|
||||
.filter=${this._search}
|
||||
?wrap-disabled=${!this._wrapLines}
|
||||
>
|
||||
${
|
||||
this._lineCount
|
||||
? nothing
|
||||
@@ -350,38 +216,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
${
|
||||
this._noMatches
|
||||
? html`<div class="waiting">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_entry.live_log.no_matches"
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-ansi-to-html
|
||||
?wrap-disabled=${!this._wrapLines}
|
||||
></ha-ansi-to-html>
|
||||
</div>
|
||||
<ha-button
|
||||
class="new-logs-indicator ${classMap({
|
||||
visible: this._newLogsIndicator,
|
||||
})}"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${!this._newLogsIndicator}
|
||||
@click=${this._scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowCollapseDown}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this._i18n.localize("ui.panel.config.logs.scroll_down_button")}
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowCollapseDown}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
</ha-log-viewer>
|
||||
${
|
||||
this._live
|
||||
? html`<div class="live-indicator">
|
||||
@@ -428,34 +263,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.log {
|
||||
/* Fills the dialog when it is full screen on narrow screens */
|
||||
flex: 1 1 60vh;
|
||||
min-height: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
direction: ltr;
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.new-logs-indicator {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
inset-inline-start: 4px;
|
||||
height: 0;
|
||||
transition: height 0.4s ease-out;
|
||||
}
|
||||
.new-logs-indicator.visible {
|
||||
height: 32px;
|
||||
}
|
||||
@keyframes breathe {
|
||||
from {
|
||||
@@ -482,9 +292,6 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
width: 14px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-logs-indicator {
|
||||
transition: none;
|
||||
}
|
||||
.live-indicator ha-svg-icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-log-viewer";
|
||||
import type { HaLogViewer } from "../../../../../components/ha-log-viewer";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import type { ZWaveJSLogConfig } from "../../../../../data/zwave_js";
|
||||
@@ -32,29 +34,26 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _logConfig?: ZWaveJSLogConfig;
|
||||
|
||||
@query("textarea", true) private _textarea?: HTMLTextAreaElement;
|
||||
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
|
||||
|
||||
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||
return [
|
||||
subscribeZWaveJSLogs(this.hass, this.configEntryId, (update) => {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
if (update.type === "log_message") {
|
||||
if (Array.isArray(update.log_message.message)) {
|
||||
for (const line of update.log_message.message) {
|
||||
this._textarea!.value += `${line}\n`;
|
||||
}
|
||||
} else {
|
||||
this._textarea!.value += `${update.log_message.message}\n`;
|
||||
}
|
||||
this._logViewer?.addLines(
|
||||
Array.isArray(update.log_message.message)
|
||||
? update.log_message.message
|
||||
: [update.log_message.message]
|
||||
);
|
||||
} else {
|
||||
this._logConfig = update.log_config;
|
||||
}
|
||||
}).then((unsub) => {
|
||||
this._textarea!.value += `${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
|
||||
)}\n`;
|
||||
this._logViewer?.addLines([
|
||||
this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
|
||||
),
|
||||
]);
|
||||
return unsub;
|
||||
}),
|
||||
];
|
||||
@@ -112,7 +111,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiDownload}
|
||||
></ha-icon-button>
|
||||
</ha-card>
|
||||
<textarea readonly></textarea>
|
||||
<ha-log-viewer></ha-log-viewer>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -136,7 +135,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
private _downloadLogs() {
|
||||
fileDownload(
|
||||
`data:text/plain;charset=utf-8,${encodeURIComponent(
|
||||
this._textarea!.value
|
||||
this._logViewer!.lines.join("\n")
|
||||
)}`,
|
||||
`zwave_js.log`
|
||||
);
|
||||
@@ -151,10 +150,11 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
setZWaveJSLogLevel(this.hass!, this.configEntryId, selected);
|
||||
this._textarea!.value += `${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.log_level_changed",
|
||||
{ level: capitalizeFirstLetter(selected) }
|
||||
)}\n`;
|
||||
this._logViewer?.addLines([
|
||||
this.hass.localize("ui.panel.config.zwave_js.logs.log_level_changed", {
|
||||
level: capitalizeFirstLetter(selected),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultArray {
|
||||
@@ -168,11 +168,6 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
}
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
padding: 16px;
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
ha-card {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -23,21 +23,12 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
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 { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
scriptsFilterQueryParamConfig,
|
||||
scriptsFilterQueryParams,
|
||||
scriptsFilterQueryString,
|
||||
scriptsLinkFromUrl,
|
||||
} from "../../../common/url/scripts-query-params";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
rejectedItems,
|
||||
@@ -46,6 +37,7 @@ import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/ha-button";
|
||||
@@ -71,10 +63,7 @@ import {
|
||||
} from "../../../data/category_registry";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { fullEntitiesContext, labelsContext } from "../../../data/context";
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} from "../../../data/data_table_filters";
|
||||
import type { DataTableFilters } from "../../../data/data_table_filters";
|
||||
import {
|
||||
deserializeFilters,
|
||||
isFilterUsed,
|
||||
@@ -99,6 +88,7 @@ import {
|
||||
showScriptEditor,
|
||||
triggerScript,
|
||||
} from "../../../data/script";
|
||||
import { findRelated } from "../../../data/search";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -154,21 +144,22 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatus;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
@state() private _activeFilters?: string[];
|
||||
|
||||
@state() private _filteredEntityIds?: string[] | null;
|
||||
|
||||
private _tableSettings = new DataTableSettingsController(
|
||||
this,
|
||||
{
|
||||
search: "script-table-search",
|
||||
sorting: "script-table-sort",
|
||||
grouping: "script-table-grouping",
|
||||
},
|
||||
{ defaultGrouping: "category" }
|
||||
);
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "script-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -183,16 +174,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
})
|
||||
private _storageFilters: DataTableFilters = {};
|
||||
|
||||
// The filters are kept in the URL by the page, the search, sorting and
|
||||
// grouping by the table
|
||||
private _filterParams = new QueryParamsController(
|
||||
this,
|
||||
scriptsFilterQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyLink(),
|
||||
}
|
||||
);
|
||||
private _fromUrl = false;
|
||||
|
||||
@state() private _expandedFilter?: string;
|
||||
|
||||
@@ -207,6 +189,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
@storage({ key: "script-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({ key: "script-table-grouping", state: false, subscribe: false })
|
||||
private _activeGrouping?: string;
|
||||
|
||||
@storage({
|
||||
key: "script-table-collapsed",
|
||||
state: false,
|
||||
@@ -451,14 +439,14 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
{ number: scripts.length }
|
||||
)}
|
||||
has-filters
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialGroupColumn=${this._activeGrouping ?? "category"}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@@ -482,9 +470,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
"ui.panel.config.script.picker.no_scripts"
|
||||
)}
|
||||
@clear-filter=${this._clearFilter}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
has-fab
|
||||
clickable
|
||||
class=${this.narrow ? "narrow" : ""}
|
||||
@@ -718,6 +705,10 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
private _filterExpanded(ev) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = ev.target.localName;
|
||||
@@ -728,38 +719,34 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _labelClicked = (ev: CustomEvent) => {
|
||||
const label = ev.detail.label;
|
||||
this._setFilter("ha-filter-labels", {
|
||||
value: [label.label_id],
|
||||
items: undefined,
|
||||
});
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-labels": {
|
||||
value: [label.label_id],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = this._filters;
|
||||
}
|
||||
this._applyFilters();
|
||||
};
|
||||
|
||||
private _filterChanged(ev) {
|
||||
this._setFilter(ev.target.localName, ev.detail);
|
||||
}
|
||||
|
||||
private _setFilter(type: string, filter: DataTableFilter) {
|
||||
const filters = { ...this._filters, [type]: filter };
|
||||
|
||||
// A filter also sends the value it was given, once it has found its items.
|
||||
// Only a change to what the URL holds is the user's, so a link isn't stored.
|
||||
if (
|
||||
scriptsFilterQueryString(filters) !==
|
||||
scriptsFilterQueryString(this._filters)
|
||||
) {
|
||||
this._storageFilters = { ...this._storageFilters, [type]: filter };
|
||||
const type = ev.target.localName;
|
||||
this._filters = { ...this._filters, [type]: ev.detail };
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = this._filters;
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._applyFilters();
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
this._storageFilters = {};
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = {};
|
||||
}
|
||||
this._applyFilters();
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _applyFilters() {
|
||||
@@ -810,55 +797,29 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._filters = this._storageFilters;
|
||||
this._applyLink();
|
||||
const hasUrlFilter =
|
||||
this._searchParms.has("area") ||
|
||||
this._searchParms.has("blueprint") ||
|
||||
this._searchParms.has("device") ||
|
||||
this._searchParms.has("label");
|
||||
if (!hasUrlFilter) {
|
||||
this._filters = this._storageFilters;
|
||||
}
|
||||
if (this._searchParms.has("area")) {
|
||||
this._filterArea();
|
||||
}
|
||||
if (this._searchParms.has("device")) {
|
||||
this._filterDevice();
|
||||
}
|
||||
if (this._searchParms.has("blueprint")) {
|
||||
this._filterBlueprint();
|
||||
}
|
||||
if (this._searchParms.has("label")) {
|
||||
this._filterLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Links, like the ones on a device or area page, 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 ? scriptsLinkFromUrl(searchParams) : undefined;
|
||||
|
||||
// Shown, but not stored as the user's choice
|
||||
if (link) {
|
||||
this._tableSettings.showSearch(link.search);
|
||||
this._filters = this._keepItems(link.filters);
|
||||
this._applyFilters();
|
||||
} 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._keepItems(this._storageFilters);
|
||||
this._applyFilters();
|
||||
queueMicrotask(() => this._writeFilters());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
// The filters only work out their items again when their value changes, so
|
||||
// keep the items of the ones that stay as they are
|
||||
private _keepItems(filters: DataTableFilters): DataTableFilters {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters).map(([key, filter]) => {
|
||||
const current = this._filters[key];
|
||||
|
||||
return [
|
||||
key,
|
||||
current && deepEqual(filter.value, current.value) ? current : filter,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _writeFilters() {
|
||||
this._filterParams.write(scriptsFilterQueryParams(this._filters));
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_entityReg")) {
|
||||
@@ -866,6 +827,71 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private _filterArea() {
|
||||
const area = this._searchParms.get("area");
|
||||
if (!area) {
|
||||
return;
|
||||
}
|
||||
this._fromUrl = true;
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-floor-areas": {
|
||||
value: { areas: [area] },
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private _filterDevice() {
|
||||
const device = this._searchParms.get("device");
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
this._fromUrl = true;
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-devices": {
|
||||
value: [device],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private _filterLabel() {
|
||||
const label = this._searchParms.get("label");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
this._fromUrl = true;
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-labels": {
|
||||
value: [label],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private async _filterBlueprint() {
|
||||
const blueprint = this._searchParms.get("blueprint");
|
||||
if (!blueprint) {
|
||||
return;
|
||||
}
|
||||
this._fromUrl = true;
|
||||
const related = await findRelated(this.hass, "script_blueprint", blueprint);
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-blueprints": {
|
||||
value: [blueprint],
|
||||
items: new Set(related.automation || []),
|
||||
},
|
||||
};
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private _editCategory(script: any) {
|
||||
const entityReg = this._entityReg.find(
|
||||
(reg) => reg.entity_id === script.entity_id
|
||||
@@ -1315,6 +1341,14 @@ ${rejected
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import "../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/item/ha-list-item-button";
|
||||
import "../../../../components/list/ha-list-base";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import { generateDefaultSection } from "../../views/default-section";
|
||||
import { addSection } from "../config-util";
|
||||
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
|
||||
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
|
||||
|
||||
@customElement("hui-dialog-create-section")
|
||||
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "dashboardSectionClipboard",
|
||||
state: true,
|
||||
subscribe: true,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
private _clipboard?: LovelaceSectionRawConfig;
|
||||
|
||||
protected render() {
|
||||
if (!this.params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const localize = this._i18n.localize;
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
|
||||
>
|
||||
<ha-list-base>
|
||||
<ha-list-item-button @click=${this._createManual}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewGridPlusOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.manual")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.manual_description"
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button @click=${this._pickStrategy}>
|
||||
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.automatic")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.automatic_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
${
|
||||
this._clipboard
|
||||
? html`
|
||||
<ha-list-item-button @click=${this._paste}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiContentPaste}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.paste")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.paste_description"
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _createManual() {
|
||||
const { lovelace, containerPath } = this.params!;
|
||||
this.closeDialog();
|
||||
lovelace.saveConfig(
|
||||
addSection(
|
||||
lovelace.config,
|
||||
containerPath,
|
||||
generateDefaultSection(this._i18n.localize, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _pickStrategy() {
|
||||
showSelectSectionStrategyDialog(this, this.params!);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private async _paste() {
|
||||
if (!this._clipboard) {
|
||||
return;
|
||||
}
|
||||
const { lovelace, containerPath } = this.params!;
|
||||
const section = deepClone(this._clipboard);
|
||||
this.closeDialog();
|
||||
try {
|
||||
await lovelace.saveConfig(
|
||||
addSection(lovelace.config, containerPath, section)
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
lovelace.showToast({
|
||||
message: this._i18n.localize(
|
||||
"ui.panel.lovelace.editor.section.paste_error"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
ha-list-base {
|
||||
--ha-list-padding: 0 0 var(--ha-space-3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-create-section": HuiDialogCreateSection;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,26 @@
|
||||
import {
|
||||
mdiCellphone,
|
||||
mdiClose,
|
||||
mdiDotsVertical,
|
||||
mdiFileMoveOutline,
|
||||
mdiMonitor,
|
||||
} from "@mdi/js";
|
||||
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import { withViewTransition } from "../../../../common/util/view-transition";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import {
|
||||
DEFAULT_SECTION_COLUMN_SPAN,
|
||||
isStrategySection,
|
||||
} from "../../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { saveConfig } from "../../../../data/lovelace/config/types";
|
||||
@@ -44,7 +32,6 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
|
||||
@@ -54,13 +41,10 @@ import {
|
||||
deleteAtPath,
|
||||
getAtPath,
|
||||
getViewPath,
|
||||
insertAtPath,
|
||||
moveAtPath,
|
||||
setAtPath,
|
||||
} from "../lovelace-path";
|
||||
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
|
||||
import "../../sections/hui-section";
|
||||
import "../../sections/hui-section-background";
|
||||
import "./hui-section-settings-editor";
|
||||
import "./hui-section-visibility-editor";
|
||||
import "./hui-section-strategy-element-editor";
|
||||
@@ -69,20 +53,11 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
|
||||
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import { getViewType } from "../../views/get-view-type";
|
||||
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
|
||||
|
||||
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
|
||||
|
||||
type PreviewMode = "desktop" | "mobile";
|
||||
|
||||
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
|
||||
{ mode: "mobile", iconPath: mdiCellphone },
|
||||
{ mode: "desktop", iconPath: mdiMonitor },
|
||||
];
|
||||
|
||||
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
|
||||
|
||||
@customElement("hui-dialog-edit-section")
|
||||
export class HuiDialogEditSection
|
||||
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
|
||||
@@ -92,8 +67,6 @@ export class HuiDialogEditSection
|
||||
|
||||
@property({ attribute: false }) public lovelace?: Lovelace;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public large = false;
|
||||
|
||||
@state() private _params?: EditSectionDialogParams;
|
||||
|
||||
@state() private _config?: LovelaceSectionRawConfig;
|
||||
@@ -110,15 +83,6 @@ export class HuiDialogEditSection
|
||||
|
||||
@state() private _yamlError = false;
|
||||
|
||||
@storage({
|
||||
key: "dashboardSectionPreviewMode",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedPreviewMode?: PreviewMode;
|
||||
|
||||
@state() private _defaultPreviewMode: PreviewMode = "mobile";
|
||||
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
@query("hui-section-strategy-element-editor")
|
||||
@@ -140,9 +104,10 @@ export class HuiDialogEditSection
|
||||
|
||||
this.lovelace = params.lovelace;
|
||||
|
||||
this._config =
|
||||
params.newSection ??
|
||||
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
|
||||
this._config = getAtPath<LovelaceSectionRawConfig>(
|
||||
params.lovelaceConfig,
|
||||
params.path
|
||||
);
|
||||
this._currTab =
|
||||
this._config && isStrategySection(this._config)
|
||||
? "tab-strategy"
|
||||
@@ -152,18 +117,7 @@ export class HuiDialogEditSection
|
||||
params.lovelaceConfig,
|
||||
viewPath
|
||||
);
|
||||
if (params.newSection) {
|
||||
this._initDirtyTracking({ type: "deep" }, {});
|
||||
this._updateDirtyState(params.newSection);
|
||||
} else {
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
}
|
||||
this._defaultPreviewMode =
|
||||
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
|
||||
}
|
||||
|
||||
private get _previewMode(): PreviewMode {
|
||||
return this._storedPreviewMode ?? this._defaultPreviewMode;
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -173,13 +127,11 @@ export class HuiDialogEditSection
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this.large = false;
|
||||
this._yamlMode = false;
|
||||
this._config = undefined;
|
||||
this._currTab = TABS[0];
|
||||
this._strategyError = false;
|
||||
this._yamlError = false;
|
||||
this._discardDirtyStateChanges();
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -188,23 +140,19 @@ export class HuiDialogEditSection
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const heading = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
|
||||
const strategy = isStrategySection(this._config)
|
||||
? cleanLegacyStrategyConfig(this._config.strategy)
|
||||
: undefined;
|
||||
const heading = this._computeHeading(strategy);
|
||||
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
|
||||
const currentTab =
|
||||
!strategy && this._currTab === "tab-strategy"
|
||||
? "tab-appearance"
|
||||
: this._currTab;
|
||||
|
||||
const columnSpan = Math.min(
|
||||
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
|
||||
);
|
||||
const singleColumn = columnSpan === 1;
|
||||
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
|
||||
|
||||
let content: TemplateResult<1> | typeof nothing = nothing;
|
||||
|
||||
if (this._yamlMode) {
|
||||
@@ -254,160 +202,65 @@ export class HuiDialogEditSection
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.width=${this.large ? "full" : "large"}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@keydown=${this._ignoreKeydown}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${classMap({
|
||||
"yaml-mode": this._yamlMode,
|
||||
})}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span
|
||||
slot="headerTitle"
|
||||
class="title-enlargeable"
|
||||
@click=${this._enlarge}
|
||||
>${heading}</span
|
||||
>
|
||||
${
|
||||
this._params.newSection
|
||||
? nothing
|
||||
: html`
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
<ha-dialog-header show-border slot="header">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span slot="title">${heading}</span>
|
||||
<ha-dropdown
|
||||
slot="actionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
`
|
||||
}
|
||||
<div class="content">
|
||||
<div class="element-editor ha-scrollbar">
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
</div>
|
||||
<div class="element-preview ha-scrollbar">
|
||||
<div class="preview-header">
|
||||
<span id="preview-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.preview"
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
singleColumn
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="preview-modes"
|
||||
role="group"
|
||||
aria-labelledby="preview-label"
|
||||
>
|
||||
${PREVIEW_MODES.map(
|
||||
({ mode, iconPath }) => html`
|
||||
<button
|
||||
id="preview-mode-${mode}"
|
||||
class=${classMap({
|
||||
"preview-mode": true,
|
||||
active: this._previewMode === mode,
|
||||
})}
|
||||
aria-pressed=${this._previewMode === mode}
|
||||
aria-label=${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
.mode=${mode}
|
||||
@click=${this._previewModeClicked}
|
||||
>
|
||||
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
|
||||
</button>
|
||||
<ha-tooltip
|
||||
for="preview-mode-${mode}"
|
||||
placement="bottom"
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-preview": true,
|
||||
wide: previewColumnSpan > 1,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--column-span": previewColumnSpan,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-container": true,
|
||||
"has-background": this._config.background !== undefined,
|
||||
blur: this._yamlError || this._strategyError,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this._config.background !== undefined
|
||||
? html`
|
||||
<hui-section-background
|
||||
.hass=${this.hass}
|
||||
.background=${this._config.background}
|
||||
.theme=${this._config.theme}
|
||||
></hui-section-background>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
preview
|
||||
></hui-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-header>
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -466,37 +319,6 @@ export class HuiDialogEditSection
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _computeHeading(strategy?: LovelaceStrategyConfig) {
|
||||
const header = this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
if (!strategy) {
|
||||
return header;
|
||||
}
|
||||
const strategyName = this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
|
||||
);
|
||||
if (!strategyName) {
|
||||
return header;
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.typed_header",
|
||||
{ type: strategyName }
|
||||
);
|
||||
}
|
||||
|
||||
private _previewModeClicked(ev: Event) {
|
||||
this._storedPreviewMode = (
|
||||
ev.currentTarget as HTMLElement & { mode: PreviewMode }
|
||||
).mode;
|
||||
}
|
||||
|
||||
private _enlarge() {
|
||||
withViewTransition(() => {
|
||||
this.large = !this.large;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTabChanged(ev: CustomEvent): void {
|
||||
const newTab = ev.detail.name;
|
||||
if (newTab === this._currTab) {
|
||||
@@ -694,13 +516,11 @@ export class HuiDialogEditSection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const newConfig = this._params.newSection
|
||||
? insertAtPath(
|
||||
this._params.lovelaceConfig,
|
||||
this._params.path,
|
||||
this._config
|
||||
)
|
||||
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
|
||||
const newConfig = setAtPath(
|
||||
this._params.lovelaceConfig,
|
||||
this._params.path,
|
||||
this._config
|
||||
);
|
||||
|
||||
this._params.saveConfig(newConfig);
|
||||
this._markDirtyStateClean();
|
||||
@@ -711,153 +531,15 @@ export class HuiDialogEditSection
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
--code-mirror-max-height: calc(100vh - 209px);
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-2);
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.content {
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 451px) and (min-height: 501px) {
|
||||
:host([large]) .content {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content .preview-frame {
|
||||
padding: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.content .section-preview {
|
||||
--preview-column-width: 390px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: var(--preview-column-width);
|
||||
}
|
||||
.content .section-preview.wide {
|
||||
max-width: none;
|
||||
width: calc(
|
||||
var(--column-span) * var(--preview-column-width) +
|
||||
(var(--column-span) - 1) *
|
||||
var(--ha-view-sections-column-gap, 32px)
|
||||
);
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 32px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.preview-modes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
}
|
||||
.preview-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: none;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.preview-mode:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.preview-mode.active {
|
||||
color: var(--primary-text-color);
|
||||
background: var(--card-background-color);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
.preview-mode:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.content .element-editor {
|
||||
padding-inline-start: var(--ha-space-1);
|
||||
padding-inline-end: var(--ha-space-2);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
max-height: var(--code-mirror-max-height);
|
||||
}
|
||||
.content > .element-editor {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
}
|
||||
.content > .element-editor,
|
||||
.content > .element-preview {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
}
|
||||
.content .preview-frame {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.element-editor {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.blur {
|
||||
filter: blur(2px) grayscale(100%);
|
||||
}
|
||||
.element-preview {
|
||||
position: relative;
|
||||
height: max-content;
|
||||
background: var(--primary-background-color);
|
||||
padding: 4px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
.section-container {
|
||||
position: relative;
|
||||
}
|
||||
.section-container.has-background {
|
||||
padding: var(--ha-space-2);
|
||||
border-radius: var(
|
||||
--ha-section-border-radius,
|
||||
var(--ha-border-radius-xl)
|
||||
);
|
||||
}
|
||||
.section-container hui-section {
|
||||
display: block;
|
||||
}
|
||||
ha-tab-group {
|
||||
margin-bottom: 16px;
|
||||
.gui-mode-button {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
ha-tab-group-tab {
|
||||
flex: 1;
|
||||
@@ -866,14 +548,6 @@ export class HuiDialogEditSection
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.gui-mode-button {
|
||||
margin-right: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.title-enlargeable {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../components/ha-icon-button-arrow-prev";
|
||||
import "../../../../components/ha-ripple";
|
||||
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
|
||||
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../sections/hui-section";
|
||||
import { getLovelaceStrategy } from "../../strategies/get-strategy";
|
||||
import type { LovelaceSectionStrategy } from "../../strategies/types";
|
||||
import { getAtPath } from "../lovelace-path";
|
||||
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
|
||||
import { showCreateSectionDialog } from "./show-create-section-dialog";
|
||||
import { showEditSectionDialog } from "./show-edit-section-dialog";
|
||||
|
||||
interface BuiltInSectionStrategy {
|
||||
type: "common-controls";
|
||||
isAvailable?: (hass: HomeAssistant) => boolean;
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
|
||||
{
|
||||
type: "common-controls",
|
||||
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
|
||||
},
|
||||
];
|
||||
|
||||
interface StrategyOption {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@customElement("hui-dialog-select-section-strategy")
|
||||
export class HuiDialogSelectSectionStrategy
|
||||
extends LitElement
|
||||
implements HassDialog<CreateSectionDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: CreateSectionDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
|
||||
{};
|
||||
|
||||
public showDialog(params: CreateSectionDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._previews = {};
|
||||
this._loadPreviews(params);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._previews = {};
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _availableBuiltInTypes() {
|
||||
return BUILT_IN_SECTION_STRATEGIES.filter(
|
||||
(strategy) => strategy.isAvailable?.(this.hass) ?? true
|
||||
).map((strategy) => strategy.type);
|
||||
}
|
||||
|
||||
private _builtInOptions(): StrategyOption[] {
|
||||
return this._availableBuiltInTypes().map((type) => ({
|
||||
type,
|
||||
name: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${type}.name`
|
||||
),
|
||||
description: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${type}.description`
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private _customOptions(): StrategyOption[] {
|
||||
return getCustomStrategiesForType("section").map((strategy) => ({
|
||||
type: CUSTOM_TYPE_PREFIX + strategy.type,
|
||||
name: strategy.name || strategy.type,
|
||||
description: strategy.description || "",
|
||||
}));
|
||||
}
|
||||
|
||||
private async _loadPreviews(params: CreateSectionDialogParams) {
|
||||
const types = [
|
||||
...this._availableBuiltInTypes(),
|
||||
...getCustomStrategiesForType("section").map(
|
||||
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
|
||||
),
|
||||
];
|
||||
await Promise.all(
|
||||
types.map(async (type) => {
|
||||
try {
|
||||
const strategy = (await getLovelaceStrategy(
|
||||
"section",
|
||||
type
|
||||
)) as LovelaceSectionStrategy;
|
||||
const stubConfig = await strategy.getStubConfig?.(this.hass);
|
||||
if (!stubConfig || this._params !== params) {
|
||||
return;
|
||||
}
|
||||
this._previews = {
|
||||
...this._previews,
|
||||
[type]: { strategy: stubConfig },
|
||||
};
|
||||
} catch (_err: unknown) {
|
||||
// The tile is shown without a preview
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const customOptions = this._customOptions();
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._open}
|
||||
width="large"
|
||||
@closed=${this._dialogClosed}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.header"
|
||||
)}
|
||||
>
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="headerNavigationIcon"
|
||||
@click=${this._back}
|
||||
></ha-icon-button-arrow-prev>
|
||||
${
|
||||
customOptions.length
|
||||
? html`
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.built_in"
|
||||
)}
|
||||
</h3>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="options">
|
||||
${this._builtInOptions().map((option) => this._renderOption(option))}
|
||||
</div>
|
||||
${
|
||||
customOptions.length
|
||||
? html`
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.custom"
|
||||
)}
|
||||
</h3>
|
||||
<div class="options">
|
||||
${customOptions.map((option) => this._renderOption(option))}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: StrategyOption) {
|
||||
const preview = this._previews[option.type];
|
||||
return html`
|
||||
<div
|
||||
class="option"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label=${option.name}
|
||||
data-type=${option.type}
|
||||
@click=${this._strategyPicked}
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<div class="header">
|
||||
<h4>${option.name}</h4>
|
||||
<p>${option.description}</p>
|
||||
</div>
|
||||
${
|
||||
preview
|
||||
? html`
|
||||
<div class="preview" inert>
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${preview}
|
||||
preview
|
||||
></hui-section>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
(ev.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
|
||||
private _back() {
|
||||
showCreateSectionDialog(this, this._params!);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _strategyPicked(ev: Event) {
|
||||
const type = (ev.currentTarget as HTMLElement).dataset.type!;
|
||||
const { lovelace, containerPath } = this._params!;
|
||||
const sectionsPath = [...containerPath, "sections"];
|
||||
const sectionCount =
|
||||
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
|
||||
|
||||
showEditSectionDialog(this, {
|
||||
lovelace,
|
||||
lovelaceConfig: lovelace.config,
|
||||
saveConfig: (newConfig) => {
|
||||
lovelace.saveConfig(newConfig);
|
||||
},
|
||||
path: [...sectionsPath, sectionCount],
|
||||
newSection: this._previews[type] ?? { strategy: { type } },
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
h3 {
|
||||
margin: 0 0 var(--ha-space-3) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.options + h3 {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
.options {
|
||||
display: grid;
|
||||
gap: var(--ha-space-4);
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
|
||||
}
|
||||
.option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.option:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.header {
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
}
|
||||
h4 {
|
||||
margin: 0 0 var(--ha-space-1) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.preview {
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
padding: var(--ha-space-3);
|
||||
background: var(--primary-background-color);
|
||||
pointer-events: none;
|
||||
mask-image: linear-gradient(to bottom, black 75%, transparent);
|
||||
}
|
||||
hui-section {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
|
||||
}
|
||||
}
|
||||
@@ -19,71 +19,8 @@ import {
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
|
||||
|
||||
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
|
||||
|
||||
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
|
||||
|
||||
const computeColumnSpanLabel = (
|
||||
span: number,
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (span >= maxColumns) {
|
||||
return localize(
|
||||
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
|
||||
);
|
||||
}
|
||||
return localize(
|
||||
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
|
||||
);
|
||||
};
|
||||
|
||||
const computeColumnSpanSchemas = (
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (maxColumns <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
number: { min: 1, max: maxColumns, slider_ticks: true },
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
const options = Array.from({ length: maxColumns }, (_, i) => {
|
||||
const span = i + 1;
|
||||
return {
|
||||
value: String(span),
|
||||
label: computeColumnSpanLabel(span, maxColumns, localize),
|
||||
image: {
|
||||
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
|
||||
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
box_max_columns:
|
||||
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
column_span?: string | number;
|
||||
column_span?: number;
|
||||
background_enabled?: boolean;
|
||||
background_color?: string;
|
||||
background_opacity?: number;
|
||||
@@ -99,30 +36,20 @@ export class HuiDialogEditSection extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
|
||||
|
||||
private get _maxColumns() {
|
||||
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
|
||||
}
|
||||
|
||||
private get _displayedColumnSpan() {
|
||||
return String(
|
||||
Math.min(
|
||||
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._maxColumns
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get _columnSpanFormValue() {
|
||||
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return Number(this._displayedColumnSpan);
|
||||
}
|
||||
return this._displayedColumnSpan;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, maxColumns: number) =>
|
||||
(maxColumns: number, localize: LocalizeFunc) =>
|
||||
[
|
||||
...computeColumnSpanSchemas(maxColumns, localize),
|
||||
{
|
||||
name: "column_span",
|
||||
default: DEFAULT_SECTION_COLUMN_SPAN,
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: maxColumns,
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background_enabled",
|
||||
selector: { boolean: {} },
|
||||
@@ -182,14 +109,17 @@ export class HuiDialogEditSection extends LitElement {
|
||||
const background = resolveSectionBackground(this.config.background);
|
||||
|
||||
const data: SettingsData = {
|
||||
column_span: this._columnSpanFormValue,
|
||||
column_span: this.config.column_span,
|
||||
background_enabled: backgroundEnabled,
|
||||
background_color: background?.color,
|
||||
background_opacity: background?.opacity,
|
||||
theme: this.config.theme,
|
||||
};
|
||||
|
||||
const schema = this._schema(this._localize, this._maxColumns);
|
||||
const schema = this._schema(
|
||||
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
|
||||
this._localize
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -224,15 +154,10 @@ export class HuiDialogEditSection extends LitElement {
|
||||
...this.config,
|
||||
};
|
||||
|
||||
if (newData.column_span === undefined) {
|
||||
if (newData.column_span) {
|
||||
newConfig.column_span = newData.column_span;
|
||||
} else {
|
||||
delete newConfig.column_span;
|
||||
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
|
||||
const columnSpan = Number(newData.column_span);
|
||||
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
|
||||
delete newConfig.column_span;
|
||||
} else {
|
||||
newConfig.column_span = columnSpan;
|
||||
}
|
||||
}
|
||||
|
||||
if (newData.background_enabled) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { Lovelace } from "../../types";
|
||||
import type { LovelacePath } from "../lovelace-path";
|
||||
|
||||
export interface CreateSectionDialogParams {
|
||||
lovelace: Lovelace;
|
||||
containerPath: LovelacePath;
|
||||
}
|
||||
|
||||
export const showCreateSectionDialog = (
|
||||
element: HTMLElement,
|
||||
params: CreateSectionDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-create-section",
|
||||
dialogImport: () => import("./hui-dialog-create-section"),
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const showSelectSectionStrategyDialog = (
|
||||
element: HTMLElement,
|
||||
params: CreateSectionDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-select-section-strategy",
|
||||
dialogImport: () => import("./hui-dialog-select-section-strategy"),
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { Lovelace } from "../../types";
|
||||
import type { LovelacePath } from "../lovelace-path";
|
||||
@@ -9,7 +8,6 @@ export interface EditSectionDialogParams {
|
||||
lovelaceConfig: LovelaceConfig;
|
||||
saveConfig: (config: LovelaceConfig) => void;
|
||||
path: LovelacePath;
|
||||
newSection?: LovelaceSectionRawConfig;
|
||||
}
|
||||
|
||||
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
|
||||
|
||||
@@ -279,8 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
}
|
||||
|
||||
private async _initializeConfig() {
|
||||
const config = this.config;
|
||||
let sectionConfig = { ...config };
|
||||
let sectionConfig = { ...this.config };
|
||||
let isStrategy = false;
|
||||
|
||||
if (isStrategySection(sectionConfig)) {
|
||||
@@ -289,9 +288,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
sectionConfig,
|
||||
this.hass!
|
||||
);
|
||||
if (config !== this.config) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sectionConfig = {
|
||||
|
||||
@@ -35,11 +35,7 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
|
||||
|
||||
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
|
||||
|
||||
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
|
||||
getStubConfig?(
|
||||
hass: HomeAssistant
|
||||
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
|
||||
}
|
||||
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
|
||||
|
||||
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
|
||||
setConfig(config: LovelaceStrategyConfig): void;
|
||||
|
||||
@@ -40,10 +40,6 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
);
|
||||
}
|
||||
|
||||
public static getStubConfig(): CommonControlsSectionStrategyConfig {
|
||||
return { type: "common-controls" };
|
||||
}
|
||||
|
||||
static async generate(
|
||||
config: CommonControlsSectionStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
|
||||
@@ -30,7 +30,6 @@ import "../components/hui-section-edit-mode";
|
||||
import { addSection } from "../editor/config-util";
|
||||
import type { LovelacePath } from "../editor/lovelace-path";
|
||||
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
|
||||
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
|
||||
import type { HuiSection } from "../sections/hui-section";
|
||||
import "../sections/hui-section-background";
|
||||
import type { Lovelace } from "../types";
|
||||
@@ -475,10 +474,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _createSection(): void {
|
||||
showCreateSectionDialog(this, {
|
||||
lovelace: this.lovelace!,
|
||||
containerPath: this.path!,
|
||||
});
|
||||
const newConfig = addSection(
|
||||
this.lovelace!.config,
|
||||
this.path!,
|
||||
generateDefaultSection(this.hass.localize, true)
|
||||
);
|
||||
this.lovelace!.saveConfig(newConfig);
|
||||
}
|
||||
|
||||
private _sectionMoved(ev: CustomEvent) {
|
||||
|
||||
@@ -93,22 +93,6 @@ const scenesTableParams = {
|
||||
assistants: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The scripts table reads these from the link
|
||||
const scriptsTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
floor: "string?",
|
||||
area: "string?",
|
||||
device: "string?",
|
||||
entity: "string?",
|
||||
label: "string?",
|
||||
category: "string?",
|
||||
assistants: "string?",
|
||||
blueprint: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
export const getMyRedirects = (): Redirects => ({
|
||||
application_credentials: {
|
||||
redirect: "/config/application_credentials",
|
||||
@@ -347,7 +331,6 @@ export const getMyRedirects = (): Redirects => ({
|
||||
scripts: {
|
||||
component: "script",
|
||||
redirect: "/config/script/dashboard",
|
||||
params: scriptsTableParams,
|
||||
},
|
||||
helpers: {
|
||||
redirect: "/config/helpers",
|
||||
|
||||
@@ -751,6 +751,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"log_viewer": {
|
||||
"scroll_down_button": "New logs - Click to scroll",
|
||||
"no_matches": "No log lines match the search"
|
||||
},
|
||||
"logbook": {
|
||||
"entries_not_found": "No activity found.",
|
||||
"automation_triggered": "Triggered",
|
||||
@@ -4985,7 +4989,7 @@
|
||||
"log_file_disabled_debug_download": {
|
||||
"environment": "Debug logging has been disabled, but no log file was downloaded because HA_DISABLE_LOG_FILE is set and Home Assistant is not writing a log file."
|
||||
},
|
||||
"scroll_down_button": "New logs - Click to scroll",
|
||||
"scroll_down_button": "[%key:ui::components::log_viewer::scroll_down_button%]",
|
||||
"live": "Live",
|
||||
"provider_not_found": "Log provider not found",
|
||||
"provider_not_available": "Logs for ''{provider}'' are not available on your system.",
|
||||
@@ -7766,8 +7770,7 @@
|
||||
"live_log": {
|
||||
"title": "Live log",
|
||||
"waiting": "Waiting for log messages from {integration}…",
|
||||
"unsupported": "This version of Home Assistant can't stream the log.",
|
||||
"no_matches": "No log lines match the search"
|
||||
"unsupported": "This version of Home Assistant can't stream the log."
|
||||
},
|
||||
"state": {
|
||||
"loaded": "Loaded",
|
||||
@@ -10405,32 +10408,8 @@
|
||||
"error_section": {
|
||||
"title": "Error"
|
||||
},
|
||||
"section_strategy": {
|
||||
"common-controls": {
|
||||
"name": "Common controls",
|
||||
"description": "Shows the entities you use most, based on your activity."
|
||||
}
|
||||
},
|
||||
"create_section": {
|
||||
"header": "Create section",
|
||||
"manual": "Manual section",
|
||||
"manual_description": "Start empty and add your own cards.",
|
||||
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
|
||||
"automatic_description": "Fills itself with cards and stays up to date.",
|
||||
"paste": "Paste section",
|
||||
"paste_description": "Add the section you copied."
|
||||
},
|
||||
"select_section_strategy": {
|
||||
"header": "Choose an automatic section",
|
||||
"built_in": "Built-in sections",
|
||||
"custom": "Community sections"
|
||||
},
|
||||
"edit_section": {
|
||||
"header": "Section configuration",
|
||||
"typed_header": "{type} section configuration",
|
||||
"preview": "Preview",
|
||||
"preview_desktop": "Desktop preview",
|
||||
"preview_mobile": "Mobile preview",
|
||||
"header": "Edit section",
|
||||
"tab_strategy": "Configuration",
|
||||
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
|
||||
"tab_appearance": "Appearance",
|
||||
@@ -10438,13 +10417,7 @@
|
||||
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
|
||||
"settings": {
|
||||
"column_span": "Width",
|
||||
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
|
||||
"column_span_options": {
|
||||
"single": "Single",
|
||||
"double": "Double",
|
||||
"triple": "Triple",
|
||||
"full": "Full width"
|
||||
},
|
||||
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
|
||||
"background": "Background options",
|
||||
"background_enabled": "Background",
|
||||
"background_enabled_helper": "Display a colored background behind the section",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
scriptsFilterQueryParamConfig,
|
||||
scriptsFilterQueryParams,
|
||||
scriptsFilterQueryString,
|
||||
scriptsLinkFromUrl,
|
||||
} from "../../../src/common/url/scripts-query-params";
|
||||
import { createQueryString } from "../../../src/common/url/query-params";
|
||||
|
||||
const filter = (value: string[] | Record<string, string[]> | undefined) => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
describe("scripts query params", () => {
|
||||
it("reads nothing without table params", () => {
|
||||
expect(scriptsLinkFromUrl("?historyBack=1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces every filter and the search with what a link asks for", () => {
|
||||
expect(scriptsLinkFromUrl("?blueprint=abc")).toEqual({
|
||||
search: "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(undefined),
|
||||
"ha-filter-devices": filter([]),
|
||||
"ha-filter-entities": filter([]),
|
||||
"ha-filter-labels": filter([]),
|
||||
"ha-filter-categories": filter([]),
|
||||
"ha-filter-voice-assistants": filter([]),
|
||||
"ha-filter-blueprints": filter(["abc"]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round trips the filters", () => {
|
||||
const filters = {
|
||||
"ha-filter-floor-areas": filter({ floors: ["ground"], areas: ["hall"] }),
|
||||
"ha-filter-devices": filter(["a", "b"]),
|
||||
"ha-filter-entities": filter(["light.hall"]),
|
||||
"ha-filter-labels": filter(["test"]),
|
||||
"ha-filter-categories": filter(["cat"]),
|
||||
"ha-filter-voice-assistants": filter(["conversation"]),
|
||||
"ha-filter-blueprints": filter(["confirmable_notification.yaml"]),
|
||||
};
|
||||
|
||||
expect(
|
||||
scriptsLinkFromUrl(
|
||||
createQueryString(
|
||||
scriptsFilterQueryParams(filters),
|
||||
scriptsFilterQueryParamConfig
|
||||
)
|
||||
)
|
||||
).toEqual({ search: "", filters });
|
||||
});
|
||||
|
||||
it("compares the same filters in another order as equal", () => {
|
||||
expect(
|
||||
scriptsFilterQueryString({ "ha-filter-labels": filter(["b", "a"]) })
|
||||
).toBe(
|
||||
scriptsFilterQueryString({ "ha-filter-labels": filter(["a", "b"]) })
|
||||
);
|
||||
expect(scriptsFilterQueryString({ "ha-filter-labels": filter([]) })).toBe(
|
||||
scriptsFilterQueryString({})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
expect(
|
||||
scriptsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../src/components/ha-log-viewer";
|
||||
|
||||
vi.mock("../../src/components/ha-button", () => ({}));
|
||||
vi.mock("../../src/components/ha-svg-icon", () => ({}));
|
||||
|
||||
const MAX_RETAINED_LINES = 5000;
|
||||
const LOG_LINE_HEIGHT = 20;
|
||||
const LOG_VIEWPORT_HEIGHT = 200;
|
||||
const ANIMATION_FRAME_DURATION = 16;
|
||||
const FILTER_DEBOUNCE = 150;
|
||||
|
||||
let viewer: HTMLElementTagNameMap["ha-log-viewer"];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (viewer) {
|
||||
viewer.remove();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const openLogViewer = async () => {
|
||||
viewer = document.createElement("ha-log-viewer");
|
||||
Object.assign(viewer, {
|
||||
_i18n: { localize: (key: string) => key },
|
||||
});
|
||||
document.body.append(viewer);
|
||||
await viewer.updateComplete;
|
||||
|
||||
const scrollContainer =
|
||||
viewer.shadowRoot!.querySelector<HTMLElement>(".log")!;
|
||||
scrollContainer.scrollTo = vi.fn();
|
||||
|
||||
const logRenderer = viewer.shadowRoot!.querySelector("ha-ansi-to-html")!;
|
||||
await logRenderer.updateComplete;
|
||||
vi.mocked(scrollContainer.scrollTo).mockClear();
|
||||
|
||||
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
|
||||
|
||||
vi.spyOn(scrollContainer, "scrollHeight", "get").mockImplementation(() => {
|
||||
const renderedRows = Array.from(renderedLog.querySelectorAll("div"));
|
||||
const visibleRows = renderedRows.filter(
|
||||
(row) => row.style.display !== "none"
|
||||
);
|
||||
return visibleRows.length * LOG_LINE_HEIGHT;
|
||||
});
|
||||
vi.spyOn(scrollContainer, "clientHeight", "get").mockReturnValue(
|
||||
LOG_VIEWPORT_HEIGHT
|
||||
);
|
||||
|
||||
return { renderedLog, scrollContainer };
|
||||
};
|
||||
|
||||
const getVisibleLines = (renderedLog: HTMLPreElement) =>
|
||||
Array.from(renderedLog.querySelectorAll("div"))
|
||||
.filter((row) => row.style.display !== "none")
|
||||
.map((row) => row.textContent);
|
||||
|
||||
const createLogLines = (firstLineNumber: number, numberOfLines: number) => {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (let index = 0; index < numberOfLines; index++) {
|
||||
const lineNumber = firstLineNumber + index;
|
||||
lines.push(`line ${lineNumber}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const openViewerWithFullLog = async () => {
|
||||
const logViewer = await openLogViewer();
|
||||
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
|
||||
|
||||
viewer.addLines(initialLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
return logViewer;
|
||||
};
|
||||
|
||||
const expectRetainedLines = (
|
||||
renderedLog: HTMLPreElement,
|
||||
expectedLines: string[]
|
||||
) => {
|
||||
const renderedRows = renderedLog.querySelectorAll("div");
|
||||
const renderedLines = Array.from(renderedRows, (row) => row.textContent);
|
||||
|
||||
expect(viewer.lines).toEqual(expectedLines);
|
||||
expect(renderedLines).toEqual(expectedLines);
|
||||
};
|
||||
|
||||
const search = async (value: string) => {
|
||||
viewer.filter = value;
|
||||
await viewer.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(FILTER_DEBOUNCE);
|
||||
};
|
||||
|
||||
describe("ha-log-viewer retention", { timeout: 15000 }, () => {
|
||||
it("renders lines added before the first render", async () => {
|
||||
viewer = document.createElement("ha-log-viewer");
|
||||
Object.assign(viewer, {
|
||||
_i18n: { localize: (key: string) => key },
|
||||
});
|
||||
viewer.addLines(["early 1", "early 2"]);
|
||||
document.body.append(viewer);
|
||||
await viewer.updateComplete;
|
||||
viewer.shadowRoot!.querySelector<HTMLElement>(".log")!.scrollTo = vi.fn();
|
||||
const logRenderer = viewer.shadowRoot!.querySelector("ha-ansi-to-html")!;
|
||||
await logRenderer.updateComplete;
|
||||
|
||||
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
|
||||
expectRetainedLines(renderedLog, ["early 1", "early 2"]);
|
||||
});
|
||||
|
||||
it("evicts the oldest stored and DOM lines as new batches arrive", async () => {
|
||||
const { renderedLog } = await openViewerWithFullLog();
|
||||
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
|
||||
expectRetainedLines(renderedLog, initialLines);
|
||||
|
||||
const linesPerBatch = 100;
|
||||
const numberOfBatches = 3;
|
||||
let totalReceivedLines = MAX_RETAINED_LINES;
|
||||
|
||||
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
|
||||
const incomingLines = createLogLines(totalReceivedLines, linesPerBatch);
|
||||
viewer.addLines(incomingLines);
|
||||
totalReceivedLines += linesPerBatch;
|
||||
|
||||
const firstRetainedLineNumber = totalReceivedLines - MAX_RETAINED_LINES;
|
||||
const expectedLines = createLogLines(
|
||||
firstRetainedLineNumber,
|
||||
MAX_RETAINED_LINES
|
||||
);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains only the newest lines up to the limit from an oversized multiline batch", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
const excessLineCount = 1000;
|
||||
const incomingLines = createLogLines(
|
||||
0,
|
||||
MAX_RETAINED_LINES + excessLineCount
|
||||
);
|
||||
const multilineMessages: string[] = [];
|
||||
|
||||
for (let lineIndex = 0; lineIndex < incomingLines.length; lineIndex += 2) {
|
||||
const firstLine = incomingLines[lineIndex];
|
||||
const secondLine = incomingLines[lineIndex + 1];
|
||||
multilineMessages.push(`${firstLine}\n${secondLine}`);
|
||||
}
|
||||
|
||||
viewer.addLines(multilineMessages);
|
||||
|
||||
const expectedLines = createLogLines(excessLineCount, MAX_RETAINED_LINES);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
|
||||
viewer.addLines([]);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("preserves the reading position when lines above it are removed", async () => {
|
||||
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
|
||||
const initialScrollTop = 2007;
|
||||
scrollContainer.scrollTop = initialScrollTop;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
|
||||
viewer.addLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const removedHeight = removedLineCount * LOG_LINE_HEIGHT;
|
||||
const expectedScrollTop = initialScrollTop - removedHeight;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(expectedScrollTop);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("scrolls to the top when the line being read is removed", async () => {
|
||||
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
|
||||
scrollContainer.scrollTop = 7;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
|
||||
viewer.addLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
expect(scrollContainer.scrollTop).toBe(0);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("continues following the bottom after evicting old lines", async () => {
|
||||
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
|
||||
const bottomScrollPosition =
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
||||
scrollContainer.scrollTop = bottomScrollPosition;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 1);
|
||||
viewer.addLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("clears the stored and rendered lines", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
viewer.addLines(["one", "two"]);
|
||||
|
||||
viewer.clear();
|
||||
|
||||
expectRetainedLines(renderedLog, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-log-viewer high volume", { timeout: 15000 }, () => {
|
||||
it("bounds high-volume batches without exceeding the function argument limit", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
const batchSize = 150000;
|
||||
for (let batchIndex = 0; batchIndex < 2; batchIndex++) {
|
||||
viewer.addLines(createLogLines(batchIndex * batchSize, batchSize));
|
||||
const firstRetainedLine =
|
||||
(batchIndex + 1) * batchSize - MAX_RETAINED_LINES;
|
||||
expectRetainedLines(
|
||||
renderedLog,
|
||||
createLogLines(firstRetainedLine, MAX_RETAINED_LINES)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps following after a scroll event caused by trimming a batch", async () => {
|
||||
const { scrollContainer } = await openViewerWithFullLog();
|
||||
scrollContainer.scrollTop =
|
||||
scrollContainer.scrollHeight - LOG_VIEWPORT_HEIGHT;
|
||||
|
||||
viewer.addLines(createLogLines(MAX_RETAINED_LINES, 10));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
|
||||
});
|
||||
|
||||
it("shows the new logs indicator when scrolled up", async () => {
|
||||
const { scrollContainer } = await openLogViewer();
|
||||
viewer.addLines(createLogLines(0, 50));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
scrollContainer.scrollTop = 107;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
|
||||
viewer.addLines(["new line"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(scrollContainer.scrollTop).toBe(107);
|
||||
expect(viewer["_newLogsIndicator"]).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the new logs indicator while it is hidden", async () => {
|
||||
const { scrollContainer } = await openLogViewer();
|
||||
const indicator = viewer.shadowRoot!.querySelector<
|
||||
HTMLElement & { disabled: boolean }
|
||||
>(".new-logs-indicator")!;
|
||||
expect(indicator.disabled).toBe(true);
|
||||
|
||||
viewer.addLines(createLogLines(0, 50));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
scrollContainer.scrollTop = 107;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
viewer.addLines(["new line"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
await viewer.updateComplete;
|
||||
|
||||
expect(indicator.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-log-viewer search", () => {
|
||||
it("hides lines that don't match and reports when nothing matches", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
viewer.addLines(["first match", "other", "second match"]);
|
||||
|
||||
await search("match");
|
||||
expect(getVisibleLines(renderedLog)).toEqual([
|
||||
"first match",
|
||||
"second match",
|
||||
]);
|
||||
expect(viewer["_noMatches"]).toBe(false);
|
||||
|
||||
await search("missing");
|
||||
expect(viewer["_noMatches"]).toBe(true);
|
||||
});
|
||||
|
||||
it("filters lines added while a search is active", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
viewer.addLines(["first match"]);
|
||||
await search("match");
|
||||
|
||||
viewer.addLines(["other", "second match"]);
|
||||
|
||||
expect(getVisibleLines(renderedLog)).toEqual([
|
||||
"first match",
|
||||
"second match",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no matches when the only matching line is evicted", async () => {
|
||||
const { renderedLog } = await openLogViewer();
|
||||
viewer.addLines(["match", ...createLogLines(1, MAX_RETAINED_LINES - 1)]);
|
||||
await search("match");
|
||||
expect(viewer["_noMatches"]).toBe(false);
|
||||
|
||||
viewer.addLines(createLogLines(MAX_RETAINED_LINES, 1));
|
||||
|
||||
expect(getVisibleLines(renderedLog)).toEqual([]);
|
||||
expect(viewer["_noMatches"]).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the reading position while searching and scrolls down when cleared", async () => {
|
||||
const { scrollContainer } = await openViewerWithFullLog();
|
||||
|
||||
await search("line 1");
|
||||
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
await search("");
|
||||
expect(scrollContainer.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -9,15 +9,25 @@ vi.mock("../../../../src/components/input/ha-input-search", () => ({}));
|
||||
vi.mock("../../../../src/components/ha-button", () => ({}));
|
||||
vi.mock("../../../../src/components/ha-dialog", () => ({}));
|
||||
vi.mock("../../../../src/components/ha-dialog-footer", () => ({}));
|
||||
vi.mock("../../../../src/components/ha-log-viewer", () => {
|
||||
class HaLogViewerStub extends HTMLElement {
|
||||
public lines: string[] = [];
|
||||
|
||||
public addLines(lines: string[]) {
|
||||
this.lines = this.lines.concat(lines);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lines = [];
|
||||
}
|
||||
}
|
||||
customElements.define("ha-log-viewer", HaLogViewerStub);
|
||||
return {};
|
||||
});
|
||||
vi.mock("../../../../src/data/error_log", () => ({
|
||||
subscribeIntegrationLog: vi.fn(async () => vi.fn()),
|
||||
}));
|
||||
|
||||
const MAX_RETAINED_LINES = 5000;
|
||||
const LOG_LINE_HEIGHT = 20;
|
||||
const LOG_VIEWPORT_HEIGHT = 200;
|
||||
const ANIMATION_FRAME_DURATION = 16;
|
||||
|
||||
let dialog: HTMLElementTagNameMap["dialog-live-log"];
|
||||
|
||||
const connectionListeners: Record<string, () => void> = {};
|
||||
@@ -56,249 +66,34 @@ const mountDialog = async () => {
|
||||
const openLogDialog = async () => {
|
||||
await mountDialog();
|
||||
|
||||
const logRenderer = dialog.shadowRoot!.querySelector("ha-ansi-to-html")!;
|
||||
await logRenderer.updateComplete;
|
||||
|
||||
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
|
||||
const scrollContainer =
|
||||
dialog.shadowRoot!.querySelector<HTMLElement>(".log")!;
|
||||
|
||||
vi.spyOn(scrollContainer, "scrollHeight", "get").mockImplementation(() => {
|
||||
const renderedRows = Array.from(renderedLog.querySelectorAll("div"));
|
||||
const visibleRows = renderedRows.filter(
|
||||
(row) => row.style.display !== "none"
|
||||
);
|
||||
return visibleRows.length * LOG_LINE_HEIGHT;
|
||||
});
|
||||
vi.spyOn(scrollContainer, "clientHeight", "get").mockReturnValue(
|
||||
LOG_VIEWPORT_HEIGHT
|
||||
);
|
||||
scrollContainer.scrollTo = vi.fn();
|
||||
|
||||
const logViewer = dialog.shadowRoot!.querySelector("ha-log-viewer")!;
|
||||
const subscriptionCall = vi.mocked(subscribeIntegrationLog).mock.calls[0];
|
||||
const receiveLogLines = subscriptionCall[2];
|
||||
|
||||
return { receiveLogLines, renderedLog, scrollContainer };
|
||||
return { receiveLogLines, logViewer };
|
||||
};
|
||||
|
||||
const getRenderedLines = (renderedLog: HTMLPreElement) =>
|
||||
Array.from(renderedLog.querySelectorAll("div"), (row) => row.textContent);
|
||||
|
||||
const createLogLines = (firstLineNumber: number, numberOfLines: number) => {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (let index = 0; index < numberOfLines; index++) {
|
||||
const lineNumber = firstLineNumber + index;
|
||||
lines.push(`line ${lineNumber}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const openDialogWithFullLog = async () => {
|
||||
const logDialog = await openLogDialog();
|
||||
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
|
||||
|
||||
logDialog.receiveLogLines(initialLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
return logDialog;
|
||||
};
|
||||
|
||||
const expectRetainedLines = (
|
||||
renderedLog: HTMLPreElement,
|
||||
expectedLines: string[]
|
||||
) => {
|
||||
const storedLines = dialog["_lines"];
|
||||
const renderedRows = renderedLog.querySelectorAll("div");
|
||||
const renderedLines = Array.from(renderedRows, (row) => row.textContent);
|
||||
|
||||
expect(storedLines).toEqual(expectedLines);
|
||||
expect(renderedLines).toEqual(expectedLines);
|
||||
};
|
||||
|
||||
describe("dialog-live-log retention", { timeout: 15000 }, () => {
|
||||
it("evicts the oldest stored and DOM lines as new batches arrive", async () => {
|
||||
const { receiveLogLines, renderedLog } = await openDialogWithFullLog();
|
||||
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
|
||||
expectRetainedLines(renderedLog, initialLines);
|
||||
|
||||
const linesPerBatch = 100;
|
||||
const numberOfBatches = 3;
|
||||
let totalReceivedLines = MAX_RETAINED_LINES;
|
||||
|
||||
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
|
||||
const incomingLines = createLogLines(totalReceivedLines, linesPerBatch);
|
||||
receiveLogLines(incomingLines);
|
||||
totalReceivedLines += linesPerBatch;
|
||||
|
||||
const firstRetainedLineNumber = totalReceivedLines - MAX_RETAINED_LINES;
|
||||
const expectedLines = createLogLines(
|
||||
firstRetainedLineNumber,
|
||||
MAX_RETAINED_LINES
|
||||
);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains only the newest lines up to the limit from an oversized multiline batch", async () => {
|
||||
const { receiveLogLines, renderedLog } = await openLogDialog();
|
||||
const excessLineCount = 1000;
|
||||
const incomingLines = createLogLines(
|
||||
0,
|
||||
MAX_RETAINED_LINES + excessLineCount
|
||||
);
|
||||
const multilineMessages: string[] = [];
|
||||
|
||||
for (let lineIndex = 0; lineIndex < incomingLines.length; lineIndex += 2) {
|
||||
const firstLine = incomingLines[lineIndex];
|
||||
const secondLine = incomingLines[lineIndex + 1];
|
||||
multilineMessages.push(`${firstLine}\n${secondLine}`);
|
||||
}
|
||||
|
||||
receiveLogLines(multilineMessages);
|
||||
|
||||
const expectedLines = createLogLines(excessLineCount, MAX_RETAINED_LINES);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
|
||||
receiveLogLines([]);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("preserves the reading position when lines above it are removed", async () => {
|
||||
const { receiveLogLines, renderedLog, scrollContainer } =
|
||||
await openDialogWithFullLog();
|
||||
const initialScrollTop = 2007;
|
||||
scrollContainer.scrollTop = initialScrollTop;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
|
||||
receiveLogLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const removedHeight = removedLineCount * LOG_LINE_HEIGHT;
|
||||
const expectedScrollTop = initialScrollTop - removedHeight;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(expectedScrollTop);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("scrolls to the top when the line being read is removed", async () => {
|
||||
const { receiveLogLines, renderedLog, scrollContainer } =
|
||||
await openDialogWithFullLog();
|
||||
scrollContainer.scrollTop = 7;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
|
||||
receiveLogLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
expect(scrollContainer.scrollTop).toBe(0);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
|
||||
it("continues following the bottom after evicting old lines", async () => {
|
||||
const { receiveLogLines, renderedLog, scrollContainer } =
|
||||
await openDialogWithFullLog();
|
||||
const bottomScrollPosition =
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
||||
scrollContainer.scrollTop = bottomScrollPosition;
|
||||
|
||||
const incomingLines = createLogLines(MAX_RETAINED_LINES, 1);
|
||||
receiveLogLines(incomingLines);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
|
||||
const removedLineCount = incomingLines.length;
|
||||
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
|
||||
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
|
||||
expectRetainedLines(renderedLog, expectedLines);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog-live-log high volume", { timeout: 15000 }, () => {
|
||||
it("bounds high-volume batches without exceeding the function argument limit", async () => {
|
||||
const { receiveLogLines, renderedLog } = await openLogDialog();
|
||||
const batchSize = 150000;
|
||||
for (let batchIndex = 0; batchIndex < 2; batchIndex++) {
|
||||
receiveLogLines(createLogLines(batchIndex * batchSize, batchSize));
|
||||
const firstRetainedLine =
|
||||
(batchIndex + 1) * batchSize - MAX_RETAINED_LINES;
|
||||
expectRetainedLines(
|
||||
renderedLog,
|
||||
createLogLines(firstRetainedLine, MAX_RETAINED_LINES)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps following after a scroll event caused by trimming a batch", async () => {
|
||||
const { receiveLogLines, scrollContainer } = await openDialogWithFullLog();
|
||||
scrollContainer.scrollTop =
|
||||
scrollContainer.scrollHeight - LOG_VIEWPORT_HEIGHT;
|
||||
|
||||
receiveLogLines(createLogLines(MAX_RETAINED_LINES, 10));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
|
||||
});
|
||||
|
||||
it("shows the new logs indicator when scrolled up", async () => {
|
||||
const { receiveLogLines, scrollContainer } = await openLogDialog();
|
||||
receiveLogLines(createLogLines(0, 50));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
scrollContainer.scrollTop = 107;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
|
||||
receiveLogLines(["new line"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(scrollContainer.scrollTop).toBe(107);
|
||||
expect(dialog["_newLogsIndicator"]).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the new logs indicator while it is hidden", async () => {
|
||||
const { receiveLogLines, scrollContainer } = await openLogDialog();
|
||||
const indicator = dialog.shadowRoot!.querySelector<
|
||||
HTMLElement & { disabled: boolean }
|
||||
>(".new-logs-indicator")!;
|
||||
expect(indicator.disabled).toBe(true);
|
||||
|
||||
receiveLogLines(createLogLines(0, 50));
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
scrollContainer.scrollTop = 107;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
receiveLogLines(["new line"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
await dialog.updateComplete;
|
||||
|
||||
expect(indicator.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog-live-log connection", () => {
|
||||
it("subscribes again without duplicating the backlog after a reconnect", async () => {
|
||||
const { receiveLogLines, renderedLog } = await openLogDialog();
|
||||
const { receiveLogLines, logViewer } = await openLogDialog();
|
||||
receiveLogLines(["backlog 1", "backlog 2"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(dialog["_lineCount"]).toBe(2);
|
||||
|
||||
connectionListeners.disconnected();
|
||||
await dialog.updateComplete;
|
||||
expect(dialog["_live"]).toBe(false);
|
||||
|
||||
connectionListeners.ready();
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(subscribeIntegrationLog).toHaveBeenCalledTimes(2);
|
||||
expect(dialog["_live"]).toBe(true);
|
||||
expect(dialog["_lineCount"]).toBe(0);
|
||||
|
||||
const receiveAfterReconnect = vi.mocked(subscribeIntegrationLog).mock
|
||||
.calls[1][2];
|
||||
receiveAfterReconnect(["backlog 1", "backlog 2", "new"]);
|
||||
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
|
||||
expect(getRenderedLines(renderedLog)).toEqual([
|
||||
"backlog 1",
|
||||
"backlog 2",
|
||||
"new",
|
||||
]);
|
||||
expect(logViewer.lines).toEqual(["backlog 1", "backlog 2", "new"]);
|
||||
expect(dialog["_lineCount"]).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores a failed subscription when removed before it settles", async () => {
|
||||
@@ -372,40 +167,3 @@ describe("dialog-live-log subscription errors", () => {
|
||||
expect(dialog["_live"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog-live-log search", () => {
|
||||
const search = async (value: string) => {
|
||||
const input = dialog.shadowRoot!.querySelector("ha-input-search")!;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
};
|
||||
|
||||
it("hides lines that don't match and reports when nothing matches", async () => {
|
||||
const { receiveLogLines, renderedLog } = await openLogDialog();
|
||||
receiveLogLines(["first match", "other", "second match"]);
|
||||
|
||||
await search("match");
|
||||
const visibleRows = Array.from(renderedLog.querySelectorAll("div")).filter(
|
||||
(row) => row.style.display !== "none"
|
||||
);
|
||||
expect(visibleRows.map((row) => row.textContent)).toEqual([
|
||||
"first match",
|
||||
"second match",
|
||||
]);
|
||||
expect(dialog["_noMatches"]).toBe(false);
|
||||
|
||||
await search("missing");
|
||||
expect(dialog["_noMatches"]).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the reading position while searching and scrolls down when cleared", async () => {
|
||||
const { scrollContainer } = await openDialogWithFullLog();
|
||||
|
||||
await search("line 1");
|
||||
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
await search("");
|
||||
expect(scrollContainer.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||