Compare commits

...
Author SHA1 Message Date
Christian Wiedel 54cefeb816 Fix wrapped flow dialog titles overlapping the subtitle (#55661) 2026-11-05 21:09:50 +01:00
renovate[bot] 4458205273 Update vitest monorepo to v5.1.0 (#55680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 19:28:39 +01:00
Paul Bottein 0c2de46f11 Fix truncated buttons in the voice assistant setup (#55677) 2026-11-05 18:36:57 +01:00
Aidan Timson 6bef900455 Fix the max-age of the demo and cast cache headers (#55676) 2026-11-05 18:36:36 +01:00
Aidan Timson 438bfa2710 Let embed entities set their entity registry options (#55675) 2026-11-05 17:26:47 +01:00
puddly 96864e951a Allow the ESPHome serial proxy URL to be null (#55665)
* Allow the ESPHome serial proxy URL to be None

* Clarify comment
2026-11-05 18:08:22 +02:00
renovate[bot] a1355e5116 Update babel monorepo to v8.1.0 (#55667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 17:19:21 +02:00
Petar Petrov ae4366020c Choose the area when creating a helper without a device (#55646)
* Choose the area of a helper that has no device

Storage helpers get an Area field in their create form. Config flow
helpers get an area picker on the success step when any of their
entities has no device; the area is set on all of them. The redirect
after creating then uses the entity ID as regenerated for the area.

* Count the area as an unsaved change in the helper dialog

* Disable the helper area picker while saving
2026-11-05 15:03:47 +00:00
renovate[bot] 7f73d29b2f Update dependency @versatiles/style to v8 (#55668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 15:46:36 +01:00
Petar Petrov ebf453ccc7 Show the new helper after creating it (#55628)
* Open the new helper after creating it

From the helpers page, a new helper now opens its device page when its
entities belong to one device, or its more-info dialog otherwise.

* Open the right entity after creating a helper

Look up the storage helper's entity by its unique ID, because its entity
ID can differ from the helper ID. Start the config entry lookup when the
success step shows and skip the redirect if the dialog closed meanwhile.

* Ignore the helper lookup after the dialog closes or reopens

* Ignore a helper created after its dialog closed or reopened
2026-11-05 15:23:30 +01:00
TimothyandClaude Opus 5.5 a0d9afe695 Keep dialogs while navigating (#55627)
* Keep dialogs while navigating

* Apply suggestions

* Keep navigate async when keeping dialogs

Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 15:11:49 +01:00
23 changed files with 822 additions and 470 deletions

No files matched your search

+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age: 3600, s-maxage=3600
Cache-Control: public, max-age=3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
+5 -5
View File
@@ -1,21 +1,21 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
+5 -1
View File
@@ -168,7 +168,8 @@ export class HaEmbedProvider extends LitElement {
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, without settings such as calendar colors
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
@@ -179,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
+5 -5
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.10",
"@babel/runtime": "8.1.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -80,7 +80,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.1.0",
"@versatiles/style": "8.0.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -137,7 +137,7 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.10",
"@babel/core": "8.1.0",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
@@ -170,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.1.0",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.5",
@@ -217,7 +217,7 @@
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
+394 -394
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -16,6 +16,13 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -196,7 +203,8 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
if (!canProceed) {
return false;
}
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace) {
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
+29
View File
@@ -390,3 +390,32 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
/**
* Set the area of entities and regenerate their entity IDs, which can include
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
hass: HomeAssistant,
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(hass, entityId, {
new_entity_id: newEntityId,
});
}
result[entityId] = newEntityId || entityId;
}
return result;
};
+2 -1
View File
@@ -19,7 +19,8 @@ export interface ESPHomeZWaveProxyCapabilities {
export interface ESPHomeSerialProxy {
name: string;
port_type: ESPHomeSerialPortType | null;
url: string;
// `null` when no device is present (e.g. unpopulated USB port)
url: string | null;
}
export interface ESPHomeDeviceCapabilities {
@@ -481,6 +481,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.manifest=${this._params.manifest}
.navigateToResult=${
this._params.navigateToResult ?? false
}
@@ -838,6 +839,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
return [
haStyleDialog,
css`
ha-dialog {
--ha-dialog-header-title-height: auto;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
+162 -17
View File
@@ -20,13 +20,18 @@ import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
getAutomaticEntityIds,
getExtendedEntityRegistryEntries,
updateEntitiesArea,
updateEntityRegistryEntry,
type EntityRegistryDisplayEntry,
type ExtEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { domainToName } from "../../data/integration";
import { domainToName, type IntegrationManifest } from "../../data/integration";
import { findRelated } from "../../data/search";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showMoreInfoDialog } from "../more-info/show-ha-more-info-dialog";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -35,6 +40,28 @@ interface DeviceTarget {
device: string;
}
interface HelperResult {
deviceId?: string;
entityId?: string;
}
const computeHelperResult = (
entities: ExtEntityRegistryEntry[]
): HelperResult | undefined => {
const deviceIds = [
...new Set(
entities.flatMap((entity) => (entity.device_id ? [entity.device_id] : []))
),
];
if (deviceIds.length === 1) {
return { deviceId: deviceIds[0] };
}
if (!deviceIds.length && entities.length === 1) {
return { entityId: entities[0].entity_id };
}
return undefined;
};
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -46,10 +73,18 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@property({ attribute: false }) public manifest?: IntegrationManifest | null;
private _domains: Record<string, string> = {};
public navigateToResult = false;
private _helperEntitiesLookup?: Promise<ExtEntityRegistryEntry[]>;
@state() private _helperEntities?: ExtEntityRegistryEntry[];
@state() private _helperArea?: string;
@state() private _deviceUpdate: Record<
string,
{ name?: string; area?: string }
@@ -73,6 +108,18 @@ class StepFlowCreateEntry extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadDomains();
if (
this.flowConfig.flowType === "config_flow" &&
this.step.result &&
this.manifest?.integration_type === "helper"
) {
this._helperEntitiesLookup = this._findHelperEntities(
this.step.result.entry_id
);
this._helperEntitiesLookup.then((entities) => {
this._helperEntities = entities;
});
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -133,11 +180,23 @@ class StepFlowCreateEntry extends LitElement {
? nothing
: this.devices.length === 0
? html`<p>
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: this.step.title }
)}
</p>`
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: html`<b>${this.step.title}</b>` }
)}
</p>
${
this._entitiesWithoutDevice(this._helperEntities).length
? html`<ha-area-picker
.value=${this._helperArea}
.disabled=${this._loading}
.helper=${localize(
"ui.panel.config.integrations.config_flow.helper_area"
)}
@value-changed=${this._helperAreaPicked}
></ha-area-picker>`
: nothing
}`
: html`
<div class="devices">
${this.devices.map(
@@ -214,9 +273,11 @@ class StepFlowCreateEntry extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
if (changedProps.has("_deviceUpdate") || changedProps.has("_helperArea")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
hasPendingUpdates:
Object.keys(this._deviceUpdate).length > 0 ||
this._helperArea !== undefined,
});
}
if (changedProps.has("_loading")) {
@@ -226,6 +287,11 @@ class StepFlowCreateEntry extends LitElement {
}
}
private _entitiesWithoutDevice = memoizeOne(
(entities?: ExtEntityRegistryEntry[]): ExtEntityRegistryEntry[] =>
(entities ?? []).filter((entity) => !entity.device_id)
);
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -290,16 +356,95 @@ class StepFlowCreateEntry extends LitElement {
await Promise.allSettled(entityUpdates);
}
fireEvent(this, "flow-update", { step: undefined });
if (this.step.result && this.navigateToResult) {
if (this.devices.length === 1) {
navigate(`/config/devices/device/${this.devices[0].id}`);
} else {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
}
// Helpers link their entities to an existing device instead of creating one
let helperEntities =
this._helperEntitiesLookup && !this.devices.length
? await this._helperEntitiesLookup
: [];
if (this._helperArea) {
helperEntities = await this._saveHelperArea(
helperEntities,
this._helperArea
);
}
const entry = this.navigateToResult ? this.step.result : undefined;
const helperResult = entry
? computeHelperResult(helperEntities)
: undefined;
// The dialog was closed while waiting
if (!this.isConnected) {
return;
}
fireEvent(this, "flow-update", { step: undefined });
if (!entry) {
return;
}
const deviceId =
this.devices.length === 1 ? this.devices[0].id : helperResult?.deviceId;
if (deviceId) {
navigate(`/config/devices/device/${deviceId}`);
} else if (helperResult?.entityId) {
showMoreInfoDialog(this, { entityId: helperResult.entityId });
} else {
navigate(
`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`
);
}
}
private async _findHelperEntities(
entryId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const { entity: entityIds = [] } = await findRelated(
this.hass,
"config_entry",
entryId
);
if (!entityIds.length) {
return [];
}
return Object.values(
await getExtendedEntityRegistryEntries(this.hass, entityIds)
).filter(
(entity): entity is ExtEntityRegistryEntry =>
entity?.config_entry_id === entryId
);
} catch (_err) {
return [];
}
}
private async _saveHelperArea(
entities: ExtEntityRegistryEntry[],
areaId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
return entities.map((entity) =>
entity.entity_id in newEntityIds
? { ...entity, entity_id: newEntityIds[entity.entity_id] }
: entity
);
} catch (err: unknown) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error: (err as { message?: string })?.message ?? "Unknown error" }
),
});
return entities;
}
}
private _helperAreaPicked(ev: ValueChangedEvent<string | undefined>): void {
this._helperArea = ev.detail.value || undefined;
}
public async finish(): Promise<void> {
@@ -690,7 +690,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
* is always in the scan, so a missing or absent port means it is offline.
* Without the usb integration nothing is scanned, so a missing port says nothing.
*/
private _serialPortUsage(url: string) {
private _serialPortUsage(url: string | null) {
if (!this._serialPorts) {
return undefined;
}
@@ -375,21 +375,16 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
margin-top: 24px;
}
.rows {
gap: var(--ha-space-4);
display: flex;
flex-direction: column;
}
.row {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ha-space-4) var(--ha-space-1);
align-items: center;
}
.row > *:first-child {
flex: 1;
margin-right: 4px;
.row {
display: contents;
}
.row ha-button {
width: 82px;
.row > :only-child {
grid-column: 1 / -1;
}
ha-select {
+6
View File
@@ -2,6 +2,7 @@ import type {
HassEntity,
HassEntityAttributeBase,
} from "home-assistant-js-websocket";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/** Base attributes type that all entities share */
export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
@@ -17,6 +18,11 @@ export type EntityInput = Pick<
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
/**
* Options of the entity in the mocked entity registry, such as timer
* presets or favorite positions
*/
options?: EntityRegistryEntry["options"];
/** When the state last changed, a random recent time by default */
last_changed?: string;
};
+2
View File
@@ -104,6 +104,7 @@ export class HassRouterPage extends ReactiveElement {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
keepDialogs: true,
});
}
@@ -134,6 +135,7 @@ export class HassRouterPage extends ReactiveElement {
if (route) {
navigate(`${route.prefix}/${result}${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
const { pathname, search, hash } = mainWindow.location;
navigate(
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
{ replace: true }
{ replace: true, keepDialogs: true }
);
return undefined;
}
+110 -12
View File
@@ -13,6 +13,7 @@ import type {
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
@@ -28,6 +29,12 @@ import type { HaListSelectable } from "../../../components/list/ha-list-selectab
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import {
fetchEntityRegistry,
getExtendedEntityRegistryEntry,
updateEntitiesArea,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { createInputBoolean } from "../../../data/input_boolean";
import { createInputButton } from "../../../data/input_button";
import { createInputDateTime } from "../../../data/input_datetime";
@@ -43,8 +50,9 @@ import { createSchedule } from "../../../data/schedule";
import { createTimer } from "../../../data/timer";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { Helper, HelperDomain, HelperFormItem } from "./const";
import { isHelperDomain } from "./const";
@@ -110,12 +118,14 @@ const HELPERS: HelperCreators = {
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends DirtyStateProviderMixin<
HelperFormItem | undefined
{ item?: HelperFormItem; area?: string } | undefined
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: HelperFormItem;
@state() private _area?: string;
@state() private _open = false;
@state() private _domain?: string;
@@ -137,6 +147,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: string;
manifest: IntegrationManifest;
dialogClosedCallback?: ShowDialogHelperDetailParams["dialogClosedCallback"];
navigateToResult?: boolean;
};
private _params?: ShowDialogHelperDetailParams;
@@ -145,11 +156,15 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._params = params;
this._domain = params.domain;
this._item = params.item;
this._area = undefined;
if (this._domain && this._domain in HELPERS) {
this._loading = true;
try {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
} finally {
this._loading = false;
}
@@ -185,6 +200,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: pendingConfigFlow.startFlowHandler,
manifest: pendingConfigFlow.manifest,
dialogClosedCallback: pendingConfigFlow.dialogClosedCallback,
navigateToResult: pendingConfigFlow.navigateToResult,
});
}
}
@@ -206,6 +222,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
new: true,
autofocus: true,
})}
<ha-area-picker
.value=${this._area}
.disabled=${this._submitting}
@value-changed=${this._areaChanged}
></ha-area-picker>
</div>
`;
footer = html`
@@ -380,28 +401,45 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState(this._item);
this._updateDirtyState({ item: this._item, area: this._area });
}
private async _createItem(): Promise<void> {
if (!this._domain || !this._item) {
return;
}
const domain = this._domain;
const params = this._params;
const area = this._area;
this._submitting = true;
this._error = "";
try {
const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
// The dialog was closed or reopened while waiting
if (this._params !== params) {
return;
}
let entityId: string | undefined;
if (createdEntity.id && (area || params?.navigateToResult)) {
entityId = await this._findEntityId(domain, createdEntity.id);
if (area) {
entityId = await this._saveArea(entityId, area);
}
if (this._params !== params) {
return;
}
}
if (params?.dialogClosedCallback && createdEntity.id) {
params.dialogClosedCallback({
flowFinished: true,
entityId: `${this._domain}.${createdEntity.id}`,
entityId: entityId ?? `${domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
if (params?.navigateToResult && entityId) {
showMoreInfoDialog(this, { entityId });
}
} catch (err: any) {
this._error = err.message || "Unknown error";
} finally {
@@ -409,6 +447,57 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
}
private async _findEntityId(
domain: string,
uniqueId: string
): Promise<string | undefined> {
const isCreated = (entry: EntityRegistryEntry) =>
entry.platform === domain && entry.unique_id === uniqueId;
try {
// The entity ID usually matches the helper ID, unless it was taken
const entry = await getExtendedEntityRegistryEntry(
this.hass,
`${domain}.${uniqueId}`
).catch(() => undefined);
if (entry && isCreated(entry)) {
return entry.entity_id;
}
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
?.entity_id;
} catch (_err) {
return undefined;
}
}
private async _saveArea(
entityId: string | undefined,
areaId: string
): Promise<string | undefined> {
let error = "Entity not found";
if (entityId) {
try {
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
entityId
];
} catch (err: unknown) {
error = (err as { message?: string })?.message ?? "Unknown error";
}
}
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error }
),
});
return entityId;
}
private _areaChanged(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this._area = ev.detail.value || undefined;
this._updateDirtyState({ item: this._item, area: this._area });
}
private async _domainPicked(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
): Promise<void> {
@@ -435,7 +524,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking({ type: "deep" }, undefined);
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
} finally {
this._loading = false;
}
@@ -444,6 +536,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
dialogClosedCallback: this._params?.dialogClosedCallback,
navigateToResult: this._params?.navigateToResult,
};
this.closeDialog();
}
@@ -452,6 +545,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private async _goBack() {
this._domain = undefined;
this._item = undefined;
this._area = undefined;
this._error = undefined;
await this.updateComplete;
await this._focusSearchInput();
@@ -490,6 +584,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
.form {
padding: var(--ha-space-6);
}
.form ha-area-picker {
display: block;
margin-top: var(--ha-space-4);
}
ha-input-search {
margin: 0 var(--ha-space-4) var(--ha-space-3);
}
@@ -1300,6 +1300,7 @@ ${rejected
if (isHelperDomain(domain)) {
showHelperDetailDialog(this, {
domain,
navigateToResult: true,
});
return;
}
@@ -1343,6 +1344,7 @@ ${rejected
showConfigFlowDialog(this, {
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
navigateToResult: true,
});
}
@@ -1568,11 +1570,12 @@ ${rejected
{ name: config.name }
),
},
navigateToResult: true,
});
}
private _createHelper() {
showHelperDetailDialog(this, {});
showHelperDetailDialog(this, { navigateToResult: true });
}
private _bulkCreateCategory = () => {
@@ -11,6 +11,7 @@ export interface ShowDialogHelperDetailParams {
entryId?: string;
entityId?: string;
}) => void;
navigateToResult?: boolean;
}
export const showHelperDetailDialog = (
+8 -2
View File
@@ -291,6 +291,12 @@ export class LovelacePanel extends LitElement {
this._fetchConfig(true);
}
private _redirectToHome() {
// Keeps the query and any open dialog: a more-info deep link may be
// restored from the URL around this redirect.
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
}
private async _fetchConfig(forceDiskRefresh: boolean) {
this._loading = true;
@@ -300,7 +306,7 @@ export class LovelacePanel extends LitElement {
// If no mode, redirect to /home as there is no "lovelace" dashboard
if (!confMode) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
@@ -361,7 +367,7 @@ export class LovelacePanel extends LitElement {
const dashboards = await fetchDashboards(this.hass!);
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
if (!dashboard) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
}
+13 -8
View File
@@ -32,6 +32,7 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
import type { NavigateOptions } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -831,7 +832,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (!viewPath && views.length) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(views[newSelectView].path || newSelectView, true);
// Filling in the default view keeps the entry as is: a dialog restored
// from the URL, like a more-info deep link, can already be open.
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
keepDialogs: true,
});
} else if (viewPath === "hass-unused-entities") {
newSelectView = "hass-unused-entities";
} else if (viewPath) {
@@ -865,10 +871,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
viewPath === "hass-unused-entities"
) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(
views[newSelectView].path || newSelectView,
true
);
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
});
}
}
@@ -1197,7 +1202,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
}
private _navigateToView(path: string | number, replace?: boolean) {
private _navigateToView(path: string | number, options?: NavigateOptions) {
if (this.embedded) {
fireEvent(this, "view-navigate", { path: String(path) });
@@ -1208,7 +1213,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
navigate(url, { replace });
navigate(url, options);
}
}
@@ -1278,7 +1283,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
newCurView = indexAfterMove(curView, oldIndex, newIndex);
// Views without a path are addressed by their index in the URL
if (newCurView !== curView && !this.config.views[curView].path) {
this._navigateToView(newCurView, true);
this._navigateToView(newCurView, { replace: true });
}
}
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
+1
View File
@@ -7974,6 +7974,7 @@
"error_saving_device": "Error updating device: {error}",
"error_saving_entity": "Error updating entity: {error}",
"created_config": "Created configuration for {name}.",
"helper_area": "This helper isn't part of a device, so you can add it to an area.",
"external_step": {
"description": "This step requires you to visit an external website to be completed.",
"open_site": "Open website"
+43 -2
View File
@@ -12,10 +12,15 @@ import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
// navigate() closes open dialogs before touching history.
// navigate() closes open dialogs before touching history. A closed dialog
// drops its state from the entry, which is what navigate() waits for.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
closeAllDialogs: vi.fn(async () => {
window.history.replaceState(null, "");
return true;
}),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
@@ -56,6 +61,7 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
vi.mocked(closeAllDialogs).mockClear();
});
describe("navigate", () => {
@@ -97,6 +103,41 @@ describe("navigate", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
it("closes an open dialog before replacing the entry", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog" });
await navigate("/home/overview", { replace: true });
expect(closeAllDialogs).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toBeNull();
});
it("keeps an open dialog and the entry state when asked to", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
await navigate("/home/overview", { replace: true, keepDialogs: true });
expect(closeAllDialogs).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toEqual({
dialog: "ha-more-info-dialog",
from: "/",
});
});
it("does not drop a navigation right after one that keeps dialogs", async () => {
setEntry("/home");
const results = await Promise.all([
navigate("/home/overview", { replace: true, keepDialogs: true }),
navigate("/config"),
]);
expect(results).toEqual([true, true]);
expect(window.location.pathname).toEqual("/config");
});
});
describe("unsaved changes guard", () => {
+1 -1
View File
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
// The Marketplace loads its translations for the path it is at
expect(navigate).toHaveBeenCalledWith(
"/marketplace/repository/1?category=theme#top",
{ replace: true }
{ replace: true, keepDialogs: true }
);
});