mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-05 20:44:32 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+394
-394
File diff suppressed because it is too large.
Load diff
+13
-2
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user