Compare commits

...
Author SHA1 Message Date
Paul Bottein c37fa57e12 Fix weather forecast tabs in the more info dialog 2026-08-27 16:06:53 +02:00
8 changed files with 58 additions and 167 deletions
+1 -10
View File
@@ -10,18 +10,15 @@ const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
search: SearchParamsSource
): MoreInfoUrlData => {
const params =
typeof search === "string"
@@ -35,9 +32,6 @@ export const decodeMoreInfoUrl = (
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
@@ -48,9 +42,6 @@ export const createMoreInfoUrl = (
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -33,7 +33,6 @@ import type {
WeatherEntity,
} from "../../../data/weather";
import {
getDefaultForecastType,
getForecast,
getSecondaryWeatherAttribute,
getSupportedForecastTypes,
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -82,7 +76,9 @@ class MoreInfoWeather extends LitElement {
@state() private _forecastType?: ModernForecastType;
@state() private _subscribed?: Promise<() => void>;
private _subscribed?: Promise<() => void>;
private _subscribedTo?: string;
private _dragScrollController = new DragScrollController(this, {
selector: ".forecast",
@@ -94,24 +90,30 @@ class MoreInfoWeather extends LitElement {
this._subscribed.then((unsub) => unsub());
this._subscribed = undefined;
}
this._subscribedTo = undefined;
this._forecastEvent = undefined;
}
private async _subscribeForecastEvents() {
this._unsubscribeForecastEvents();
if (
!this.isConnected ||
!this._connection ||
!this.stateObj ||
!this._forecastType
) {
private _updateForecastSubscription() {
const stateObj = this.stateObj;
const forecastType = this._forecastType;
if (!this.isConnected || !this._connection || !stateObj || !forecastType) {
this._unsubscribeForecastEvents();
return;
}
const target = `${stateObj.entity_id}-${forecastType}`;
if (target === this._subscribedTo) {
return;
}
this._unsubscribeForecastEvents();
this._subscribedTo = target;
this._subscribed = subscribeForecast(
this._connection.connection,
this.stateObj.entity_id,
this._forecastType,
stateObj.entity_id,
forecastType,
(event) => {
this._forecastEvent = event;
}
@@ -121,7 +123,7 @@ class MoreInfoWeather extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecastEvents();
this._updateForecastSubscription();
}
}
@@ -133,37 +135,9 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
}
this._forecastType = this._selectedForecastType();
this._updateForecastSubscription();
}
protected updated(_changedProps: PropertyValues<this>): void {
@@ -184,6 +158,16 @@ class MoreInfoWeather extends LitElement {
getSupportedForecastTypes(stateObj)
);
private _selectedForecastType(): ModernForecastType | undefined {
if (!this.stateObj) {
return undefined;
}
const supported = this._supportedForecasts(this.stateObj);
return (
supported.find((type) => type === this._forecastType) ?? supported[0]
);
}
private _groupForecastByDay = memoizeOne((forecast: ForecastAttribute[]) => {
if (!forecast) return [];
@@ -536,14 +520,7 @@ class MoreInfoWeather extends LitElement {
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+2 -36
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -92,7 +91,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -110,7 +108,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
@@ -158,10 +155,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@@ -198,7 +191,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
@@ -252,7 +244,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
@@ -301,10 +292,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -323,29 +311,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
if (this._childView) {
const dialog =
@@ -371,7 +340,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
@@ -1078,15 +1046,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
+1 -5
View File
@@ -318,10 +318,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
const { entityId, view } = decodeMoreInfoUrl(window.location.search);
if (!entityId) {
return;
}
@@ -331,7 +328,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
-2
View File
@@ -55,7 +55,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)
: false),
data: ev.detail.data,
hash: ev.detail.hash,
returnUrl,
},
() => import("../dialogs/more-info/ha-more-info-dialog"),
@@ -66,7 +65,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
createMoreInfoUrl(returnUrl, {
entityId: ev.detail.entityId,
view,
hash: ev.detail.hash,
})
);
}
+7 -22
View File
@@ -247,15 +247,13 @@ describe("todo query params", () => {
});
describe("more-info query params", () => {
it("decodes the entity, view, and named hash params", () => {
it("decodes the entity and view", () => {
const params = decodeMoreInfoUrl(
"?more-info-entity-id=weather.home&more-info-view=info",
"#forecast=hourly"
"?more-info-entity-id=weather.home&more-info-view=info"
);
expect(params.entityId).toBe("weather.home");
expect(params.view).toBe("info");
expect(params.hash.get("forecast")).toBe("hourly");
});
it("ignores invalid views", () => {
@@ -271,22 +269,21 @@ describe("more-info query params", () => {
createMoreInfoUrl("/lovelace/home?theme=dark", {
entityId: "weather.home",
view: "info",
hash: new URLSearchParams({ forecast: "hourly" }),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info"
);
});
it("removes more-info query params but preserves other hash state", () => {
it("removes more-info query params but preserves the hash", () => {
expect(
removeMoreInfoUrl(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#some-anchor"
)
).toBe("/lovelace/home?theme=dark#forecast=hourly");
).toBe("/lovelace/home?theme=dark#some-anchor");
});
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
it("preserves the hash of the page it links from", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
@@ -296,16 +293,4 @@ describe("more-info query params", () => {
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
);
});
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
view: "info",
hash: new URLSearchParams(),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
);
});
});
+14 -27
View File
@@ -389,8 +389,10 @@ test.describe("Light more-info dialog", () => {
}
});
test.describe("Weather more-info deep link", () => {
test("opens and synchronizes the selected forecast", async ({ page }) => {
test.describe("Weather more-info forecast", () => {
test("switches the rendered forecast when a tab is selected", async ({
page,
}) => {
await goToPanel(
page,
"/?scenario=weather-more-info&more-info-entity-id=weather.test_weather&more-info-view=info#/lovelace"
@@ -406,40 +408,25 @@ test.describe("Weather more-info deep link", () => {
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await page.locator("ha-test").evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: {
entityId: "weather.test_weather",
hash: new URLSearchParams({ forecast: "hourly" }),
},
bubbles: true,
composed: true,
})
);
});
// Only the hourly and twice daily forecasts group their items under a day
// header, so it tells the rendered forecast apart from the daily one.
await expect(weather.locator(".forecast-day-header")).toHaveCount(0);
await weather
.locator("ha-tab-group-tab")
.filter({ hasText: "Hourly" })
.click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Hourly" })
).toBeAttached();
await expect(weather.locator(".forecast-day-header").first()).toBeVisible();
await dialog.getByRole("button", { name: "History" }).click();
await expect(page).toHaveURL(/more-info-view=history/);
await dialog.getByRole("button", { name: "Back" }).click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await weather
.locator("ha-tab-group-tab")
.filter({ hasText: "Daily" })
.click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await expect(page).toHaveURL(/more-info-view=info/);
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();