Compare commits

..
35 changed files with 621 additions and 319 deletions

No files matched your search

@@ -2,8 +2,9 @@ import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HASSDomEvent } from "../dom/fire_event";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl } from "../navigate";
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
@@ -29,8 +30,8 @@ export interface QueryParamsControllerOptions {
* Keeps part of a page's state in the query string of the URL, so it can be
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on.
* config's params. The controller only acts while the host is connected and
* the URL is still the page it was first connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
@@ -43,6 +44,8 @@ export class QueryParamsController<
private _hostUpdated = false;
private _connected = false;
constructor(
private _host: ReactiveControllerHost,
private _config: C,
@@ -52,7 +55,10 @@ export class QueryParamsController<
}
public hostConnected() {
this._path = currentPath();
// A cached page is reconnected on the way to another page, before its
// router has moved on, so it keeps the page it was first connected on
this._path ??= currentPath();
this._connected = true;
mainWindow.addEventListener("location-changed", this._navigated);
mainWindow.addEventListener("popstate", this._navigated);
@@ -68,6 +74,9 @@ export class QueryParamsController<
}
public hostDisconnected() {
// A page that was left can still finish a lookup, while a new page on the
// same path is showing
this._connected = false;
mainWindow.removeEventListener("location-changed", this._navigated);
mainWindow.removeEventListener("popstate", this._navigated);
this._cancelPending();
@@ -117,11 +126,25 @@ export class QueryParamsController<
}
private _isActive(): boolean {
return this._options.enabled() && currentPath() === this._path;
return (
this._connected && this._options.enabled() && currentPath() === this._path
);
}
private _navigated = () => {
private _navigated = (ev?: Event | HASSDomEvent<NavigateOptions>) => {
this._cancelPending();
// A router sends a path without a page, like /config/automation, on to
// its default page after the host is connected, so it follows that
const path = currentPath();
if (
ev &&
"detail" in ev &&
ev.detail?.replace &&
path.startsWith(`${this._path}/`)
) {
this._path = path;
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
+114
View File
@@ -0,0 +1,114 @@
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 devices table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const devicesFilterQueryParamConfig = {
list: [
"status",
"floor",
"area",
"domain",
"label",
"config_entry",
"sub_entry",
],
} as const satisfies QueryParamConfig;
export type DevicesFilterQueryParams = QueryParamValues<
typeof devicesFilterQueryParamConfig
>;
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 devicesFilterQueryParams = (
filters: DataTableFilters
): DevicesFilterQueryParams => ({
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
domain: list(filters["ha-filter-integrations"]),
label: list(filters["ha-filter-labels"]),
config_entry: list(filters.config_entry),
sub_entry: list(filters.sub_entry),
});
// 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 devicesFilterQueryString = (filters: DataTableFilters): string => {
const params = devicesFilterQueryParams(filters);
const sorted: DevicesFilterQueryParams = {};
for (const key of devicesFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
}
return createQueryString(sorted, devicesFilterQueryParamConfig);
};
// 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 DevicesLink {
search: string;
filters: DataTableFilters;
}
export const devicesLinkFromUrl = (
searchParams: SearchParamsSource
): DevicesLink | undefined => {
if (
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof DevicesFilterQueryParams): 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-states": filter(values("status")),
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-integrations": filter(values("domain")),
"ha-filter-labels": filter(values("label")),
config_entry: filter(values("config_entry")),
sub_entry: filter(values("sub_entry")),
},
};
};
@@ -120,7 +120,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this._localize;
const localize = this._localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -1,5 +1,4 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { DataTableColumnContainer } from "./ha-data-table";
export interface DataTableSettingsDialogParams {
@@ -10,7 +9,6 @@ export interface DataTableSettingsDialogParams {
) => void;
hiddenColumns?: string[];
columnOrder?: string[];
localizeFunc?: LocalizeFunc;
}
export const loadDataTableSettingsDialog = () =>
+3 -32
View File
@@ -1,7 +1,6 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "./ha-ripple";
@customElement("ha-tab")
@@ -17,14 +16,7 @@ export class HaTab extends LitElement {
protected render(): TemplateResult {
return html`
<div
tabindex="0"
role="tab"
aria-selected=${this.active}
aria-label=${ifDefined(this.name)}
aria-describedby=${ifDefined(this.badge ? "badge" : undefined)}
@keydown=${this._handleKeyDown}
>
<div>
<span class="content">
<span class="icon">
<slot name="icon"></slot>
@@ -37,7 +29,7 @@ export class HaTab extends LitElement {
<span class="name">${this.name}</span>
${
this.badge
? html`<span class="badge" id="badge">${this.badge}</span>`
? html`<span class="badge">${this.badge}</span>`
: nothing
}
</span>
@@ -46,12 +38,6 @@ export class HaTab extends LitElement {
`;
}
private _handleKeyDown(ev: KeyboardEvent): void {
if (ev.key === "Enter") {
(ev.target as HTMLElement).click();
}
}
static styles = css`
div {
padding: 0;
@@ -64,7 +50,6 @@ export class HaTab extends LitElement {
height: var(--header-height);
cursor: pointer;
position: relative;
outline: none;
}
.content {
@@ -115,7 +100,7 @@ export class HaTab extends LitElement {
background-color: var(--ha-color-fill-primary-loud-resting);
}
/* Hidden from view but still read out through aria-describedby */
/* Hidden from view but still read out as part of the link name */
:host([narrow]) .badge {
position: absolute;
width: 1px;
@@ -159,20 +144,6 @@ export class HaTab extends LitElement {
:host(:not([narrow])) ::slotted([slot="icon"]) {
margin-bottom: 0;
}
div:focus-visible {
background-color: rgba(var(--rgb-secondary-text-color), 0.08);
}
@supports (color: color-mix(in srgb, black, transparent)) {
div:focus-visible {
background-color: color-mix(
in srgb,
var(--secondary-text-color) 8%,
transparent
);
}
}
`;
}
declare global {
+5 -3
View File
@@ -204,12 +204,14 @@ export class TraceTree {
const node = this._base(config, path, type, parentDisabled);
const records = this.trace.trace[path] as ConditionTraceStep[] | undefined;
const executed = !!records?.some((record) => record.result || record.error);
// A disabled condition is still recorded by Core, with a `null` result
// (or `enabled: false` in a sequence). It is skipped, so the run passes.
node.condition = {
executed,
passed: !!records?.some((record) => record.result?.result),
failed: !!records?.some(
(record) => record.result && !record.result.result
passed: !!records?.some(
(record) => record.result && record.result.result !== false
),
failed: !!records?.some((record) => record.result?.result === false),
};
node.track = executed;
return node;
+2 -1
View File
@@ -32,7 +32,8 @@ export interface TriggerTraceStep extends BaseTraceStep {
}
export interface ConditionTraceStep extends BaseTraceStep {
result?: { result: boolean };
// `null` when the condition is disabled.
result?: { result: boolean | null };
}
export interface CallServiceActionTraceStep extends BaseTraceStep {
+1 -6
View File
@@ -21,7 +21,6 @@ import { canShowPage } from "../common/config/can_show_page";
import { QueryParamsController } from "../common/controllers/query-params-controller";
import { consume } from "../common/decorators/consume";
import { fireEvent, type HASSDomTargetEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import {
dataTableQueryParamConfig,
dataTableQueryParamsFromState,
@@ -62,8 +61,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
// Unread, kept for callers that still pass it until they move to contexts
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@@ -340,7 +337,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
}
protected render(): TemplateResult {
const localize = this.localizeFunc || this._i18n.localize;
const localize = this._i18n.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<ha-filter-pane-chip
@@ -495,7 +492,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
return html`
<hass-tabs-subpage
.localizeFunc=${this.localizeFunc}
.isWide=${this.isWide}
.backPath=${this.backPath}
.backCallback=${this.backCallback}
@@ -868,7 +864,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this.hiddenColumns = hiddenColumns;
fireEvent(this, "columns-changed", { columnOrder, hiddenColumns });
},
localizeFunc: this.localizeFunc,
});
}
+99 -22
View File
@@ -9,16 +9,19 @@ import {
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { canShowPage } from "../common/config/can_show_page";
import { consume } from "../common/decorators/consume";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -29,8 +32,10 @@ import {
internationalizationContext,
narrowViewportContext,
} from "../data/context";
import type { CoreFrontendUserData } from "../data/frontend";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
import { handleBackClick } from "./back-navigation";
const normalizePathname = (pathname: string): string =>
pathname.endsWith("/") && pathname.length > 1
@@ -61,8 +66,6 @@ export class HassTabsSubpage extends LitElement {
// Unread, kept for callers that still pass it until they move to contexts
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
@property({ type: String, attribute: "back-path" }) public backPath?: string;
@property({ attribute: false }) public backCallback?: () => void;
@@ -110,6 +113,10 @@ export class HassTabsSubpage extends LitElement {
@state() private _activeTab?: PageNavigation;
@state() private _hasCustomHeader = false;
@state() private _hasCustomToolbar = false;
@query(".content") private _content?: HTMLDivElement;
// @ts-ignore
@@ -119,11 +126,11 @@ export class HassTabsSubpage extends LitElement {
(
tabs: PageNavigation[],
activeTab: PageNavigation | undefined,
_components,
_language,
_userData,
_narrow,
localizeFunc,
_components: string[],
_language: string,
_userData: CoreFrontendUserData | undefined,
_narrow: boolean,
localize: LocalizeFunc,
entities: ContextType<typeof entitiesContext>
) => {
const shownTabs = tabs.filter((page) =>
@@ -134,9 +141,7 @@ export class HassTabsSubpage extends LitElement {
this.showTabs = false;
if (shownTabs.length === 1) {
const page = shownTabs[0];
return [
page.translationKey ? localizeFunc(page.translationKey) : page.name,
];
return [this._tabName(page, localize)];
}
return [""];
}
@@ -144,16 +149,18 @@ export class HassTabsSubpage extends LitElement {
this.showTabs = true;
return shownTabs.map(
(page) => html`
<a href=${page.path} @click=${this._tabClicked}>
<a
href=${page.path}
aria-current=${ifDefined(
page.path === activeTab?.path ? "page" : undefined
)}
@click=${this._tabClicked}
>
<ha-tab
.active=${page.path === activeTab?.path}
.narrow=${this._narrow}
.badge=${page.badge}
.name=${
page.translationKey
? localizeFunc(page.translationKey)
: page.name
}
.name=${this._tabName(page, localize)}
>
${
page.iconPath
@@ -183,6 +190,7 @@ export class HassTabsSubpage extends LitElement {
}
protected render(): TemplateResult {
const localize = this._i18n.localize;
const tabs = this._getTabs(
this.tabs,
this._activeTab,
@@ -190,14 +198,19 @@ export class HassTabsSubpage extends LitElement {
this._i18n.language,
this._hassConfig.userData,
this._narrow,
this.localizeFunc || this._i18n.localize,
localize,
this._entities
);
const backPath = sanitizeNavigationPath(this.backPath);
const hiddenHeading = this.showTabs
? this._activeTab && this._tabName(this._activeTab, localize)
: this._hasCustomHeader || this._hasCustomToolbar
? (tabs[0] as string)
: undefined;
return html`
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<slot name="toolbar">
<slot name="toolbar" @slotchange=${this._toolbarSlotChanged}>
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
@@ -212,7 +225,9 @@ export class HassTabsSubpage extends LitElement {
${
this._narrow || !this.showTabs
? html`<div class="main-title">
<slot name="header">${!this.showTabs ? tabs[0] : ""}</slot>
<slot name="header" @slotchange=${this._headerSlotChanged}
>${!this.showTabs ? html`<h1>${tabs[0]}</h1>` : ""}</slot
>
</div>`
: ""
}
@@ -244,6 +259,11 @@ export class HassTabsSubpage extends LitElement {
: nothing
}
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
${
hiddenHeading
? html`<h1 class="visually-hidden">${hiddenHeading}</h1>`
: nothing
}
<slot></slot>
${this.hasFab ? html`<div class="fab-bottom-space"></div>` : nothing}
</div>
@@ -268,6 +288,33 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _headerSlotChanged(ev: Event): void {
if (ev.target !== ev.currentTarget) {
return;
}
this._hasCustomHeader =
(ev.target as HTMLSlotElement).assignedNodes().length > 0;
}
// slotchange bubbles, so nested slots also land here
private _toolbarSlotChanged(ev: Event): void {
if (ev.target !== ev.currentTarget) {
return;
}
this._hasCustomToolbar =
(ev.target as HTMLSlotElement).assignedNodes().length > 0;
}
private _tabName(
page: PageNavigation,
localize: LocalizeFunc
): string | undefined {
return (
(page.translationKey && localize(page.translationKey as LocalizeKeys)) ||
page.name
);
}
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
}
@@ -373,6 +420,32 @@ export class HassTabsSubpage extends LitElement {
max-width: 45%;
}
#tabbar > a:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: calc(-1 * var(--wa-focus-ring-width));
background-color: rgba(var(--rgb-secondary-text-color), 0.08);
}
@supports (color: color-mix(in srgb, black, transparent)) {
#tabbar > a:focus-visible {
background-color: color-mix(
in srgb,
var(--secondary-text-color) 8%,
transparent
);
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
#tabbar.bottom-bar {
position: absolute;
bottom: 0;
@@ -418,6 +491,10 @@ export class HassTabsSubpage extends LitElement {
.narrow .main-title {
margin-inline-start: var(--main-title-margin, var(--ha-space-2));
}
.main-title h1 {
margin: 0;
font: inherit;
}
.content {
position: relative;
@@ -255,14 +255,14 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
this._filteredSchema(this.addon.options, this.addon.schema!)
);
return html`
<h1>${this.addon.name}</h1>
<h2 class="app-name">${this.addon.name}</h2>
<ha-card outlined>
<div class="header">
<h2>
<h3>
${this.hass.localize(
"ui.panel.config.apps.configuration.options.header"
)}
</h2>
</h3>
<div class="card-menu">
<ha-dropdown @wa-select=${this._handleAction}>
<ha-icon-button
@@ -547,11 +547,22 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
.app-name {
margin-block: var(--ha-space-4) var(--ha-space-2);
padding-inline-start: var(--ha-space-2);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
.header {
display: flex;
justify-content: space-between;
}
.header h2 {
.header h3 {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
@@ -561,6 +572,9 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
display: block;
margin-block: 0px;
font-weight: var(--ha-font-weight-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-actions.right {
justify-content: flex-end;
@@ -89,12 +89,14 @@ type AreaQuickLinkKey =
const NAVIGATION_ACTIONS: {
value: string;
path: string;
params?: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
params: "&status=disabled",
icon: mdiDevices,
countKey: "devices",
},
@@ -852,7 +854,9 @@ class HaConfigAreaPage extends LitElement {
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
navigate(
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
);
return;
}
@@ -747,11 +747,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
!this.automations.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
<h1>
<h2>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_header"
)}
</h1>
</h2>
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_text_1"
@@ -1626,8 +1626,14 @@ ${rejected
.empty ha-button {
--mdc-icon-size: 24px;
}
.empty h1 {
.empty h2 {
margin-block: var(--ha-space-5);
font-family: var(--ha-font-family-heading);
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
@@ -12,6 +12,8 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { 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";
@@ -24,12 +26,15 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesFilterQueryString,
devicesLinkFromUrl,
} from "../../../common/url/devices-query-params";
import {
hasRejectedItems,
rejectedItems,
@@ -38,7 +43,6 @@ import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
@@ -132,8 +136,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selected: string[] = [];
private _selectedCanDelete: string[] = [];
@@ -141,14 +143,11 @@ export class HaConfigDeviceDashboard extends LitElement {
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@state()
@storage({
storage: "sessionStorage",
key: "devices-table-search",
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _tableSettings = new DataTableSettingsController(this, {
search: "devices-table-search",
sorting: "devices-table-sort",
grouping: "devices-table-grouping",
});
@state()
private _filters: DataTableFilters = {};
@@ -169,12 +168,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@state()
_labels?: LabelRegistryEntry[];
@storage({ key: "devices-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "devices-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({ key: "devices-table-collapsed", state: false, subscribe: false })
private _activeCollapsed?: string;
@@ -196,39 +189,16 @@ export class HaConfigDeviceDashboard extends LitElement {
callback: (entries) => entries[0]?.contentRect.width,
});
private _ignoreLocationChange = false;
private _fromUrl = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (this._ignoreLocationChange) {
this._ignoreLocationChange = false;
return;
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
devicesFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
);
private _states = memoizeOne((localize: LocalizeFunc) => [
{
@@ -241,7 +211,7 @@ export class HaConfigDeviceDashboard extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
this._applyLink();
}
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
@@ -256,60 +226,45 @@ export class HaConfigDeviceDashboard extends LitElement {
}
}
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
// Links, like the ones on an integration 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 ? devicesLinkFromUrl(searchParams) : undefined;
// Shown, but not stored as the user's choice
if (link) {
this._tableSettings.showSearch(link.search);
this._filters = this._keepItems(link.filters);
} else if (searchParams?.toString() === "") {
// Opened again without params, like from the sidebar. Written once the
// table has seen the URL without params, so it restores what it stored
this._filters = this._keepItems(this._storageFilters);
queueMicrotask(() => this._writeFilters());
if (!area && !domain && !configEntry && !label) {
return;
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": {
value: [
...((this._filters["ha-filter-states"]?.value as string[]) || []),
"disabled",
],
items: undefined,
},
"ha-filter-floor-areas": {
value: area ? { areas: [area] } : undefined,
items: undefined,
},
"ha-filter-integrations": {
value: domain ? [domain] : [],
items: undefined,
},
config_entry: {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
this._writeFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
// 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(devicesFilterQueryParams(this._filters));
}
private _reloadConfigEntries() {
@@ -318,9 +273,8 @@ export class HaConfigDeviceDashboard extends LitElement {
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._writeFilters();
}
private _devicesAndFilterDomains = memoizeOne(
@@ -399,24 +353,29 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
if (!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)) {
const configEntryIds = this._filters.config_entry?.value;
const subEntryIds = filter.value;
if (
!Array.isArray(configEntryIds) ||
!configEntryIds.length ||
!Array.isArray(subEntryIds)
) {
return;
}
const configEntryId = this._filters.config_entry.value[0];
outputDevices = outputDevices.filter(
(device) =>
device.config_entries_subentries[configEntryId] &&
(filter.value as string[]).some((subEntryId) =>
device.config_entries_subentries[configEntryId].includes(
subEntryId
)
outputDevices = outputDevices.filter((device) =>
configEntryIds.some((configEntryId) =>
device.config_entries_subentries[configEntryId]?.some(
(subEntryId) =>
subEntryId !== null && subEntryIds.includes(subEntryId)
)
)
);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
// The titles shown in the filter chip need a single config entry
if (configEntryIds.length === 1 && !this._subEntries) {
this._loadSubEntries(configEntryIds[0]);
}
} else if (
key === "ha-filter-integrations" &&
@@ -654,6 +613,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "integration",
minWidth: "120px",
template: (device) =>
!this.entries
@@ -669,6 +629,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "parent",
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
@@ -678,6 +639,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "manufacturer",
minWidth: "120px",
},
model: {
@@ -875,7 +837,7 @@ export class HaConfigDeviceDashboard extends LitElement {
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.filter=${this._filter}
.filter=${this._tableSettings.search}
has-filters
.filters=${
Object.values(this._filters).filter((filter) =>
@@ -887,19 +849,20 @@ export class HaConfigDeviceDashboard extends LitElement {
)
).length
}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@clear-filter=${this._clearFilter}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@search-changed=${this._tableSettings.searchChanged}
@sorting-changed=${this._tableSettings.sortingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
sync-url
has-fab
class=${this.narrow ? "narrow" : ""}
>
@@ -1093,10 +1056,27 @@ export class HaConfigDeviceDashboard extends LitElement {
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
if (!this._fromUrl) {
this._storageFilters = this._filters;
const filters = { ...this._filters, [type]: ev.detail };
// A filter can still finish looking up its items after the page is left.
// Keep them for when the page comes back, but leave the URL alone.
if (!this.isConnected) {
this._filters = filters;
return;
}
// 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 (
devicesFilterQueryString(filters) !==
devicesFilterQueryString(this._filters)
) {
this._storageFilters = { ...this._storageFilters, [type]: ev.detail };
}
this._filters = filters;
this._writeFilters();
}
private _batteryEntity(
@@ -1123,15 +1103,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const deviceId = ev.detail.id;
this._ignoreLocationChange = true;
navigate(`/config/devices/device/${deviceId}`);
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
const { filteredConfigEntry, filteredDomains } =
this._devicesAndFilterDomains(
@@ -1157,8 +1131,13 @@ export class HaConfigDeviceDashboard extends LitElement {
});
return;
}
const integrations = this._filters["ha-filter-integrations"]?.value;
showAddIntegrationDialog(this, {
domain: this._searchParms.get("domain") || undefined,
domain:
Array.isArray(integrations) && integrations.length === 1
? integrations[0]
: undefined,
navigateToResult: true,
});
}
@@ -1319,14 +1298,6 @@ ${rejected
this._dataTable.clearSelection();
}
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;
}
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allDevices.length === 1
? `/config/devices/device/${allDevices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
}
>
<ha-dropdown-item value="devices">
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allServices.length === 1
? `/config/devices/device/${allServices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
}
>
<ha-dropdown-item value="services">
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
}
>
${this.hass.localize(
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
}
>
${this.hass.localize(
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
}
>
<ha-dropdown-item value="devices">
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
}
>
<ha-dropdown-item value="services">
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
private async _openDialog() {
await navigate(
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
{
replace: true,
}
@@ -326,7 +326,7 @@ class ZWaveJSConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
+5 -3
View File
@@ -468,7 +468,7 @@ export class HaConfigLabels extends LitElement {
this._navigateConfig("/config/entities");
break;
case "navigate-devices":
this._navigateConfig("/config/devices/dashboard");
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
break;
case "navigate-automations":
this._navigateConfig("/config/automation/dashboard");
@@ -485,8 +485,10 @@ export class HaConfigLabels extends LitElement {
}
};
private _navigateConfig = (path: string) => {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
private _navigateConfig = (path: string, params = "") => {
navigate(
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
);
};
private _handleColumnsChanged(ev: CustomEvent) {
+8 -19
View File
@@ -95,8 +95,6 @@ const SEARCH_CONTEXT_LINES = 3;
@customElement("error-log-card")
class ErrorLogCard extends LitElement {
@property({ attribute: false }) public localizeFunc?: LocalizeFunc<any>;
@property() public filter = "";
@property() public header?: string;
@@ -194,7 +192,7 @@ class ErrorLogCard extends LitElement {
const hasBoots =
!integrationLog && this._streamSupported && Array.isArray(this._boots);
const localize = this.localizeFunc || this._localize;
const localize = this._localize;
const logFileDownloadUnavailableReason =
!integrationLog && (!this.provider || this.provider === "core")
? getCoreLogFileDownloadUnavailableReason(this._config)
@@ -473,10 +471,7 @@ class ErrorLogCard extends LitElement {
this.filter)
) {
this._debounceSearch();
} else if (
(changedProps.has("_localize") || changedProps.has("localizeFunc")) &&
this.filter
) {
} else if (changedProps.has("_localize") && this.filter) {
// The hidden lines buttons are built outside Lit, so relabel them.
this._ansiToHtmlElement?.filterLines(this.filter);
}
@@ -736,13 +731,10 @@ class ErrorLogCard extends LitElement {
return;
}
this._error = (this.localizeFunc || this._localize)(
"ui.panel.config.logs.failed_get_logs",
{
provider: this.provider,
error: extractApiErrorMessage(err),
}
);
this._error = this._localize("ui.panel.config.logs.failed_get_logs", {
provider: this.provider,
error: extractApiErrorMessage(err),
});
}
}
@@ -821,7 +813,7 @@ class ErrorLogCard extends LitElement {
}
this._unsubscribeIntegrationLog();
this._loadingState = "loaded";
this._error = (this.localizeFunc || this._localize)(
this._error = this._localize(
"ui.panel.config.logs.integration_log_unsupported"
);
}
@@ -874,10 +866,7 @@ class ErrorLogCard extends LitElement {
}, 150);
private _expandLabel = (count: number) =>
(this.localizeFunc || this._localize)(
"ui.panel.config.logs.show_hidden_lines",
{ count }
);
this._localize("ui.panel.config.logs.show_hidden_lines", { count });
private _debounceScrollToBottom = debounce(() => {
this._logElement!.scrollTop = this._logElement!.scrollHeight;
@@ -692,11 +692,11 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
!this.scenes.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiPalette}></ha-svg-icon>
<h1>
<h2>
${this.hass.localize(
"ui.panel.config.scene.picker.empty_header"
)}
</h1>
</h2>
<p>
${this.hass.localize("ui.panel.config.scene.picker.empty_text")}
</p>
@@ -1319,8 +1319,14 @@ ${rejected
.empty ha-button {
--mdc-icon-size: 24px;
}
.empty h1 {
.empty h2 {
margin-block: var(--ha-space-5);
font-family: var(--ha-font-family-heading);
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
+10 -4
View File
@@ -22,8 +22,8 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
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";
@@ -673,11 +673,11 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
!this.scripts.length
? html` <div class="empty" slot="empty">
<ha-svg-icon .path=${mdiScriptText}></ha-svg-icon>
<h1>
<h2>
${this.hass.localize(
"ui.panel.config.script.picker.empty_header"
)}
</h1>
</h2>
<p>
${this.hass.localize(
"ui.panel.config.script.picker.empty_text"
@@ -1381,8 +1381,14 @@ ${rejected
.empty ha-button {
--mdc-icon-size: 24px;
}
.empty h1 {
.empty h2 {
margin-block: var(--ha-space-5);
font-family: var(--ha-font-family-heading);
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
+15 -9
View File
@@ -9,7 +9,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
@@ -76,9 +76,14 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
return this.hass.auth.external?.config.canWriteTag;
}
private _tableSettings = new DataTableSettingsController(this, {
search: "tags-table-search",
});
@state()
@storage({
storage: "sessionStorage",
key: "tags-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<TagRowData> = {
@@ -94,7 +99,6 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.tag.headers.name"),
main: true,
sortable: true,
urlName: "name",
filterable: true,
flex: 2,
},
@@ -107,7 +111,6 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
last_scanned_datetime: {
title: localize("ui.panel.config.tag.headers.last_scanned"),
sortable: true,
urlName: "last_scanned",
direction: "desc",
template: (tag) => html`
${
@@ -215,9 +218,8 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
: undefined
}
@retry-load=${this._retryFetchTags}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
@@ -415,6 +417,10 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
}
});
};
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
}
declare global {
@@ -721,7 +721,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
this.columnOrder = columnOrder;
this.hiddenColumns = hiddenColumns;
},
localizeFunc: this._i18n.localize,
});
}
@@ -95,16 +95,16 @@ export class HaMarketplaceDiscover extends LitElement {
return html`<div class="content">
<div class="intro">
<h1>${localize("ui.panel.marketplace.discover.title")}</h1>
<h2>${localize("ui.panel.marketplace.discover.title")}</h2>
<p>${localize("ui.panel.marketplace.discover.intro")}</p>
</div>
${this._sections(this.marketplace.repositories).map(
(section) =>
html`<section class=${section.name}>
<div class="section-header">
<h2>
<h3>
${localize(`ui.panel.marketplace.discover.${section.name}`)}
</h2>
</h3>
<a class="see-all" href=${section.seeAll}>
${localize("ui.panel.marketplace.discover.see_all")}
</a>
@@ -184,10 +184,14 @@ export class HaMarketplaceDiscover extends LitElement {
padding: var(--ha-space-8) var(--ha-space-4);
}
h1 {
.intro h2 {
margin: 0;
font-family: var(--ha-font-family-heading);
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
.intro p {
@@ -202,10 +206,13 @@ export class HaMarketplaceDiscover extends LitElement {
margin-block-end: var(--ha-space-3);
}
h2 {
h3 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.see-all {
@@ -339,7 +339,6 @@ export class HaMarketplaceDashboard extends LitElement {
if (this.tab === "discover") {
return html`<hass-tabs-subpage
.tabs=${tabs}
.localizeFunc=${this._i18n.localize}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config"
@@ -373,7 +372,6 @@ export class HaMarketplaceDashboard extends LitElement {
{ number: repositories.length }
)}
.isWide=${this.isWide}
.localizeFunc=${this._i18n.localize}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config"
@@ -424,9 +422,9 @@ export class HaMarketplaceDashboard extends LitElement {
? nothing
: html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiStore}></ha-svg-icon>
<h1>
<h2>
${this._i18n.localize("ui.panel.marketplace.dashboard.empty_header")}
</h1>
</h2>
<p>
${this._i18n.localize("ui.panel.marketplace.dashboard.empty_text")}
</p>
@@ -1100,8 +1098,14 @@ export class HaMarketplaceDashboard extends LitElement {
.empty ha-button {
--mdc-icon-size: 24px;
}
.empty h1 {
.empty h2 {
margin-block: var(--ha-space-5);
font-family: var(--ha-font-family-heading);
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
}
`,
];
+11 -3
View File
@@ -78,11 +78,19 @@ const lorawanDevicesParams = {
connection: "string?",
} satisfies Redirect["params"];
// The tags table reads these from the link
const tagsTableParams = {
// The devices table reads these from the link
const devicesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
status: "string?",
floor: "string?",
area: "string?",
domain: "string?",
label: "string?",
config_entry: "string?",
sub_entry: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
@@ -285,6 +293,7 @@ export const getMyRedirects = (): Redirects => ({
},
devices: {
redirect: "/config/devices/dashboard",
params: devicesTableParams,
},
entities: {
redirect: "/config/entities",
@@ -337,7 +346,6 @@ export const getMyRedirects = (): Redirects => ({
tags: {
component: "tag",
redirect: "/config/tags",
params: tagsTableParams,
},
voice_assistants: {
redirect: "/config/voice-assistants",
@@ -9,6 +9,7 @@ import {
type Mock,
} from "vitest";
import { QueryParamsController } from "../../../src/common/controllers/query-params-controller";
import { fireEvent } from "../../../src/common/dom/fire_event";
const config = { string: ["search", "sort"] } as const;
@@ -98,6 +99,15 @@ describe("QueryParamsController", () => {
expect(currentUrl()).toBe("/page?other=1");
});
it("does nothing once disconnected, also on the same page", () => {
// A page left while a lookup runs, with a new page on the same path
controller.hostDisconnected();
setUrl("/page?search=new");
controller.write({ search: "old" });
expect(currentUrl()).toBe("/page?search=new");
expect(controller.searchParams()).toBeUndefined();
});
it("does nothing once the URL is another page", () => {
setUrl("/other-page?other=1");
window.dispatchEvent(new Event("location-changed"));
@@ -124,4 +134,30 @@ describe("QueryParamsController", () => {
controller.hostConnected();
expect(navigated).toHaveBeenCalledOnce();
});
it("stays on its page when reconnected on the way to another page", () => {
controller.hostUpdated();
controller.hostDisconnected();
setUrl("/other-page");
controller.hostConnected();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/other-page");
expect(navigated).not.toHaveBeenCalled();
});
it("follows a router sending its path on to the default page", () => {
setUrl("/page/dashboard?other=1");
fireEvent(window, "location-changed", { replace: true });
expect(navigated).toHaveBeenCalledOnce();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/dashboard?other=1&search=abc");
});
it("does not follow a new entry for a page below its own", () => {
setUrl("/page/detail");
fireEvent(window, "location-changed", { replace: false });
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/detail");
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../src/common/url/devices-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
describe("devices query params", () => {
it("reads nothing without table params", () => {
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-states": { value: [], items: undefined },
"ha-filter-floor-areas": { value: undefined, items: undefined },
"ha-filter-integrations": { value: [], items: undefined },
"ha-filter-labels": { value: ["a", "b"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: [], items: undefined },
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-states": { value: ["disabled"], items: undefined },
"ha-filter-floor-areas": {
value: { floors: ["ground"], areas: ["kitchen"] },
items: undefined,
},
"ha-filter-integrations": { value: ["hue"], items: undefined },
"ha-filter-labels": { value: ["a"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: ["def"], items: undefined },
};
expect(
devicesLinkFromUrl(
createQueryString(
devicesFilterQueryParams(filters),
devicesFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("writes the values, not the items the filters found", () => {
expect(
devicesFilterQueryParams({
"ha-filter-floor-areas": {
value: { areas: ["kitchen"] },
items: new Set(["device_1"]),
},
})
).toEqual({
status: undefined,
floor: undefined,
area: ["kitchen"],
domain: undefined,
label: undefined,
config_entry: undefined,
sub_entry: undefined,
});
});
});
-31
View File
@@ -1,31 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-tab";
import type { HaTab } from "../../src/components/ha-tab";
vi.mock("../../src/components/ha-ripple", () => ({}));
const openTab = async (badge?: string) => {
const tab = document.createElement("ha-tab") as HaTab;
tab.name = "Installed";
tab.badge = badge;
document.body.append(tab);
await tab.updateComplete;
return tab.shadowRoot!.querySelector('[role="tab"]')!;
};
describe("ha-tab", () => {
afterEach(() => document.body.replaceChildren());
it("tells a screen reader about its badge too", async () => {
const tab = await openTab("3 updates");
const describedBy = tab.getAttribute("aria-describedby")!;
expect(tab.querySelector(`#${describedBy}`)!.textContent).toBe("3 updates");
});
it("describes nothing without a badge", async () => {
const tab = await openTab();
expect(tab.hasAttribute("aria-describedby")).toBe(false);
});
});
+23
View File
@@ -572,6 +572,29 @@ describe("TraceTree branch completion", () => {
).toBe(true);
});
it.each([{ result: null }, { enabled: false }])(
"passes through a disabled condition: %j",
(result) => {
const tree = new TraceTree(
createTrace(
[
{
condition: "template",
value_template: "{{ false }}",
enabled: false,
},
],
[{ path: "sequence/0", timestamp, result }]
)
);
expect(tree.sequence[0].condition).toEqual({
executed: true,
passed: true,
failed: false,
});
}
);
it.each<Action>([
{ action: "light.turn_on", enabled: false },
{ stop: "Done", enabled: false },
@@ -297,6 +297,7 @@ it("browses the way the link says", async () => {
it("browses the way the link says, also when the tab was open before", async () => {
// The router keeps the page of a tab, and hangs it back in when it is opened
window.history.replaceState(null, "", "/marketplace/browse");
const dashboard = await openDashboard();
dashboard.remove();
window.history.replaceState(null, "", "/marketplace/browse?type=theme");