Compare commits

..
Author SHA1 Message Date
Wendelin f3f0dfc62b Update easing animation function 2025-02-14 11:00:08 +01:00
Wendelin d4125d433f Add switch theme animation 2025-02-13 16:00:35 +01:00
Wendelin c824e58e0a Show users theme even if browser theme is active 2025-02-13 14:46:38 +01:00
Wendelin c6617718b7 Fix tests 2025-02-13 11:43:02 +01:00
Wendelin cd1ca72e45 Fix storeState overrides user theme 2025-02-13 09:51:39 +01:00
Wendelin 4f9ca3b173 Add save theme for user functionality 2025-02-12 15:30:15 +01:00
ildar170975andGitHub 1349c8520c developer-tools-template: allow "select all" for "rendered" (2) (#24171)
removed a harmful user-select
2025-02-11 13:26:46 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6d1a55cc3a Update vaadinWebComponents monorepo to v24.6.4 (#24153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 09:48:10 +01:00
WendelinandGitHub 23a9ae6835 Onboarding restore use error code (#24172)
Use error code for incorrect password
2025-02-11 09:47:18 +01:00
WendelinandGitHub dbd1e928de Make restore button destructive (#24173) 2025-02-11 09:37:56 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e86ad21ce2 Update dependency eslint to v9.20.0 (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 09:12:21 +01:00
Bram KragtenandGitHub 0d97afb3f2 Add base support for sub entries (#23160)
* Add base support for sub entries

* add demo types

* fix translations

* Use sub entry name when deleting

* Update show-dialog-sub-config-flow.ts

* adjust for multiple sub types

* WIP, not functional

* add subentry_type

* rename to supported_subentry_types

* config_subentries -> config_entries_subentries

* Add localized sub flow title

* use Record

* rename

* more rename
2025-02-10 21:24:05 +01:00
03a415beff Onboarding restore use core api (#23920)
* Fix type issues

* Extract backup-upload

* Add onboarding upload section

* Extract and use ha-backup-details

* Implement backup details and restore

* remove unused hassio onboarding calls

* Require hass in dialog-hassio-backup

* Add restore view

* Add formatDateTime without locale and config

* Add restore status

* Fix prettier

* Fix styles of backup details

* Remove unused localize

* Fix onboarding restore translations

* Hide data-picker on core only instance

* Split uploadBackup into 2 separate funcs

* Add formatDateTimeWithBrowserDefaults

* Fix selected data for core only

* Show error reasons on status page

* Use new backup info agents

* Add mem function for formatDateTimeWithBrowserDefaults

* Fix overflow on mobile

* Handle errors when in hassio mode

* Fix cancel restore texts

* Fix hassio localize type issue

* Remove unused onboarding localize in hassio backup restore

* improve format_date_time

* Fix tests

* Fix and simplify backup upload issues

* Use foreach instead of reduce

* Fix attributes, unused styles and properties

* Simplify supervisor warning

* Fix language type issues

* Fix ha-backup-data-picker

* Improve backup-details-restore

* Fix onboarding-restore issues

* Improve loadBackupInfo

* Revert uploadBackup changes

* Improve cancel restore

* Use destructive

* Update src/panels/config/backup/dialogs/dialog-upload-backup.ts

Co-authored-by: Bram Kragten <[email protected]>

* Show backup type not at onboarding

* Only show backup type in correct translationPanel

* Fix quotes

---------

Co-authored-by: Bram Kragten <[email protected]>
2025-02-10 16:40:08 +01:00
Paulus SchoutsenandGitHub 44cc75afbc Require opt-in config flow navigateToResult (#24120) 2025-02-10 15:58:10 +01:00
Petar PetrovandGitHub 748642a8d6 Limit max label width of hui-energy-devices-graph-card (#24152) 2025-02-10 15:54:20 +01:00
Petar PetrovandGitHub 3d5c65d652 Bring back energy usage graph order (#24156) 2025-02-10 15:53:36 +01:00
Paul BotteinandGitHub a26bf80b13 Fix section border radius (#24159) 2025-02-10 15:53:05 +01:00
69 changed files with 2886 additions and 868 deletions
+2
View File
@@ -65,6 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,
@@ -85,6 +86,7 @@ export class HaDemo extends HomeAssistantAppEl {
},
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,
+1
View File
@@ -11,6 +11,7 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
+3
View File
@@ -48,6 +48,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -71,6 +72,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -94,6 +96,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -47,6 +47,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -70,6 +71,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -93,6 +95,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -32,6 +32,8 @@ const createConfigEntry = (
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
disabled_by: null,
pref_disable_new_entities: false,
pref_disable_polling: false,
@@ -188,6 +190,7 @@ const createEntityRegistryEntries = (
): EntityRegistryEntry[] => [
{
config_entry_id: item.entry_id,
config_subentry_id: null,
device_id: "mock-device-id",
area_id: null,
disabled_by: null,
@@ -214,6 +217,7 @@ const createDeviceRegistryEntries = (
{
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",
@@ -14,7 +14,7 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
declare global {
interface HASSDomEvents {
"backup-uploaded": { backup: HassioBackup };
"hassio-backup-uploaded": { backup: HassioBackup };
"backup-cleared": undefined;
}
}
@@ -70,7 +70,7 @@ export class HassioUploadBackup extends LitElement {
this._uploading = true;
try {
const backup = await uploadBackup(this.hass, file);
fireEvent(this, "backup-uploaded", { backup: backup.data });
fireEvent(this, "hassio-backup-uploaded", { backup: backup.data });
} catch (err: any) {
showAlertDialog(this, {
title: "Upload failed",
@@ -5,7 +5,6 @@ import { customElement, property, query } from "lit/decorators";
import { atLeastVersion } from "../../../src/common/config/version";
import { formatDate } from "../../../src/common/datetime/format_date";
import { formatDateTime } from "../../../src/common/datetime/format_date_time";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-textfield";
@@ -19,13 +18,10 @@ import type {
} from "../../../src/data/hassio/backup";
import type { Supervisor } from "../../../src/data/supervisor/supervisor";
import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg";
import type { HomeAssistant, TranslationDict } from "../../../src/types";
import type { HomeAssistant } from "../../../src/types";
import "./supervisor-formfield-label";
import type { HaTextField } from "../../../src/components/ha-textfield";
type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];
interface CheckboxItem {
slug: string;
checked: boolean;
@@ -67,8 +63,6 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ attribute: false }) public supervisor?: Supervisor;
@property({ attribute: false }) public backup?: HassioBackupDetail;
@@ -115,10 +109,6 @@ export class SupervisorBackupContent extends LitElement {
this._focusTarget?.focus();
}
private _localize = (key: BackupOrRestoreKey) =>
this.supervisor?.localize(`backup.${key}`) ||
this.localize!(`ui.panel.page-onboarding.restore.${key}`);
protected render() {
if (!this.onboarding && !this.supervisor) {
return nothing;
@@ -132,8 +122,8 @@ export class SupervisorBackupContent extends LitElement {
${this.backup
? html`<div class="details">
${this.backup.type === "full"
? this._localize("full_backup")
: this._localize("partial_backup")}
? this.supervisor?.localize("backup.full_backup")
: this.supervisor?.localize("backup.partial_backup")}
(${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br />
${this.hass
? formatDateTime(
@@ -145,7 +135,7 @@ export class SupervisorBackupContent extends LitElement {
</div>`
: html`<ha-textfield
name="backupName"
.label=${this._localize("name")}
.label=${this.supervisor?.localize("backup.name")}
.value=${this.backupName}
@change=${this._handleTextValueChanged}
>
@@ -153,11 +143,13 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup || this.backup.type === "full"
? html`<div class="sub-header">
${!this.backup
? this._localize("type")
: this._localize("select_type")}
? this.supervisor?.localize("backup.type")
: this.supervisor?.localize("backup.select_type")}
</div>
<div class="backup-types">
<ha-formfield .label=${this._localize("full_backup")}>
<ha-formfield
.label=${this.supervisor?.localize("backup.full_backup")}
>
<ha-radio
@change=${this._handleRadioValueChanged}
value="full"
@@ -166,7 +158,9 @@ export class SupervisorBackupContent extends LitElement {
>
</ha-radio>
</ha-formfield>
<ha-formfield .label=${this._localize("partial_backup")}>
<ha-formfield
.label=${this.supervisor?.localize("backup.partial_backup")}
>
<ha-radio
@change=${this._handleRadioValueChanged}
value="partial"
@@ -202,7 +196,7 @@ export class SupervisorBackupContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this._localize("folders")}
.label=${this.supervisor?.localize("backup.folders")}
.iconPath=${mdiFolder}
>
</supervisor-formfield-label>`}
@@ -222,7 +216,7 @@ export class SupervisorBackupContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this._localize("addons")}
.label=${this.supervisor?.localize("backup.addons")}
.iconPath=${mdiPuzzle}
>
</supervisor-formfield-label>`}
@@ -247,7 +241,7 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup
? html`<ha-formfield
class="password"
.label=${this._localize("password_protection")}
.label=${this.supervisor?.localize("backup.password_protection")}
>
<ha-checkbox
.checked=${this.backupHasPassword}
@@ -259,7 +253,7 @@ export class SupervisorBackupContent extends LitElement {
${this.backupHasPassword
? html`
<ha-password-field
.label=${this._localize("password")}
.label=${this.supervisor?.localize("backup.password")}
name="backupPassword"
.value=${this.backupPassword}
@change=${this._handleTextValueChanged}
@@ -267,7 +261,7 @@ export class SupervisorBackupContent extends LitElement {
</ha-password-field>
${!this.backup
? html`<ha-password-field
.label=${this._localize("confirm_password")}
.label=${this.supervisor?.localize("backup.confirm_password")}
name="confirmBackupPassword"
.value=${this.confirmBackupPassword}
@change=${this._handleTextValueChanged}
@@ -72,7 +72,7 @@ export class DialogHassioBackupUpload
</ha-header-bar>
</div>
<hassio-upload-backup
@backup-uploaded=${this._backupUploaded}
@hassio-backup-uploaded=${this._backupUploaded}
.hass=${this.hass}
></hassio-upload-backup>
</ha-dialog>
@@ -35,7 +35,6 @@ import { fileDownload } from "../../../../src/util/file_download";
import "../../components/supervisor-backup-content";
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
import type { BackupOrRestoreKey } from "../../util/translations";
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
@customElement("dialog-hassio-backup")
@@ -43,7 +42,7 @@ class HassioBackupDialog
extends LitElement
implements HassDialog<HassioBackupDialogParams>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string;
@@ -62,9 +61,13 @@ class HassioBackupDialog
this._dialogParams = dialogParams;
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
if (!this._backup) {
this._error = this._localize("no_backup_found");
this._error = this._dialogParams.supervisor?.localize(
"backup.no_backup_found"
);
} else if (this._dialogParams.onboarding && !this._backup.homeassistant) {
this._error = this._localize("restore_no_home_assistant");
this._error = this._dialogParams.supervisor?.localize(
"backup.restore_no_home_assistant"
);
}
this._restoringBackup = false;
}
@@ -82,13 +85,6 @@ class HassioBackupDialog
return true;
}
private _localize(key: BackupOrRestoreKey) {
return (
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
this._dialogParams!.localize!(`ui.panel.page-onboarding.restore.${key}`)
);
}
protected render() {
if (!this._dialogParams || !this._backup) {
return nothing;
@@ -102,7 +98,7 @@ class HassioBackupDialog
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this._localize("close")}
.label=${this._dialogParams.supervisor?.localize("backup.close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._restoringBackup}
@@ -150,7 +146,6 @@ class HassioBackupDialog
.supervisor=${this._dialogParams.supervisor}
.backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus
>
</supervisor-backup-content>
@@ -161,7 +156,7 @@ class HassioBackupDialog
.disabled=${this._restoringBackup || !!this._error}
@click=${this._restoreClicked}
>
${this._localize("restore")}
${this._dialogParams.supervisor?.localize("backup.restore")}
</ha-button>
</div>
</ha-md-dialog>
@@ -196,18 +191,22 @@ class HassioBackupDialog
}
if (
!(await showConfirmationDialog(this, {
title: this._localize(
this._backup!.type === "full"
? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title"
title: supervisor?.localize(
`backup.${
this._backup!.type === "full"
? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title"
}`
),
text: this._localize(
this._backup!.type === "full"
? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text"
text: supervisor?.localize(
`backup.${
this._backup!.type === "full"
? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text"
}`
),
confirmText: this._localize("restore"),
dismissText: this._localize("cancel"),
confirmText: supervisor?.localize("backup.restore"),
dismissText: supervisor?.localize("backup.cancel"),
}))
) {
this._restoringBackup = false;
@@ -227,7 +226,8 @@ class HassioBackupDialog
this.closeDialog();
} catch (error: any) {
this._error =
error?.body?.message || this._localize("restore_start_failed");
error?.body?.message ||
supervisor?.localize("backup.restore_start_failed");
} finally {
this._restoringBackup = false;
}
@@ -286,7 +286,7 @@ class HassioBackupDialog
title: supervisor.localize("backup.remote_download_title"),
text: supervisor.localize("backup.remote_download_text"),
confirmText: supervisor.localize("backup.download"),
dismissText: this._localize("cancel"),
dismissText: supervisor?.localize("backup.cancel"),
});
if (!confirm) {
return;
@@ -302,7 +302,7 @@ class HassioBackupDialog
private get _computeName() {
return this._backup
? this._backup.name || this._backup.slug
: this._localize("unnamed_backup");
: this._dialogParams!.supervisor?.localize("backup.unnamed_backup") || "";
}
static get styles(): CSSResultGroup {
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../src/common/dom/fire_event";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import type { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioBackupDialogParams {
@@ -8,7 +7,6 @@ export interface HassioBackupDialogParams {
onRestoring?: () => void;
onboarding?: boolean;
supervisor?: Supervisor;
localize?: LocalizeFunc;
}
export const showHassioBackupDialog = (
-4
View File
@@ -1,4 +0,0 @@
import type { TranslationDict } from "../../../src/types";
export type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];
+3 -3
View File
@@ -91,8 +91,8 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.3",
"@vaadin/vaadin-themable-mixin": "24.6.3",
"@vaadin/combo-box": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.9",
@@ -190,7 +190,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.19.0",
"eslint": "9.20.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10",
-1
View File
@@ -99,7 +99,6 @@ export const DOMAINS_TOGGLE = new Set([
"switch",
"group",
"automation",
"climate",
"humidifier",
"valve",
]);
+14
View File
@@ -26,6 +26,20 @@ const formatDateTimeMem = memoizeOne(
})
);
export const formatDateTimeWithBrowserDefaults = (dateObj: Date) =>
formatDateTimeWithBrowserDefaultsMem().format(dateObj);
const formatDateTimeWithBrowserDefaultsMem = memoizeOne(
() =>
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
);
// Aug 9, 2021, 8:23 AM
export const formatShortDateTimeWithYear = (
dateObj: Date,
+4 -1
View File
@@ -41,7 +41,7 @@ export class HaProgressButton extends LitElement {
indeterminate
></ha-circular-progress>
`
: ""}
: nothing}
</div>
`}
`;
@@ -117,6 +117,9 @@ export class HaProgressButton extends LitElement {
mwc-button.error slot {
visibility: hidden;
}
:host([destructive]) {
--mdc-theme-primary: var(--error-color);
}
`;
}
+24 -18
View File
@@ -11,6 +11,7 @@ import "./ha-icon-button";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import { ensureArray } from "../common/array/ensure-array";
import { bytesToString } from "../util/bytes-to-string";
import type { LocalizeFunc } from "../common/translations/localize";
declare global {
interface HASSDomEvents {
@@ -23,6 +24,8 @@ declare global {
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property() public accept!: string;
@property() public icon?: string;
@@ -31,6 +34,10 @@ export class HaFileUpload extends LitElement {
@property() public secondary?: string;
@property({ attribute: "uploading-label" }) public uploadingLabel?: string;
@property({ attribute: "delete-label" }) public deleteLabel?: string;
@property() public supports?: string;
@property({ type: Object }) public value?: File | File[] | FileList | string;
@@ -73,23 +80,22 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
return html`
${this.uploading
? html`<div class="container">
<div class="uploading">
<span class="header"
>${this.value
? this.hass?.localize(
"ui.components.file-upload.uploading_name",
{ name: this._name }
)
: this.hass?.localize(
"ui.components.file-upload.uploading"
)}</span
>${this.uploadingLabel || this.value
? localize("ui.components.file-upload.uploading_name", {
name: this._name,
})
: localize("ui.components.file-upload.uploading")}</span
>
${this.progress
? html`<div class="progress">
${this.progress}${blankBeforePercent(this.hass!.locale)}%
${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
</div>`
: nothing}
</div>
@@ -116,14 +122,11 @@ export class HaFileUpload extends LitElement {
.path=${this.icon || mdiFileUpload}
></ha-svg-icon>
<ha-button unelevated @click=${this._openFilePicker}>
${this.label ||
this.hass?.localize("ui.components.file-upload.label")}
${this.label || localize("ui.components.file-upload.label")}
</ha-button>
<span class="secondary"
>${this.secondary ||
this.hass?.localize(
"ui.components.file-upload.secondary"
)}</span
localize("ui.components.file-upload.secondary")}</span
>
<span class="supports">${this.supports}</span>`
: typeof this.value === "string"
@@ -136,8 +139,7 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") ||
"Delete"}
.label=${this.deleteLabel || localize("ui.common.delete")}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -155,8 +157,8 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") ||
"Delete"}
.label=${this.deleteLabel ||
localize("ui.common.delete")}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -238,6 +240,10 @@ export class HaFileUpload extends LitElement {
border-radius: var(--mdc-shape-small, 4px);
height: 100%;
}
.row {
display: flex;
align-items: center;
}
label.container {
border: dashed 1px
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));
+22 -15
View File
@@ -2,7 +2,6 @@ import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import checkValidDate from "../common/datetime/check_valid_date";
import {
formatDateTime,
formatDateTimeNumeric,
@@ -13,6 +12,8 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api";
export const enum BackupScheduleRecurrence {
NEVER = "never",
@@ -231,27 +232,23 @@ export const restoreBackup = (
export const uploadBackup = async (
hass: HomeAssistant,
file: File,
agent_ids: string[]
): Promise<void> => {
agentIds: string[]
): Promise<{ backup_id: string }> => {
const fd = new FormData();
fd.append("file", file);
const params = agent_ids.reduce((acc, agent_id) => {
acc.append("agent_id", agent_id);
return acc;
}, new URLSearchParams());
const params = new URLSearchParams();
const resp = await hass.fetchWithAuth(
`/api/backup/upload?${params.toString()}`,
{
agentIds.forEach((agentId) => {
params.append("agent_id", agentId);
});
return handleFetchPromise(
hass.fetchWithAuth(`/api/backup/upload?${params.toString()}`, {
method: "POST",
body: fd,
}
})
);
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
};
export const getPreferredAgentForDownload = (agents: string[]) => {
@@ -449,3 +446,13 @@ export const getFormattedBackupTime = memoizeOne(
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
}
);
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
export interface BackupUploadFileFormData {
file?: File;
}
export const INITIAL_UPLOAD_FORM_DATA: BackupUploadFileFormData = {
file: undefined,
};
+66
View File
@@ -0,0 +1,66 @@
import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupContentExtended } from "./backup";
import type {
BackupManagerState,
RestoreBackupStage,
RestoreBackupState,
} from "./backup_manager";
export interface BackupOnboardingInfo {
state: BackupManagerState;
last_non_idle_event?: {
manager_state: BackupManagerState;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
reason: string | null;
} | null;
}
export interface BackupOnboardingConfig extends BackupOnboardingInfo {
backups: BackupContentExtended[];
}
export const fetchBackupOnboardingInfo = async () =>
handleFetchPromise<BackupOnboardingConfig>(
fetch("/api/onboarding/backup/info")
);
export interface RestoreOnboardingBackupParams {
backup_id: string;
agent_id: string;
password?: string;
restore_addons?: string[];
restore_database?: boolean;
restore_folders?: string[];
}
export const restoreOnboardingBackup = async (
params: RestoreOnboardingBackupParams
) =>
handleFetchPromise(
fetch("/api/onboarding/backup/restore", {
method: "POST",
body: JSON.stringify(params),
})
);
export const uploadOnboardingBackup = async (
file: File,
agentIds: string[]
): Promise<{ backup_id: string }> => {
const fd = new FormData();
fd.append("file", file);
const params = new URLSearchParams();
agentIds.forEach((agentId) => {
params.append("agent_id", agentId);
});
return handleFetchPromise(
fetch(`/api/onboarding/backup/upload?${params.toString()}`, {
method: "POST",
body: fd,
})
);
};
+26
View File
@@ -19,6 +19,8 @@ export interface ConfigEntry {
supports_remove_device: boolean;
supports_unload: boolean;
supports_reconfigure: boolean;
supported_subentry_types: Record<string, { supports_reconfigure: boolean }>;
num_subentries: number;
pref_disable_new_entities: boolean;
pref_disable_polling: boolean;
disabled_by: "user" | null;
@@ -27,6 +29,30 @@ export interface ConfigEntry {
error_reason_translation_placeholders: Record<string, string> | null;
}
export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
hass.callWS<SubEntry[]>({
type: "config_entries/subentries/list",
entry_id,
});
export const deleteSubEntry = (
hass: HomeAssistant,
entry_id: string,
subentry_id: string
) =>
hass.callWS({
type: "config_entries/subentries/delete",
entry_id,
subentry_id,
});
export type ConfigEntryMutableParams = Partial<
Pick<
ConfigEntry,
+5 -1
View File
@@ -2,7 +2,11 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
export type FlowType = "config_flow" | "options_flow" | "repair_flow";
export type FlowType =
| "config_flow"
| "config_subentries_flow"
| "options_flow"
| "repair_flow";
export interface DataEntryFlowProgressedEvent {
type: "data_entry_flow_progressed";
+1
View File
@@ -17,6 +17,7 @@ export {
export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;
+1
View File
@@ -50,6 +50,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
icon: string | null;
platform: string;
config_entry_id: string | null;
config_subentry_id: string | null;
device_id: string | null;
area_id: string | null;
labels: string[];
+20 -40
View File
@@ -1,6 +1,5 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { handleFetchPromise } from "../../util/hass-call-api";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
@@ -82,34 +81,24 @@ export const fetchHassioBackups = async (
};
export const fetchHassioBackupInfo = async (
hass: HomeAssistant | undefined,
hass: HomeAssistant,
backup: string
): Promise<HassioBackupDetail> => {
if (hass) {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioBackupDetail>>(
"GET",
`hassio/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`
)
);
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`,
method: "get",
});
}
// When called from onboarding we don't have hass
return hassioApiResultExtractor(
await handleFetchPromise(
fetch(`/api/hassio/backups/${backup}/info`, {
method: "GET",
})
await hass.callApi<HassioResponse<HassioBackupDetail>>(
"GET",
`hassio/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`
)
);
};
@@ -240,24 +229,15 @@ export const uploadBackup = async (
};
export const restoreBackup = async (
hass: HomeAssistant | undefined,
hass: HomeAssistant,
type: HassioBackupDetail["type"],
backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useSnapshotUrl: boolean
): Promise<void> => {
if (hass) {
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails
);
} else {
await handleFetchPromise(
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
method: "POST",
body: JSON.stringify(backupDetails),
})
);
}
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails
);
};
+46
View File
@@ -0,0 +1,46 @@
import type { HomeAssistant } from "../types";
import type { DataEntryFlowStep } from "./data_entry_flow";
const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`,
};
export const createSubConfigFlow = (
hass: HomeAssistant,
configEntryId: string,
subFlowType: string,
subentry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
);
export const fetchSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi<DataEntryFlowStep>(
"GET",
`config/config_entries/subentries/flow/${flowId}`,
undefined,
HEADERS
);
export const handleSubConfigFlowStep = (
hass: HomeAssistant,
flowId: string,
data: Record<string, any>
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/subentries/flow/${flowId}`,
data,
HEADERS
);
export const deleteSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi("DELETE", `config/config_entries/subentries/flow/${flowId}`);
+1
View File
@@ -63,6 +63,7 @@ export type TranslationCategory =
| "entity_component"
| "exceptions"
| "config"
| "config_subentries"
| "config_panel"
| "options"
| "device_automation"
+19
View File
@@ -1,5 +1,11 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { HomeAssistant, ThemeSettings } from "../types";
import {
fetchFrontendUserData,
saveFrontendUserData,
subscribeFrontendUserData,
} from "./frontend";
export interface ThemeVars {
// Incomplete
@@ -50,3 +56,16 @@ export const subscribeThemes = (
conn,
onChange
);
export const SELECTED_THEME_KEY = "selectedTheme";
export const saveSelectedTheme = (hass: HomeAssistant, data?: ThemeSettings) =>
saveFrontendUserData(hass.connection, SELECTED_THEME_KEY, data);
export const subscribeSelectedTheme = (
hass: HomeAssistant,
callback: (selectedTheme?: ThemeSettings | null) => void
) => subscribeFrontendUserData(hass.connection, SELECTED_THEME_KEY, callback);
export const fetchSelectedTheme = (hass: HomeAssistant) =>
fetchFrontendUserData(hass.connection, SELECTED_THEME_KEY);
@@ -282,6 +282,8 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult}
></step-flow-create-entry>
`}
`}
@@ -77,7 +77,7 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType === "repair_flow") {
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return;
}
try {
@@ -147,7 +147,7 @@ class FlowPreviewTemplate extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType === "repair_flow") {
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return;
}
try {
@@ -16,7 +16,9 @@ export const loadConfigFlowDialog = loadDataEntryFlowDialog;
export const showConfigFlowDialog = (
element: HTMLElement,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig">
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
entryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_flow",
@@ -148,8 +148,8 @@ export interface DataEntryFlowDialogParams {
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
entryId?: string;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
}
export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow");
@@ -0,0 +1,275 @@
import { html } from "lit";
import type { ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import {
createSubConfigFlow,
deleteSubConfigFlow,
fetchSubConfigFlow,
handleSubConfigFlowStep,
} from "../../data/sub_config_flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
export const loadSubConfigFlowDialog = loadDataEntryFlowDialog;
export const showSubConfigFlowDialog = (
element: HTMLElement,
configEntry: ConfigEntry,
flowType: string,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
subEntryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_subentries_flow",
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createSubConfigFlow(hass, handler, flowType, dialogParams.subEntryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config_subentries", configEntry.domain),
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
]);
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: step.reason;
},
renderShowFormStepHeader(hass, step) {
return (
hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderShowFormStepFieldLabel(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.name`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data.${field.name}`
) || field.name
);
},
renderShowFormStepFieldHelper(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.description`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data_description.${field.name}`,
step.description_placeholders
);
return description
? html`<ha-markdown breaks .content=${description}></ha-markdown>`
: "";
},
renderShowFormStepFieldError(hass, step, error) {
return (
hass.localize(
`component.${step.translation_domain || step.translation_domain || configEntry.domain}.config_subentries.${flowType}.error.${error}`,
step.description_placeholders
) || error
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
},
renderShowFormStepSubmitButton(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.submit`
) ||
hass.localize(
`ui.panel.config.integrations.config_flow.${
step.last_step === false ? "next" : "submit"
}`
)
);
},
renderExternalStepHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) ||
hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)
);
},
renderExternalStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return html`
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.external_step.description"
)}
</p>
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
`;
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.create_entry.${
step.description || "default"
}`,
step.description_placeholders
);
return html`
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: step.title }
)}
</p>
`;
},
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormProgressDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.progress.${step.progress_action}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderMenuDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.menu_options.${option}`,
step.description_placeholders
);
},
renderLoadingDescription(hass, reason, handler, step) {
if (reason !== "loading_flow" && reason !== "loading_step") {
return "";
}
const domain = step?.handler || handler;
return hass.localize(
`ui.panel.config.integrations.config_flow.loading.${reason}`,
{
integration: domain
? domainToName(hass.localize, domain)
: // when we are continuing a config flow, we only know the ID and not the domain
hass.localize(
"ui.panel.config.integrations.config_flow.loading.fallback_title"
),
}
);
},
});
@@ -60,6 +60,7 @@ class StepFlowAbort extends LitElement {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.domain,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
});
},
});
@@ -29,6 +29,8 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
navigateToResult = false;
private _devices = memoizeOne(
(
showDevices: boolean,
@@ -153,7 +155,7 @@ class StepFlowCreateEntry extends LitElement {
private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined });
if (this.step.result) {
if (this.step.result && this.navigateToResult) {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
+6
View File
@@ -20,6 +20,12 @@
<meta name="color-scheme" content="dark light" />
<%= renderTemplate("_style_base.html.template") %>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
+1 -1
View File
@@ -17,7 +17,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
@property({ attribute: false }) public localize: LocalizeFunc = empty;
// Use browser language setup before login.
@property() public language?: string = getLocalLanguage();
@property() public language: string = getLocalLanguage();
@property() public translationFragment?: string;
+5 -9
View File
@@ -41,6 +41,7 @@ import "./onboarding-analytics";
import "./onboarding-create-user";
import "./onboarding-loading";
import "./onboarding-welcome";
import "./onboarding-restore-backup";
import "./onboarding-welcome-links";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { navigate } from "../common/navigate";
@@ -157,8 +158,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
private _renderStep() {
if (this._restoring) {
return html`<onboarding-restore-backup
.hass=${this.hass}
.localize=${this.localize}
.supervisor=${this._supervisor ?? false}
.language=${this.language}
>
</onboarding-restore-backup>`;
}
@@ -166,8 +168,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (this._init) {
return html`<onboarding-welcome
.localize=${this.localize}
.language=${this.language}
.supervisor=${this._supervisor}
></onboarding-welcome>`;
}
@@ -236,7 +236,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
}
if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language!);
document.querySelector("html")!.setAttribute("lang", this.language);
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
@@ -272,10 +272,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
"Home Assistant OS",
"Home Assistant Supervised",
].includes(response.installation_type);
if (this._supervisor) {
// Only load if we have supervisor
import("./onboarding-restore-backup");
}
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(
@@ -454,7 +450,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
subscribeOne(conn, subscribeUser),
]);
this.initializeHass(auth, conn);
if (this.language && this.language !== this.hass!.language) {
if (this.language !== this.hass!.language) {
this._updateHass({
locale: { ...this.hass!.locale, language: this.language },
language: this.language,
+294 -94
View File
@@ -1,136 +1,336 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup";
import "../../hassio/src/components/hassio-upload-backup";
import "./restore-backup/onboarding-restore-backup-upload";
import "./restore-backup/onboarding-restore-backup-details";
import "./restore-backup/onboarding-restore-backup-restore";
import "./restore-backup/onboarding-restore-backup-status";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-ansi-to-html";
import "../components/ha-card";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-circular-progress";
import "../components/ha-alert";
import "../components/ha-button";
import { fetchInstallationType } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import "./onboarding-loading";
import { onBoardingStyles } from "./styles";
import { removeSearchParam } from "../common/url/search-params";
import { navigate } from "../common/navigate";
import { onBoardingStyles } from "./styles";
import {
fetchBackupOnboardingInfo,
type BackupOnboardingConfig,
type BackupOnboardingInfo,
} from "../data/backup_onboarding";
import type { BackupContentExtended, BackupData } from "../data/backup";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { storage } from "../common/decorators/storage";
const STATUS_INTERVAL_IN_MS = 5000;
@customElement("onboarding-restore-backup")
class OnboardingRestoreBackup extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property() public language!: string;
@state() private _restoring = false;
@property({ type: Boolean }) public supervisor = false;
@state() private _backupSlug?: string;
@state() private _view:
| "loading"
| "upload"
| "select_data"
| "confirm_restore"
| "status" = "loading";
@state() private _backup?: BackupContentExtended;
@state() private _backupInfo?: BackupOnboardingInfo;
@state() private _selectedData?: BackupData;
@state() private _error?: string;
@state() private _failed?: boolean;
@storage({
key: "onboarding-restore-backup-backup-id",
})
private _backupId?: string;
@storage({
key: "onboarding-restore-running",
})
private _restoreRunning?: boolean;
protected render(): TemplateResult {
return html`
${this._restoring
? html`<h1>
${this.localize("ui.panel.page-onboarding.restore.in_progress")}
</h1>
<ha-alert alert-type="info">
${this.localize("ui.panel.page-onboarding.restore.in_progress")}
</ha-alert>
<onboarding-loading></onboarding-loading>`
: html` <h1>
${this.localize("ui.panel.page-onboarding.restore.header")}
</h1>
<hassio-upload-backup
@backup-uploaded=${this._backupUploaded}
@backup-cleared=${this._backupCleared}
.hass=${this.hass}
.localize=${this.localize}
></hassio-upload-backup>`}
<div class="footer">
<ha-button @click=${this._back} .disabled=${this._restoring}>
${this.localize("ui.panel.page-onboarding.back")}
</ha-button>
${this._backupSlug
? html`<ha-button
@click=${this._showBackupDialog}
.disabled=${this._restoring}
${
this._view !== "status" || this._failed
? html`<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>`
: nothing
}
</ha-icon-button>
<h1>${this.localize("ui.panel.page-onboarding.restore.header")}</h1>
${
this._error || (this._failed && this._view !== "status")
? html`<ha-alert
alert-type="error"
.title=${this._failed && this._view !== "status"
? this.localize("ui.panel.page-onboarding.restore.failed")
: ""}
>
${this.localize("ui.panel.page-onboarding.restore.restore")}
</ha-button>`
: nothing}
</div>
${this._failed && this._view !== "status"
? this.localize(
`ui.panel.page-onboarding.restore.${this._backupInfo?.last_non_idle_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}`
)
: this._error}
</ha-alert>`
: nothing
}
${
this._view === "loading"
? html`<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>`
: this._view === "upload"
? html`
<onboarding-restore-backup-upload
.supervisor=${this.supervisor}
.localize=${this.localize}
@backup-uploaded=${this._backupUploaded}
></onboarding-restore-backup-upload>
`
: this._view === "select_data"
? html`<onboarding-restore-backup-details
.localize=${this.localize}
.backup=${this._backup!}
@backup-restore=${this._restore}
></onboarding-restore-backup-details>`
: this._view === "confirm_restore"
? html`<onboarding-restore-backup-restore
.localize=${this.localize}
.backup=${this._backup!}
.supervisor=${this.supervisor}
.selectedData=${this._selectedData!}
@restore-started=${this._restoreStarted}
></onboarding-restore-backup-restore>`
: nothing
}
${
this._view === "status" && this._backupInfo
? html`<onboarding-restore-backup-status
.localize=${this.localize}
.backupInfo=${this._backupInfo}
@show-backup-upload=${this._reupload}
></onboarding-restore-backup-status>`
: nothing
}
${
["select_data", "confirm_restore"].includes(this._view) && this._backup
? html`<div class="backup-summary-wrapper">
<ha-backup-details-summary
translation-key-panel="page-onboarding.restore"
show-upload-another
.backup=${this._backup}
.localize=${this.localize}
@show-backup-upload=${this._reupload}
.isHassio=${this.supervisor}
></ha-backup-details-summary>
</div>`
: nothing
}
`;
}
private _back(): void {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
private _backupUploaded(ev) {
const backup = ev.detail.backup;
this._backupSlug = backup.slug;
this._showBackupDialog();
}
private _backupCleared() {
this._backupSlug = undefined;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._loadBackupInfo();
}
private async _checkRestoreStatus(): Promise<void> {
if (this._restoring) {
try {
await fetchInstallationType();
} catch (err: any) {
private async _loadBackupInfo() {
let onboardingInfo: BackupOnboardingConfig;
try {
onboardingInfo = await fetchBackupOnboardingInfo();
} catch (err: any) {
if (this._restoreRunning) {
if (
(err as Error).message === "unauthorized" ||
(err as Error).message === "not_found"
err.error === "Request error" ||
// core can restart but haven't loaded the backup integration yet
(err.status_code === 500 && err.body?.error === "backup_disabled")
) {
// core is down because of restore, keep trying
this._scheduleLoadBackupInfo();
return;
}
// core seems to be back up restored
if (err.status_code === 404) {
this._restoreRunning = undefined;
this._backupId = undefined;
window.location.replace("/");
return;
}
}
this._error = err?.message || "Cannot get backup info";
// if we are in an unknown state, show upload
if (this._view === "loading") {
this._view = "upload";
}
return;
}
const {
last_non_idle_event: lastNonIdleEvent,
state: currentState,
backups,
} = onboardingInfo;
this._backupInfo = {
state: currentState,
last_non_idle_event: lastNonIdleEvent,
};
if (this._backupId) {
this._backup = backups.find(
({ backup_id }) => backup_id === this._backupId
);
}
const failedRestore =
lastNonIdleEvent?.manager_state === "restore_backup" &&
lastNonIdleEvent?.state === "failed";
if (failedRestore) {
this._failed = true;
}
if (this._restoreRunning) {
this._view = "status";
if (failedRestore || currentState !== "restore_backup") {
this._failed = true;
this._restoreRunning = undefined;
} else {
this._scheduleLoadBackupInfo();
}
return;
}
if (
this._backup &&
// after backup was uploaded
(lastNonIdleEvent?.manager_state === "receive_backup" ||
// when restore was confirmed but failed to start (for example, encryption key was wrong)
failedRestore)
) {
if (!this.supervisor && this._backup.homeassistant_included) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this._backup.homeassistant_version,
database_included: this._backup.database_included,
};
// skip select data when supervisor is not available and backup includes HA
this._view = "confirm_restore";
} else {
this._view = "select_data";
}
return;
}
// show upload as default
this._view = "upload";
}
private _scheduleLoadBackupInfo() {
setTimeout(() => this._loadBackupInfo(), STATUS_INTERVAL_IN_MS);
}
private async _backupUploaded(ev: CustomEvent) {
this._backupId = ev.detail.backupId;
await this._loadBackupInfo();
}
private async _restoreStarted() {
if (this._backupInfo) {
this._backupInfo.state = "restore_backup";
}
this._view = "status";
this._restoreRunning = true;
await this._loadBackupInfo();
}
private async _back() {
if (this._view === "upload" || (this._view === "status" && this._failed)) {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
} else {
const confirmed = await showConfirmationDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.text"
),
confirmText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.yes"
),
dismissText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.no"
),
});
if (!confirmed) {
return;
}
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
}
private _scheduleCheckRestoreStatus(): void {
setTimeout(() => this._checkRestoreStatus(), 1000);
private _restore(ev: CustomEvent) {
if (!this._backup || !ev.detail.selectedData) {
return;
}
this._selectedData = ev.detail.selectedData;
this._view = "confirm_restore";
}
private _showBackupDialog(): void {
showHassioBackupDialog(this, {
slug: this._backupSlug!,
onboarding: true,
localize: this.localize,
onRestoring: () => {
this._restoring = true;
this._scheduleCheckRestoreStatus();
},
});
private _reupload() {
this._backup = undefined;
this._backupId = undefined;
this._view = "upload";
}
static get styles(): CSSResultGroup {
return [
onBoardingStyles,
css`
:host {
display: flex;
flex-direction: column;
align-items: center;
}
hassio-upload-backup {
width: 100%;
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
}
`,
];
}
static styles = [
onBoardingStyles,
css`
:host {
display: flex;
flex-direction: column;
position: relative;
}
ha-icon-button-arrow-prev {
position: absolute;
top: 12px;
}
ha-card {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
padding: 32px;
}
.backup-summary-wrapper {
margin-top: 24px;
padding: 0 20px;
}
`,
];
}
declare global {
+4 -8
View File
@@ -1,5 +1,5 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
@@ -13,8 +13,6 @@ class OnboardingWelcome extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Boolean }) public supervisor = false;
protected render(): TemplateResult {
return html`
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
@@ -24,11 +22,9 @@ class OnboardingWelcome extends LitElement {
${this.localize("ui.panel.page-onboarding.welcome.start")}
</ha-button>
${this.supervisor
? html`<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>`
: nothing}
<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>
`;
}
@@ -0,0 +1,57 @@
import { css, html, LitElement, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../panels/config/backup/components/ha-backup-details-restore";
import "../../panels/config/backup/components/ha-backup-details-summary";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupContentExtended } from "../../data/backup";
@customElement("onboarding-restore-backup-details")
class OnboardingRestoreBackupDetails extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
render() {
return html`
${this.backup.homeassistant_included
? html`<ha-backup-details-restore
.backup=${this.backup}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore"
ha-required
></ha-backup-details-restore>`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.home_assistant_missing"
)}
</ha-alert>
`}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
ha-backup-details-restore {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-details": OnboardingRestoreBackupDetails;
}
}
@@ -0,0 +1,175 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-alert";
import "../../components/buttons/ha-progress-button";
import "../../components/ha-password-field";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
type BackupContentExtended,
type BackupData,
} from "../../data/backup";
import { restoreOnboardingBackup } from "../../data/backup_onboarding";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
@property({ attribute: false })
public selectedData!: BackupData;
@property({ type: Boolean }) public supervisor = false;
@state() private _encryptionKey = "";
@state() private _encryptionKeyWrong = false;
@state() private _error?: string;
@state() private _loading = false;
render() {
const agentId = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
const backupProtected = this.backup.agents[agentId].protected;
return html`
${this.backup.homeassistant_included &&
!this.supervisor &&
(this.backup.addons.length > 0 || this.backup.folders.length > 0)
? html`<ha-alert alert-type="warning" class="supervisor-warning">
${this.localize(
"ui.panel.page-onboarding.restore.details.addons_unsupported"
)}
</ha-alert>`
: nothing}
<ha-card
.header=${this.localize("ui.panel.page-onboarding.restore.restore")}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert> `
: nothing}
<p>
${this.localize(
"ui.panel.page-onboarding.restore.confirm_restore_full_backup_text"
)}
</p>
${backupProtected
? html`<p>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.title"
)}
</p>
${this._encryptionKeyWrong
? html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
</ha-alert>
`
: nothing}
<ha-password-field
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.input_label"
)}
.value=${this._encryptionKey}
></ha-password-field>`
: nothing}
</div>
<div class="card-actions">
<ha-progress-button
.progress=${this._loading}
.disabled=${this._loading ||
(backupProtected && this._encryptionKey === "")}
@click=${this._startRestore}
destructive
>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.action"
)}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _encryptionKeyChanged(ev): void {
this._encryptionKey = ev.target.value;
}
private async _startRestore(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as HaProgressButton;
this._loading = true;
this._error = undefined;
this._encryptionKeyWrong = false;
const backupAgent = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
try {
await restoreOnboardingBackup({
agent_id: backupAgent,
backup_id: this.backup.backup_id,
password: this._encryptionKey || undefined,
restore_addons: this.selectedData.addons.map((addon) => addon.slug),
restore_database: this.selectedData.database_included,
restore_folders: this.selectedData.folders,
});
button.actionSuccess();
fireEvent(this, "restore-started");
} catch (err: any) {
if (err.error === "Request error") {
// core can shutdown before we get a response
button.actionSuccess();
fireEvent(this, "restore-started");
return;
}
button.actionError();
if (err.body?.code === "incorrect_password") {
this._encryptionKeyWrong = true;
} else {
this._error =
err.body?.message || err.message || "Unknown error occurred";
}
this._loading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.supervisor-warning {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-restore": OnboardingRestoreBackupRestore;
}
interface HASSDomEvents {
"restore-started";
}
}
@@ -0,0 +1,119 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupOnboardingInfo } from "../../data/backup_onboarding";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { removeSearchParam } from "../../common/url/search-params";
@customElement("onboarding-restore-backup-status")
class OnboardingRestoreBackupStatus extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false })
public backupInfo!: BackupOnboardingInfo;
render() {
return html`
<ha-card
.header=${this.localize(
`ui.panel.page-onboarding.restore.${this.backupInfo.state === "restore_backup" ? "in_progress" : "failed"}`
)}
>
<div class="card-content">
${this.backupInfo.state === "restore_backup"
? html`
<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
<p>
${this.localize(
"ui.panel.page-onboarding.restore.in_progress_description"
)}
</p>
`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.failed_status_description"
)}
</ha-alert>
${this.backupInfo.last_non_idle_event?.reason
? html`
<div class="failed">
<h4>Error:</h4>
${this.backupInfo.last_non_idle_event?.reason}
</div>
`
: nothing}
`}
</div>
${this.backupInfo.state !== "restore_backup"
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
<ha-button @click=${this._home} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.home`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
private _home() {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.loading {
display: flex;
justify-content: center;
padding: 32px;
}
p {
text-align: center;
padding: 0 16px;
font-size: 16px;
}
.failed {
padding: 16px 0;
font-size: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-status": OnboardingRestoreBackupStatus;
}
interface HASSDomEvents {
"restore-started";
}
}
@@ -0,0 +1,126 @@
import { mdiFolderUpload } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-file-upload";
import "../../components/ha-alert";
import { haStyle } from "../../resources/styles";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
import { uploadOnboardingBackup } from "../../data/backup_onboarding";
declare global {
interface HASSDomEvents {
"backup-uploaded": { backupId: string };
}
}
@customElement("onboarding-restore-backup-upload")
class OnboardingRestoreBackupUpload extends LitElement {
@property({ type: Boolean }) public supervisor = false;
@property({ attribute: false }) public localize!: LocalizeFunc;
@state() private _uploading = false;
@state() private _error?: string;
render() {
return html`
<ha-card
.header=${this.localize(
"ui.panel.page-onboarding.restore.upload_backup"
)}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.localize}
.label=${this.localize(
"ui.panel.page-onboarding.restore.upload_input_label"
)}
.secondary=${this.localize(
"ui.panel.page-onboarding.restore.upload_secondary"
)}
.supports=${this.localize(
"ui.panel.page-onboarding.restore.upload_supports_tar"
)}
.deleteLabel=${this.localize(
"ui.panel.page-onboarding.restore.delete"
)}
.uploadingLabel=${this.localize(
"ui.panel.page-onboarding.restore.uploading"
)}
@file-picked=${this._filePicked}
></ha-file-upload>
</div>
</ha-card>
`;
}
private async _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.unsupported.text"
),
confirmText: this.localize("ui.panel.page-onboarding.restore.ok"),
});
return;
}
const agentIds = this.supervisor
? [HASSIO_LOCAL_AGENT]
: [CORE_LOCAL_AGENT];
this._uploading = true;
try {
const { backup_id } = await uploadOnboardingBackup(file, agentIds);
fireEvent(this, "backup-uploaded", { backupId: backup_id });
} catch (err: any) {
this._error =
typeof err.body === "string"
? err.body
: err.body?.message || err.message || "Unknown error occurred";
} finally {
this._uploading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
width: 100%;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-upload": OnboardingRestoreBackupUpload;
}
}
@@ -21,7 +21,7 @@ export interface BackupAddonItem {
@customElement("ha-backup-addons-picker")
export class HaBackupAddonsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public addons!: BackupAddonItem[];
@@ -32,7 +32,7 @@ export class HaBackupAddonsPicker extends LitElement {
private _addons = memoizeOne((addons: BackupAddonItem[]) =>
addons.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
stringCompare(a.name, b.name, this.hass?.locale?.language)
)
);
@@ -47,23 +47,32 @@ interface SelectedItems {
@customElement("ha-backup-data-picker")
export class HaBackupDataPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public data!: BackupData;
@property({ attribute: false }) public value?: BackupData;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Array, attribute: "required-items" })
public requiredItems: string[] = [];
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() public _addonIcons: Record<string, boolean> = {};
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) {
if (this.hass && isComponentLoaded(this.hass, "hassio")) {
this._fetchAddonInfo();
}
}
private async _fetchAddonInfo() {
const { addons } = await fetchHassioAddonsInfo(this.hass);
const { addons } = await fetchHassioAddonsInfo(this.hass!);
this._addonIcons = addons.reduce<Record<string, boolean>>(
(acc, addon) => ({
...acc,
@@ -74,16 +83,14 @@ export class HaBackupDataPicker extends LitElement {
}
private _homeAssistantItems = memoizeOne(
(data: BackupData, _localize: LocalizeFunc) => {
(data: BackupData, localize: LocalizeFunc) => {
const items: CheckBoxItem[] = [];
if (data.homeassistant_included) {
items.push({
label: data.database_included
? this.hass.localize(
"ui.panel.config.backup.data_picker.settings_and_history"
)
: this.hass.localize("ui.panel.config.backup.data_picker.settings"),
label: localize(
`ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
),
id: "config",
version: data.homeassistant_version,
});
@@ -99,18 +106,22 @@ export class HaBackupDataPicker extends LitElement {
);
private _localizeFolder(folder: string): string {
const localize = this.localize || this.hass!.localize;
switch (folder) {
case "media":
return this.hass.localize("ui.panel.config.backup.data_picker.media");
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.media`
);
case "share":
return this.hass.localize(
"ui.panel.config.backup.data_picker.share_folder"
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.share_folder`
);
case "ssl":
return this.hass.localize("ui.panel.config.backup.data_picker.ssl");
return localize(`ui.panel.${this.translationKeyPanel}.data_picker.ssl`);
case "addons/local":
return this.hass.localize(
"ui.panel.config.backup.data_picker.local_addons"
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.local_addons`
);
}
return capitalizeFirstLetter(folder);
@@ -215,14 +226,13 @@ export class HaBackupDataPicker extends LitElement {
}
protected render() {
const homeAssistantItems = this._homeAssistantItems(
this.data,
this.hass.localize
);
const localize = this.localize || this.hass!.localize;
const homeAssistantItems = this._homeAssistantItems(this.data, localize);
const addonsItems = this._addonsItems(
this.data,
this.hass.localize,
localize,
this._addonIcons
);
@@ -247,6 +257,7 @@ export class HaBackupDataPicker extends LitElement {
selectedItems.homeassistant.length <
homeAssistantItems.length}
@change=${this._sectionChanged}
?disabled=${this.requiredItems.length > 0}
></ha-checkbox>
</ha-formfield>
<div class="items">
@@ -266,6 +277,7 @@ export class HaBackupDataPicker extends LitElement {
item.id
)}
@change=${this._homeassistantChanged}
.disabled=${this.requiredItems.includes(item.id)}
></ha-checkbox>
</ha-formfield>
`
@@ -280,8 +292,8 @@ export class HaBackupDataPicker extends LitElement {
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${this.hass.localize(
"ui.panel.config.backup.data_picker.addons"
.label=${localize(
`ui.panel.${this.translationKeyPanel}.data_picker.addons`
)}
.iconPath=${mdiPuzzle}
>
@@ -0,0 +1,148 @@
import memoizeOne from "memoize-one";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import "./ha-backup-data-picker";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type {
BackupContentExtended,
BackupData,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
@customElement("ha-backup-details-restore")
class HaBackupDetailsRestore extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "ha-required" })
public haRequired = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() private _selectedData?: BackupData;
protected willUpdate() {
if (!this.hasUpdated && this.haRequired) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this.backup.homeassistant_version,
database_included: this.backup.database_included,
};
}
}
render() {
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.title`
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.translationKeyPanel=${this.translationKeyPanel}
.localize=${this.localize}
.hass=${this.hass}
.data=${this.backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.requiredItems=${this._isHomeAssistantRequired(this.haRequired)}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled}
destructive
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.action`
)}
</ha-button>
</div>
</ha-card>
`;
}
private _restore() {
fireEvent(this, "backup-restore", { selectedData: this._selectedData });
}
private _selectedBackupChanged(ev: CustomEvent) {
ev.stopPropagation();
this._selectedData = ev.detail.value;
}
private _isHomeAssistantRequired = memoizeOne((required: boolean) =>
required ? ["config"] : []
);
private get _isRestoreDisabled(): boolean {
return (
!this._selectedData ||
(this.haRequired && !this._selectedData.homeassistant_included) ||
!(
this._selectedData?.database_included ||
this._selectedData?.homeassistant_included ||
this._selectedData.addons.length ||
this._selectedData.folders.length
)
);
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-restore": HaBackupDetailsRestore;
}
interface HASSDomEvents {
"backup-restore": { selectedData?: BackupData };
}
}
@@ -0,0 +1,153 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
formatDateTime,
formatDateTimeWithBrowserDefaults,
} from "../../../../common/datetime/format_date_time";
import {
computeBackupSize,
computeBackupType,
type BackupContentExtended,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
import { bytesToString } from "../../../../util/bytes-to-string";
declare global {
interface HASSDomEvents {
"show-backup-upload": undefined;
}
}
@customElement("ha-backup-details-summary")
class HaBackupDetailsSummary extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "hassio" }) public isHassio = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@property({ type: Boolean, attribute: "show-upload-another" })
public showUploadAnother = false;
render() {
const backupDate = new Date(this.backup.date);
const formattedDate = this.hass
? formatDateTime(backupDate, this.hass.locale, this.hass.config)
: formatDateTimeWithBrowserDefaults(backupDate);
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.title`
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
${this.translationKeyPanel === "config.backup"
? html`<ha-md-list-item>
<span slot="headline">
${this.localize("ui.panel.config.backup.backup_type")}
</span>
<span slot="supporting-text">
${this.localize(
`ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}`
)}
</span>
</ha-md-list-item>`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.size`
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this.backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.created`
)}
</span>
<span slot="supporting-text"> ${formattedDate} </span>
</ha-md-list-item>
</ha-md-list>
</div>
${this.showUploadAnother
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-summary": HaBackupDetailsSummary;
}
}
@@ -3,7 +3,10 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
@@ -14,7 +17,10 @@ import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup,
INITIAL_UPLOAD_FORM_DATA,
type BackupUploadFileFormData,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
@@ -22,16 +28,6 @@ import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
const SUPPORTED_FORMAT = "application/x-tar";
interface FormData {
file?: File;
}
const INITIAL_DATA: FormData = {
file: undefined,
};
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends LitElement
@@ -45,13 +41,13 @@ export class DialogUploadBackup
@state() private _error?: string;
@state() private _formData?: FormData;
@state() private _formData?: BackupUploadFileFormData;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(params: UploadBackupDialogParams): Promise<void> {
this._params = params;
this._formData = INITIAL_DATA;
this._formData = INITIAL_UPLOAD_FORM_DATA;
}
private _dialogClosed() {
@@ -78,13 +74,18 @@ export class DialogUploadBackup
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-md-dialog
open
@closed=${this._dialogClosed}
.disableCancelAction=${this._uploading}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._uploading}
></ha-icon-button>
<span slot="title">
@@ -99,7 +100,8 @@ export class DialogUploadBackup
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_FORMAT}
.accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label"
)}
@@ -107,13 +109,17 @@ export class DialogUploadBackup
"ui.panel.config.backup.dialogs.upload.supports_tar"
)}
@file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}
<ha-button @click=${this.closeDialog} .disabled=${this._uploading}
>${this.hass.localize("ui.common.cancel")}</ha-button
>
<ha-button @click=${this._upload} .disabled=${!this._formValid()}>
<ha-button
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
)}
@@ -123,7 +129,7 @@ export class DialogUploadBackup
`;
}
private async _filePicked(ev: CustomEvent<{ files: File[] }>): Promise<void> {
private _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined;
const file = ev.detail.files[0];
@@ -133,9 +139,14 @@ export class DialogUploadBackup
};
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
}
private async _upload() {
const { file } = this._formData!;
if (!file || file.type !== SUPPORTED_FORMAT) {
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title"
@@ -154,7 +165,7 @@ export class DialogUploadBackup
this._uploading = true;
try {
await uploadBackup(this.hass!, file, agentIds);
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this.closeDialog();
} catch (err: any) {
@@ -8,7 +8,6 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { computeDomain } from "../../../common/entity/compute_domain";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert";
@@ -25,24 +24,20 @@ import type {
BackupConfig,
BackupContentAgent,
BackupContentExtended,
BackupData,
} from "../../../data/backup";
import "./components/ha-backup-details-summary";
import "./components/ha-backup-details-restore";
import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup,
fetchBackupDetails,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
import type { HassioAddonInfo } from "../../../data/hassio/addon";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { bytesToString } from "../../../util/bytes-to-string";
import "./components/ha-backup-data-picker";
import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@@ -93,10 +88,6 @@ class HaConfigBackupDetails extends LitElement {
@state() private _error?: string;
@state() private _selectedData?: BackupData;
@state() private _addonsInfo?: HassioAddonInfo[];
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
@@ -157,81 +148,18 @@ class HaConfigBackupDetails extends LitElement {
: !this._backup
? html`<ha-circular-progress active></ha-circular-progress>`
: html`
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.details.summary.title"
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.backup_type"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.size"
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this._backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.created"
)}
</span>
<span slot="supporting-text">
${formatDateTime(
new Date(this._backup.date),
this.hass.locale,
this.hass.config
)}
</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.details.restore.title"
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.hass=${this.hass}
.data=${this._backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.addonsInfo=${this._addonsInfo}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled()}
class="danger"
>
${this.hass.localize(
"ui.panel.config.backup.details.restore.action"
)}
</ha-button>
</div>
</ha-card>
<ha-backup-details-summary
.backup=${this._backup}
.hass=${this.hass}
.localize=${this.hass.localize}
.isHassio=${isHassio}
></ha-backup-details-summary>
<ha-backup-details-restore
.backup=${this._backup}
@backup-restore=${this._restore}
.hass=${this.hass}
.localize=${this.hass.localize}
></ha-backup-details-restore>
<ha-card>
<div class="card-header">
${this.hass.localize(
@@ -360,30 +288,13 @@ class HaConfigBackupDetails extends LitElement {
`;
}
private _selectedBackupChanged(ev: CustomEvent) {
ev.stopPropagation();
this._selectedData = ev.detail.value;
}
private _isRestoreDisabled() {
return (
!this._selectedData ||
!(
this._selectedData?.database_included ||
this._selectedData?.homeassistant_included ||
this._selectedData.addons.length ||
this._selectedData.folders.length
)
);
}
private _restore() {
if (!this._backup || !this._selectedData) {
private _restore(ev: CustomEvent) {
if (!this._backup || !ev.detail.selectedData) {
return;
}
showRestoreBackupDialog(this, {
backup: this._backup,
selectedData: this._selectedData,
selectedData: ev.detail.selectedData,
});
}
@@ -469,13 +380,6 @@ class HaConfigBackupDetails extends LitElement {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
.warning {
color: var(--error-color);
}
@@ -485,9 +389,6 @@ class HaConfigBackupDetails extends LitElement {
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}
ha-backup-data-picker {
display: block;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
@@ -51,8 +51,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import type { ConfigEntry } from "../../../data/config_entries";
import { sortConfigEntries } from "../../../data/config_entries";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters";
import {
@@ -108,6 +108,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entities!: EntityRegistryEntry[];
@@ -219,6 +221,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
private _setFiltersFromUrl() {
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");
if (!domain && !configEntry && !label) {
@@ -243,6 +246,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
}
@@ -334,6 +341,32 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (
key === "sub_entry" &&
Array.isArray(filter.value) &&
filter.value.length
) {
if (
!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)
) {
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
)
)
);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
}
} else if (
key === "ha-filter-integrations" &&
Array.isArray(filter.value) &&
@@ -755,7 +788,15 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
${this.entries?.find(
(entry) =>
entry.entry_id === this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]}
)?.title || this._filters.config_entry.value[0]}${this._filters
.config_entry.value.length === 1 &&
Array.isArray(this._filters.sub_entry?.value) &&
this._filters.sub_entry.value.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry!.value![0]
)?.title || this._filters.sub_entry!.value![0]})`
: nothing}
</ha-alert>`
: nothing}
<ha-filter-floor-areas
@@ -888,6 +929,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
`;
}
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _filterExpanded(ev) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
@@ -66,8 +66,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../data/config_entries";
import { getConfigEntries } from "../../../data/config_entries";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getConfigEntries, getSubEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import type {
DataTableFiltersItems,
@@ -146,6 +146,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entries?: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() private _manifests?: IntegrationManifest[];
@state()
@@ -522,6 +524,27 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (
key === "sub_entry" &&
Array.isArray(filter) &&
filter.length
) {
if (
!(
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry.length === 1
)
) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
(filter as string[]).includes(entity.config_subentry_id)
);
if (!this._subEntries) {
this._loadSubEntries(this._filters.config_entry[0]);
}
} else if (
key === "ha-filter-integrations" &&
Array.isArray(filter) &&
@@ -904,14 +927,22 @@ ${
</ha-md-button-menu>
${
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry?.length
this._filters.config_entry.length
? html`<ha-alert slot="filter-pane">
${this.hass.localize(
"ui.panel.config.entities.picker.filtering_by_config_entry"
)}
${this._entries?.find(
(entry) => entry.entry_id === this._filters.config_entry![0]
)?.title || this._filters.config_entry[0]}
)?.title || this._filters.config_entry[0]}${this._filters
.config_entry.length === 1 &&
Array.isArray(this._filters.sub_entry) &&
this._filters.sub_entry.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry![0]
)?.title || this._filters.sub_entry[0]})`
: nothing}
</ha-alert>`
: nothing
}
@@ -1024,6 +1055,7 @@ ${
private _setFiltersFromUrl() {
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");
if (!domain && !configEntry && !label) {
@@ -1036,6 +1068,7 @@ ${
"ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
this._filterLabel();
}
@@ -1093,6 +1126,7 @@ ${
hidden_by: null,
area_id: null,
config_entry_id: null,
config_subentry_id: null,
device_id: null,
icon: null,
readonly: true,
@@ -1384,6 +1418,10 @@ ${rejected
this._entries = await getConfigEntries(this.hass);
}
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _addDevice() {
const { filteredConfigEntry, filteredDomains } =
this._filteredEntitiesAndDomains(
@@ -654,6 +654,7 @@ class AddIntegrationDialog extends LitElement {
startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest,
navigateToResult: true,
});
}
@@ -137,6 +137,7 @@ export class HaConfigFlowCard extends LitElement {
}
showConfigFlowDialog(this, {
continueFlowId: this.flow.flow_id,
navigateToResult: true,
dialogClosedCallback: () => {
this._handleFlowUpdated();
},
@@ -16,6 +16,7 @@ import {
mdiOpenInNew,
mdiPackageVariant,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
mdiReload,
mdiReloadAlert,
@@ -52,14 +53,17 @@ import { getSignedPath } from "../../../data/auth";
import type {
ConfigEntry,
DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries";
import {
ERROR_STATES,
RECOVERABLE_STATES,
deleteConfigEntry,
deleteSubEntry,
disableConfigEntry,
enableConfigEntry,
getConfigEntries,
getSubEntries,
reloadConfigEntry,
updateConfigEntry,
} from "../../../data/config_entries";
@@ -106,6 +110,7 @@ import { fileDownload } from "../../../util/file_download";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
export const renderConfigEntryError = (
hass: HomeAssistant,
@@ -172,6 +177,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _domainEntities: Record<string, string[]> = {};
@state() private _subEntries: Record<string, SubEntry[]> = {};
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
@@ -214,11 +221,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("domain")) {
this.hass.loadBackendTranslation("title", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined;
this._fetchManifest();
this._fetchDiagnostics();
this._fetchEntitySources();
}
if (
changedProperties.has("configEntries") ||
changedProperties.has("_extraConfigEntries")
) {
this._fetchSubEntries();
}
}
private async _fetchEntitySources() {
@@ -573,7 +587,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
const attention = ATTENTION_SOURCES.includes(
flow.context.source
);
return html` <ha-md-list-item
return html`<ha-md-list-item
class="config_entry ${attention ? "attention" : ""}"
>
${flow.localized_title}
@@ -673,6 +687,73 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
ev.target.style.display = "none";
}
private _renderDeviceLine(
item: ConfigEntry,
devices: DeviceRegistryEntry[],
services: DeviceRegistryEntry[],
entities: EntityRegistryEntry[],
subItem?: SubEntry
) {
let devicesLine: (TemplateResult | string)[] = [];
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as const) {
if (items.length === 0) {
continue;
}
const url =
items.length === 1
? `/config/devices/device/${items[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`;
devicesLine.push(
// no white space before/after template on purpose
html`<a href=${url}
>${this.hass.localize(
`ui.panel.config.integrations.config_entry.${localizeKey}`,
{ count: items.length }
)}</a
>`
);
}
if (entities.length) {
devicesLine.push(
// no white space before/after template on purpose
html`<a
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
if (devicesLine.length === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else if (devicesLine.length === 2) {
devicesLine = [
devicesLine[0],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[1],
];
} else if (devicesLine.length === 3) {
devicesLine = [
devicesLine[0],
", ",
devicesLine[1],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[2],
];
}
return devicesLine;
}
private _renderConfigEntry(item: ConfigEntry) {
let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined;
@@ -720,274 +801,299 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}.`);
}
} else {
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as const) {
if (items.length === 0) {
continue;
}
const url =
items.length === 1
? `/config/devices/device/${items[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`;
devicesLine.push(
// no white space before/after template on purpose
html`<a href=${url}
>${this.hass.localize(
`ui.panel.config.integrations.config_entry.${localizeKey}`,
{ count: items.length }
)}</a
>`
);
}
if (entities.length) {
devicesLine.push(
// no white space before/after template on purpose
html`<a
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
if (devicesLine.length === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else if (devicesLine.length === 2) {
devicesLine = [
devicesLine[0],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[1],
];
} else if (devicesLine.length === 3) {
devicesLine = [
devicesLine[0],
", ",
devicesLine[1],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[2],
];
}
devicesLine = this._renderDeviceLine(item, devices, services, entities);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries[item.entry_id] || [];
return html`<ha-md-list-item
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
})}
data-entry-id=${item.entry_id}
.configEntry=${item}
>
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${devicesLine}</div>
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-button></a
>`
: item.supports_options
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
})}
data-entry-id=${item.entry_id}
.configEntry=${item}
>
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${devicesLine}</div>
${stateText
? html`
<ha-button slot="end" @click=${this._showOptions}>
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-button>
</ha-button></a
>`
: item.supports_options
? html`
<ha-button slot="end" @click=${this._showOptions}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${item.disabled_by && devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${item.disabled_by && services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${item.disabled_by && entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.title`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this._diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`;
}
private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubEntry) {
const devices = this._getConfigEntryDevices(configEntry).filter((device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const services = this._getConfigEntryServices(configEntry).filter(
(device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const entities = this._getConfigEntryEntities(configEntry).filter(
(entity) => entity.config_subentry_id === subEntry.subentry_id
);
return html`<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this._renderDeviceLine(
configEntry,
devices,
services,
entities,
subEntry
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-button slot="end" @click=${this._handleReconfigureSub}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${item.disabled_by && devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${item.disabled_by && services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${item.disabled_by && entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this._diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>`;
}
@@ -1009,6 +1115,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _continueFlow(ev) {
showConfigFlowDialog(this, {
continueFlowId: ev.target.flow.flow_id,
navigateToResult: true,
dialogClosedCallback: () => {
// this._handleFlowUpdated();
},
@@ -1030,6 +1137,27 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
}
private async _fetchSubEntries() {
const subEntriesPromises = (
this._extraConfigEntries || this.configEntries
)?.map((entry) =>
entry.num_subentries
? getSubEntries(this.hass, entry.entry_id).then((subEntries) => ({
entry_id: entry.entry_id,
subEntries,
}))
: undefined
);
if (subEntriesPromises) {
const subEntries = await Promise.all(subEntriesPromises);
this._subEntries = {};
subEntries.forEach((entry) => {
if (!entry) return;
this._subEntries[entry.entry_id] = entry.subEntries;
});
}
}
private async _fetchDiagnostics() {
if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) {
return;
@@ -1177,6 +1305,49 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
);
}
private async _handleReconfigureSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
showSubConfigFlowDialog(
this,
configEntry,
subEntry.flowType || subEntry.subentry_type,
{
startFlowHandler: configEntry.entry_id,
subEntryId: subEntry.subentry_id,
}
);
}
private async _handleDeleteSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: subEntry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await deleteSubEntry(this.hass, configEntry.entry_id, subEntry.subentry_id);
}
private _handleDisable(ev: Event): void {
this._disableIntegration(
((ev.target as HTMLElement).closest(".config_entry") as any).configEntry
@@ -1384,6 +1555,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, configEntry.domain),
entryId: configEntry.entry_id,
navigateToResult: true,
});
}
@@ -1454,6 +1626,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
});
}
private async _addSubEntry(ev) {
showSubConfigFlowDialog(this, ev.target.entry, ev.target.flowType, {
startFlowHandler: ev.target.entry.entry_id,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -1583,6 +1761,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
pointer-events: none;
content: "";
}
ha-md-list-item.sub-entry {
--md-list-item-leading-space: 50px;
}
a {
text-decoration: none;
}
@@ -207,6 +207,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
supports_remove_device: false,
supports_unload: false,
supports_reconfigure: false,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
@@ -279,6 +279,7 @@ class HaDomainIntegrations extends LitElement {
{
startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: true,
manifest: await fetchIntegrationManifest(this.hass, domain),
}
);
@@ -295,6 +296,7 @@ class HaDomainIntegrations extends LitElement {
root instanceof ShadowRoot ? (root.host as HTMLElement) : this,
{
continueFlowId: flow.flow_id,
navigateToResult: true,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, flow.handler),
}
@@ -301,10 +301,6 @@ ${type === "object"
direction: var(--direction);
}
.edit-pane .card-content {
user-select: initial;
}
.edit-pane a {
color: var(--primary-color);
}
@@ -27,6 +27,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import type { ECOption } from "../../../../resources/echarts";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
@customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard
@@ -109,8 +110,11 @@ export class HuiEnergyDevicesGraphCard
return `${title}${params.marker} ${params.seriesName}: ${value}`;
}
private _createOptions = memoizeOne(
(data: BarSeriesOption[]): ECOption => ({
private _createOptions = memoizeOne((data: BarSeriesOption[]): ECOption => {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
return {
xAxis: {
type: "value",
name: "kWh",
@@ -124,6 +128,17 @@ export class HuiEnergyDevicesGraphCard
axisLabel: {
formatter: this._getDeviceName.bind(this),
overflow: "truncate",
fontSize: 12,
margin: 5,
width: Math.min(
isMobile ? 100 : 200,
Math.max(
...(data[0]?.data?.map(
(d: any) =>
measureTextWidth(this._getDeviceName(d.value[1]), 12) + 5
) || [])
)
),
},
},
grid: {
@@ -137,8 +152,8 @@ export class HuiEnergyDevicesGraphCard
show: true,
formatter: this._renderTooltip.bind(this),
},
})
);
};
});
private _getDeviceName(statisticId: string): string {
return (
@@ -300,6 +300,8 @@ export class HuiEnergyUsageGraphCard
type: "bar",
stack: "usage",
data: [],
// @ts-expect-error
order: 0,
});
}
@@ -315,6 +317,8 @@ export class HuiEnergyUsageGraphCard
)
);
// @ts-expect-error
datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
@@ -482,7 +486,7 @@ export class HuiEnergyUsageGraphCard
this._compareStart!
);
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(combinedData).forEach(([type, sources], idx) => {
Object.entries(sources).forEach(([statId, source]) => {
const points: BarSeriesOption["data"] = [];
// Process chart data.
@@ -513,6 +517,13 @@ export class HuiEnergyUsageGraphCard
statId,
statisticsMetaData[statId]
),
// @ts-expect-error
order:
type === "used_solar"
? 1
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 2,
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
@@ -240,6 +240,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
.container.edit-mode {
padding: 8px;
border-radius: var(--ha-card-border-radius, 12px);
border-start-end-radius: 0;
border: 2px dashed var(--divider-color);
min-height: var(--row-height);
}
+117 -46
View File
@@ -1,11 +1,14 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiAlertCircleOutline } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-formfield";
import "../../components/ha-radio";
import "../../components/ha-button";
import "../../components/ha-circular-progress";
import "../../components/ha-svg-icon";
import "../../components/ha-list-item";
import type { HaRadio } from "../../components/ha-radio";
import "../../components/ha-select";
import "../../components/ha-settings-row";
@@ -14,8 +17,10 @@ import {
DEFAULT_ACCENT_COLOR,
DEFAULT_PRIMARY_COLOR,
} from "../../resources/styles-data";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ThemeSettings } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import type { StorageLocation } from "../../state/themes-mixin";
import { subscribeSelectedTheme } from "../../data/ws-themes";
const USE_DEFAULT_THEME = "__USE_DEFAULT_THEME__";
const HOME_ASSISTANT_THEME = "default";
@@ -26,57 +31,78 @@ export class HaPickThemeRow extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false })
public storageLocation: StorageLocation = "browser";
@state() _themeNames: string[] = [];
@state() private _selectedTheme?: ThemeSettings;
@state() private _loading = false;
protected render(): TemplateResult {
if (this._loading) {
return html`<ha-circular-progress indeterminate></ha-circular-progress>`;
}
const hasThemes =
this.hass.themes.themes && Object.keys(this.hass.themes.themes).length;
const curThemeIsUseDefault = this.hass.selectedTheme?.theme === "";
const curTheme = this.hass.selectedTheme?.theme
? this.hass.selectedTheme?.theme
: this.hass.themes.darkMode
const curThemeIsUseDefault = this._selectedTheme?.theme === "";
const curTheme = this._selectedTheme?.theme
? this._selectedTheme?.theme
: this._selectedTheme?.dark
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const themeSettings = this.hass.selectedTheme;
return html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading"
>${this.hass.localize("ui.panel.profile.themes.header")}</span
>
<span slot="description">
${!hasThemes
<span
slot="description"
class=${this.storageLocation === "user" &&
this.hass.browserThemeEnabled
? "device-info"
: ""}
>
${!hasThemes &&
!(this.storageLocation === "user" && this.hass.browserThemeEnabled)
? this.hass.localize("ui.panel.profile.themes.error_no_theme")
: ""}
<a
href=${documentationUrl(
this.hass,
"/integrations/frontend/#defining-themes"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>
${this.storageLocation === "user" && this.hass.browserThemeEnabled
? html`<ha-svg-icon .path=${mdiAlertCircleOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.profile.themes.device.user_theme_info"
)}`
: html`<a
href=${documentationUrl(
this.hass,
"/integrations/frontend/#defining-themes"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>`}
</span>
<ha-select
.label=${this.hass.localize("ui.panel.profile.themes.dropdown_label")}
.disabled=${!hasThemes}
.value=${this.hass.selectedTheme?.theme || USE_DEFAULT_THEME}
.value=${this._selectedTheme?.theme || USE_DEFAULT_THEME}
@selected=${this._handleThemeSelection}
naturalMenuWidth
>
<mwc-list-item .value=${USE_DEFAULT_THEME}>
<ha-list-item .value=${USE_DEFAULT_THEME}>
${this.hass.localize("ui.panel.profile.themes.use_default")}
</mwc-list-item>
<mwc-list-item .value=${HOME_ASSISTANT_THEME}>
</ha-list-item>
<ha-list-item .value=${HOME_ASSISTANT_THEME}>
Home Assistant
</mwc-list-item>
</ha-list-item>
${this._themeNames.map(
(theme) => html`
<mwc-list-item .value=${theme}>${theme}</mwc-list-item>
<ha-list-item .value=${theme}>${theme}</ha-list-item>
`
)}
</ha-select>
@@ -86,7 +112,7 @@ export class HaPickThemeRow extends LitElement {
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html` <div class="inputs">
? html`<div class="inputs">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.profile.themes.dark_mode.auto"
@@ -96,7 +122,7 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="auto"
.checked=${themeSettings?.dark === undefined}
.checked=${this._selectedTheme?.dark === undefined}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -108,7 +134,7 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="light"
.checked=${themeSettings?.dark === false}
.checked=${this._selectedTheme?.dark === false}
>
</ha-radio>
</ha-formfield>
@@ -121,14 +147,14 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="dark"
.checked=${themeSettings?.dark === true}
.checked=${this._selectedTheme?.dark === true}
>
</ha-radio>
</ha-formfield>
${curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-textfield
.value=${themeSettings?.primaryColor ||
.value=${this._selectedTheme?.primaryColor ||
DEFAULT_PRIMARY_COLOR}
type="color"
.label=${this.hass.localize(
@@ -138,7 +164,8 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleColorChange}
></ha-textfield>
<ha-textfield
.value=${themeSettings?.accentColor || DEFAULT_ACCENT_COLOR}
.value=${this._selectedTheme?.accentColor ||
DEFAULT_ACCENT_COLOR}
type="color"
.label=${this.hass.localize(
"ui.panel.profile.themes.accent_color"
@@ -146,19 +173,36 @@ export class HaPickThemeRow extends LitElement {
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-textfield>
${themeSettings?.primaryColor || themeSettings?.accentColor
? html` <mwc-button @click=${this._resetColors}>
${this._selectedTheme?.primaryColor ||
this._selectedTheme?.accentColor
? html`<ha-button @click=${this._resetColors}>
${this.hass.localize("ui.panel.profile.themes.reset")}
</mwc-button>`
: ""}
</ha-button>`
: nothing}
</div>`
: ""}
: nothing}
</div>`
: ""}
: nothing}
`;
}
public willUpdate(changedProperties: PropertyValues) {
if (!this.hasUpdated) {
if (this.storageLocation === "browser") {
this._selectedTheme = this.hass.selectedTheme ?? undefined;
} else {
this._loading = true;
this._selectedTheme = undefined;
subscribeSelectedTheme(
this.hass,
(selectedTheme?: ThemeSettings | null) => {
this._selectedTheme = selectedTheme ?? undefined;
this._loading = false;
}
);
}
}
const oldHass = changedProperties.get("hass") as undefined | HomeAssistant;
const themesChanged =
changedProperties.has("hass") &&
@@ -169,13 +213,34 @@ export class HaPickThemeRow extends LitElement {
}
}
private _shouldSaveHass() {
return (
this.storageLocation === "browser" ||
(this.storageLocation === "user" && !this.hass.browserThemeEnabled)
);
}
private _updateSelectedTheme(updatedTheme: Partial<ThemeSettings>) {
this._selectedTheme = {
...this._selectedTheme,
...updatedTheme,
theme: updatedTheme.theme ?? this._selectedTheme?.theme ?? "",
};
fireEvent(this, "settheme", {
settings: this._selectedTheme,
storageLocation: this.storageLocation,
saveHass: this._shouldSaveHass(),
});
}
private _handleColorChange(ev: CustomEvent) {
const target = ev.target as any;
fireEvent(this, "settheme", { [target.name]: target.value });
this._updateSelectedTheme({ [target.name]: target.value });
}
private _resetColors() {
fireEvent(this, "settheme", {
this._updateSelectedTheme({
primaryColor: undefined,
accentColor: undefined,
});
@@ -198,18 +263,18 @@ export class HaPickThemeRow extends LitElement {
dark = true;
break;
}
fireEvent(this, "settheme", { dark });
this._updateSelectedTheme({ dark });
}
private _handleThemeSelection(ev) {
const theme = ev.target.value;
if (theme === this.hass.selectedTheme?.theme) {
if (theme === this._selectedTheme?.theme) {
return;
}
if (theme === USE_DEFAULT_THEME) {
if (this.hass.selectedTheme?.theme) {
fireEvent(this, "settheme", {
this._updateSelectedTheme({
theme: "",
primaryColor: undefined,
accentColor: undefined,
@@ -217,7 +282,7 @@ export class HaPickThemeRow extends LitElement {
}
return;
}
fireEvent(this, "settheme", {
this._updateSelectedTheme({
theme,
primaryColor: undefined,
accentColor: undefined,
@@ -249,6 +314,12 @@ export class HaPickThemeRow extends LitElement {
flex-grow: 1;
margin: 0 4px;
}
.device-info {
color: var(--warning-color);
display: flex;
align-items: center;
gap: 8px;
}
`;
}
@@ -1,10 +1,12 @@
import "@material/mwc-button";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-card";
import "../../components/ha-button";
import "../../components/ha-expansion-panel";
import "../../components/ha-switch";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import { isExternal } from "../../data/external";
@@ -27,6 +29,7 @@ import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
import type { HaSwitch } from "../../components/ha-switch";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@@ -38,6 +41,8 @@ class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _browserThemeActivated = false;
private _unsubCoreData?: UnsubscribeFunc;
private _getCoreData() {
@@ -91,9 +96,9 @@ class HaProfileSectionGeneral extends LitElement {
: ""}
</div>
<div class="card-actions">
<mwc-button class="warning" @click=${this._handleLogOut}>
<ha-button class="warning" @click=${this._handleLogOut}>
${this.hass.localize("ui.panel.profile.logout")}
</mwc-button>
</ha-button>
</div>
</ha-card>
<ha-card
@@ -128,6 +133,12 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
this._browserThemeActivated}
.storageLocation=${"user"}
></ha-pick-theme-row>
${this.hass.user!.is_admin
? html`
<ha-advanced-mode-row
@@ -148,10 +159,42 @@ class HaProfileSectionGeneral extends LitElement {
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
isExternal
? "ui.panel.profile.themes.device.mobile_app_header"
: "ui.panel.profile.themes.device.browser_header"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.profile.themes.device.description"
)}
</span>
<ha-switch
.checked=${this.hass.browserThemeEnabled ||
this._browserThemeActivated}
@change=${this._toggleBrowserTheme}
></ha-switch>
</ha-settings-row>
${this.hass.browserThemeEnabled || this._browserThemeActivated
? html`
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.profile.themes.device.custom_theme"
)}
expanded
>
<ha-pick-theme-row
class="device-theme"
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
</ha-expansion-panel>
`
: nothing}
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
@@ -167,11 +210,11 @@ class HaProfileSectionGeneral extends LitElement {
"ui.panel.profile.customize_sidebar.description"
)}
</span>
<mwc-button @click=${this._customizeSidebar}>
<ha-button @click=${this._customizeSidebar}>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</mwc-button>
</ha-button>
</ha-settings-row>
${this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
@@ -225,6 +268,39 @@ class HaProfileSectionGeneral extends LitElement {
});
}
private async _toggleBrowserTheme(ev: Event) {
const switchElement = ev.target as HaSwitch;
const enabled = switchElement.checked;
if (!enabled) {
if (!this.hass.browserThemeEnabled && this._browserThemeActivated) {
// no changed have made, disable without confirmation
this._browserThemeActivated = false;
} else {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.profile.themes.device.delete_header"
),
text: this.hass.localize(
"ui.panel.profile.themes.device.delete_description"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (confirm) {
this._browserThemeActivated = false;
fireEvent(this, "resetBrowserTheme");
} else {
// revert switch
switchElement.click();
}
}
} else {
this._browserThemeActivated = true;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -251,6 +327,14 @@ class HaProfileSectionGeneral extends LitElement {
text-align: center;
color: var(--secondary-text-color);
}
ha-expansion-panel {
margin: 0 8px 8px;
}
.device-theme {
display: block;
padding-bottom: 16px;
}
`,
];
}
+111 -14
View File
@@ -3,18 +3,37 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import {
fetchSelectedTheme,
saveSelectedTheme,
SELECTED_THEME_KEY,
subscribeSelectedTheme,
subscribeThemes,
} from "../data/ws-themes";
import type { Constructor, HomeAssistant, ThemeSettings } from "../types";
import { clearStateKey, storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
export type StorageLocation = "user" | "browser";
interface SetThemeSettings {
settings: ThemeSettings;
storageLocation: StorageLocation;
saveHass: boolean;
}
declare global {
// for add event listener
interface HTMLElementEventMap {
settheme: HASSDomEvent<Partial<HomeAssistant["selectedTheme"]>>;
settheme: HASSDomEvent<SetThemeSettings>;
}
interface HASSDomEvents {
settheme: Partial<HomeAssistant["selectedTheme"]>;
settheme: SetThemeSettings;
resetBrowserTheme: undefined;
}
interface FrontendUserData {
selectedTheme?: ThemeSettings;
}
}
@@ -27,16 +46,38 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.addEventListener("settheme", (ev) => {
this._updateHass({
selectedTheme: {
...this.hass!.selectedTheme!,
...ev.detail,
},
});
this._applyTheme(mql.matches);
storeState(this.hass!);
if (ev.detail.saveHass) {
this._updateHass({
selectedTheme: ev.detail.settings,
browserThemeEnabled: ev.detail.storageLocation === "browser",
});
this._animateApplyTheme(mql.matches);
}
if (ev.detail.storageLocation === "browser") {
storeState(this.hass!);
} else {
if (ev.detail.saveHass) {
clearStateKey(SELECTED_THEME_KEY);
}
saveSelectedTheme(this.hass!, ev.detail.settings);
}
});
mql.addListener((ev) => this._applyTheme(ev.matches));
this.addEventListener("resetBrowserTheme", async () => {
clearStateKey(SELECTED_THEME_KEY);
const selectedTheme = await fetchSelectedTheme(this.hass!);
this._updateHass({
selectedTheme,
browserThemeEnabled: false,
});
this._animateApplyTheme(mql.matches);
});
mql.addEventListener("change", (ev) =>
this._animateApplyTheme(ev.matches)
);
if (!this._themeApplied && mql.matches) {
applyThemesOnElement(
document.documentElement,
@@ -63,6 +104,62 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
invalidateThemeCache();
this._applyTheme(mql.matches);
});
subscribeSelectedTheme(
this.hass!,
(selectedTheme?: ThemeSettings | null) => {
if (
!window.localStorage.getItem(SELECTED_THEME_KEY) &&
selectedTheme
) {
this._themeApplied = true;
this._updateHass({
selectedTheme,
browserThemeEnabled: false,
});
this._applyTheme(mql.matches);
}
}
);
}
private async _animateApplyTheme(darkPreferred: boolean) {
if (!this.hass) {
return;
}
if (!document.startViewTransition || !document.documentElement.animate) {
this._applyTheme(darkPreferred);
} else {
await document.startViewTransition(() => {
this._applyTheme(darkPreferred);
}).ready;
const { top, left, width, height } =
document.documentElement.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(
Math.max(left, right),
Math.max(top, bottom)
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "linear(0, 0.1, 1)",
pseudoElement: "::view-transition-new(root)",
}
);
}
}
private _applyTheme(darkPreferred: boolean) {
+63 -5
View File
@@ -7575,7 +7575,16 @@
"primary_color": "Primary color",
"accent_color": "Accent color",
"reset": "Reset",
"use_default": "Use default theme"
"use_default": "Use default theme",
"device": {
"browser_header": "Browser theme",
"mobile_app_header": "Mobile app theme",
"custom_theme": "Custom theme",
"description": "Overwrite user theme with custom device settings",
"delete_header": "Delete device theme",
"delete_description": "Are you sure you want to delete the device specific theme?",
"user_theme_info": "Device theme is active. You won't see changes on user theme settings."
}
},
"dashboard": {
"header": "Dashboard",
@@ -8193,9 +8202,53 @@
},
"restore": {
"header": "Restore a backup",
"upload_backup": "[%key:ui::panel::config::backup::dialogs::upload::title%]",
"unsupported": {
"title": "[%key:ui::panel::config::backup::dialogs::upload::unsupported::title%]",
"text": "[%key:ui::panel::config::backup::dialogs::upload::unsupported::text%]"
},
"ok": "[%key:ui::common::ok%]",
"upload_input_label": "[%key:ui::panel::config::backup::dialogs::upload::input_label%]",
"upload_supports_tar": "[%key:ui::panel::config::backup::dialogs::upload::supports_tar%]",
"upload_secondary": "[%key:ui::components::file-upload::secondary%]",
"delete": "[%key:ui::common::delete%]",
"uploading": "[%key:ui::components::file-upload::uploading%]",
"details": {
"home_assistant_missing": "This backup does not include your Home Assistant configuration, you cannot use it to restore your instance.",
"addons_unsupported": "This backup includes add-ons and folders, which are not supported in this installation method of Home Assistant. You can still restore Home Assistant, but the unsupported files will not be restored.",
"summary": {
"title": "[%key:ui::panel::config::backup::details::summary::title%]",
"size": "[%key:ui::panel::config::backup::details::summary::size%]",
"created": "[%key:ui::panel::config::backup::details::summary::created%]",
"upload_another": "Upload another",
"home": "Home"
},
"restore": {
"title": "[%key:ui::panel::config::backup::details::restore::title%]",
"action": "[%key:ui::panel::config::backup::details::restore::action%]",
"encryption": {
"title": "[%key:ui::panel::config::backup::dialogs::restore::encryption::different_key%]",
"incorrect_key": "[%key:ui::panel::config::backup::dialogs::restore::encryption::incorrect_key%]",
"input_label": "[%key:ui::panel::config::backup::dialogs::restore::encryption::input_label%]"
}
}
},
"data_picker": {
"settings": "[%key:ui::panel::config::backup::data_picker::settings%]",
"settings_and_history": "[%key:ui::panel::config::backup::data_picker::settings_and_history%]",
"media": "[%key:ui::panel::config::backup::data_picker::media%]",
"share_folder": "[%key:ui::panel::config::backup::data_picker::share_folder%]",
"local_addons": "[%key:ui::panel::config::backup::data_picker::local_addons%]",
"addons": "[%key:ui::panel::config::backup::data_picker::addons%]",
"ssl": "[%key:ui::panel::config::backup::data_picker::ssl%]"
},
"restore_no_home_assistant": "[%key:supervisor::backup::restore_no_home_assistant%]",
"in_progress": "Restore in progress",
"in_progress_description": "The restore process is running in the background. Home Assistant will automatically start again once the restore is complete. Please be patient, this can take a while. Do not close or refresh this page.",
"failed": "Restore failed",
"upload_backup": "[%key:supervisor::backup::upload_backup%]",
"failed_status_description": "The backup could not be restored. Please try again.",
"failed_description": "The last restore attempt failed. Please try it again.",
"failed_wrong_password_description": "The last restore attempt failed, because the encryption key was incorrect. Please try it again.",
"upload_supports": "Supports .TAR files",
"upload_drop": "[%key:ui::components::file-upload::secondary%]",
"show_log": "Show full log",
@@ -8203,7 +8256,6 @@
"full_backup": "[%key:supervisor::backup::full_backup%]",
"partial_backup": "[%key:supervisor::backup::partial_backup%]",
"name": "[%key:supervisor::backup::name%]",
"type": "[%key:supervisor::backup::type%]",
"select_type": "[%key:supervisor::backup::select_type%]",
"folders": "[%key:supervisor::backup::folders%]",
"addons": "[%key:supervisor::backup::addons%]",
@@ -8218,10 +8270,16 @@
"close": "[%key:ui::common::close%]",
"cancel": "[%key:ui::common::cancel%]",
"retry": "Retry",
"back": "[%key:ui::common::back%]",
"restore_start_failed": "[%key:supervisor::backup::restore_start_failed%]",
"no_backup_found": "[%key:supervisor::backup::no_backup_found%]",
"restore_no_home_assistant": "[%key:supervisor::backup::restore_no_home_assistant%]",
"unnamed_backup": "[%key:supervisor::backup::unnamed_backup%]"
"unnamed_backup": "[%key:supervisor::backup::unnamed_backup%]",
"cancel_restore": {
"title": "Cancel restore process?",
"text": "Are you sure you want to cancel the restore process and return to the onboarding?",
"yes": "[%key:ui::common::yes%]",
"no": "[%key:ui::common::no%]"
}
}
},
"custom": {
+1
View File
@@ -223,6 +223,7 @@ export interface HomeAssistant {
config: HassConfig;
themes: Themes;
selectedTheme: ThemeSettings | null;
browserThemeEnabled?: boolean;
panels: Panels;
panelUrl: string;
// i18n
+23 -5
View File
@@ -1,8 +1,9 @@
import { SELECTED_THEME_KEY } from "../data/ws-themes";
import type { HomeAssistant } from "../types";
const STORED_STATE = [
"dockedSidebar",
"selectedTheme",
SELECTED_THEME_KEY,
"selectedLanguage",
"vibrate",
"debugConnection",
@@ -11,9 +12,17 @@ const STORED_STATE = [
"defaultPanel",
];
const CLEARABLE_STATE = [SELECTED_THEME_KEY];
export function storeState(hass: HomeAssistant) {
try {
STORED_STATE.forEach((key) => {
const states = [...STORED_STATE];
if (!hass.browserThemeEnabled) {
states.splice(states.indexOf(SELECTED_THEME_KEY), 1);
}
states.forEach((key) => {
const value = hass[key];
window.localStorage.setItem(
key,
@@ -32,15 +41,18 @@ export function storeState(hass: HomeAssistant) {
}
export function getState() {
const state = {};
const state: Partial<HomeAssistant> = {};
STORED_STATE.forEach((key) => {
const storageItem = window.localStorage.getItem(key);
if (storageItem !== null) {
let value = JSON.parse(storageItem);
// selectedTheme went from string to object on 20200718
if (key === "selectedTheme" && typeof value === "string") {
value = { theme: value };
if (key === SELECTED_THEME_KEY) {
if (typeof value === "string") {
value = { theme: value };
}
state.browserThemeEnabled = true;
}
// dockedSidebar went from boolean to enum on 20190720
if (key === "dockedSidebar" && typeof value === "boolean") {
@@ -55,3 +67,9 @@ export function getState() {
export function clearState() {
window.localStorage.clear();
}
export function clearStateKey(key: string) {
if (CLEARABLE_STATE.includes(key)) {
window.localStorage.removeItem(key);
}
}
@@ -4,6 +4,7 @@ import {
formatDateTime,
formatDateTimeWithSeconds,
formatDateTimeNumeric,
formatDateTimeWithBrowserDefaults,
} from "../../../src/common/datetime/format_date_time";
import {
NumberFormat,
@@ -49,6 +50,19 @@ describe("formatDateTime", () => {
"November 18, 2017 at 23:12"
);
});
it("Formats date times without optional params", () => {
assert.strictEqual(
formatDateTimeWithBrowserDefaults(dateObj),
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(dateObj)
);
});
});
describe("formatDateTimeWithSeconds", () => {
+29 -12
View File
@@ -6,6 +6,7 @@ describe("ha-pref-storage", () => {
const mockHass = {
dockedSidebar: "auto",
selectedTheme: { theme: "default" },
vibrate: "false",
unknownKey: "unknownValue",
};
@@ -24,15 +25,11 @@ describe("ha-pref-storage", () => {
window.localStorage.setItem = vi.fn();
storeState(mockHass as unknown as HomeAssistant);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(8);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(7);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"dockedSidebar",
JSON.stringify("auto")
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedTheme",
JSON.stringify({ theme: "default" })
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedLanguage",
JSON.stringify(null)
@@ -41,13 +38,19 @@ describe("ha-pref-storage", () => {
"unknownKey",
JSON.stringify("unknownValue")
);
// browserThemeEnabled is not set in mockHass, so selectedTheme should not be stored
expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
"selectedTheme",
JSON.stringify({ theme: "default" })
);
});
test("storeState fails", async () => {
const { storeState } = await import("../../src/util/ha-pref-storage");
window.localStorage.setItem = vi.fn((key) => {
if (key === "selectedTheme") {
if (key === "selectedLanguage") {
throw new Error("Test error");
}
});
@@ -65,13 +68,13 @@ describe("ha-pref-storage", () => {
JSON.stringify("auto")
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedTheme",
JSON.stringify({ theme: "default" })
);
expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
"selectedLanguage",
JSON.stringify(null)
);
expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
"vibrate",
JSON.stringify("false")
);
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledOnce();
// eslint-disable-next-line no-console
@@ -87,7 +90,7 @@ describe("ha-pref-storage", () => {
window.localStorage.setItem("selectedTheme", JSON.stringify("test"));
window.localStorage.setItem("dockedSidebar", JSON.stringify(true));
window.localStorage.setItem("selectedLanguage", JSON.stringify("german"));
window.localStorage.setItem("selectedLanguage", JSON.stringify("de"));
// should not be in state
window.localStorage.setItem("testEntry", JSON.stringify("this is a test"));
@@ -96,7 +99,21 @@ describe("ha-pref-storage", () => {
expect(state).toEqual({
dockedSidebar: "docked",
selectedTheme: { theme: "test" },
selectedLanguage: "german",
browserThemeEnabled: true,
selectedLanguage: "de",
});
});
test("getState without theme", async () => {
const { getState } = await import("../../src/util/ha-pref-storage");
window.localStorage.setItem("dockedSidebar", JSON.stringify(true));
window.localStorage.setItem("selectedLanguage", JSON.stringify("de"));
const state = getState();
expect(state).toEqual({
dockedSidebar: "docked",
selectedLanguage: "de",
});
});
+105 -96
View File
@@ -1616,6 +1616,15 @@ __metadata:
languageName: node
linkType: hard
"@eslint/core@npm:^0.11.0":
version: 0.11.0
resolution: "@eslint/core@npm:0.11.0"
dependencies:
"@types/json-schema": "npm:^7.0.15"
checksum: 10/0a055edf516529d19eea2196e3149eefb4c6f0bb30145b08cdb92ec114735630bd27585f76466c7cb6fa1073617d1f5e49b36ad63d4d45e55defd94a3268256d
languageName: node
linkType: hard
"@eslint/eslintrc@npm:^3.2.0":
version: 3.2.0
resolution: "@eslint/eslintrc@npm:3.2.0"
@@ -1633,10 +1642,10 @@ __metadata:
languageName: node
linkType: hard
"@eslint/js@npm:9.19.0":
version: 9.19.0
resolution: "@eslint/js@npm:9.19.0"
checksum: 10/d8133a83330676d5f0827713af2e9bbf35530631a93520fb59ead6b827a325c54fdd7ad99f2158f895fb393c47bbc55dfdaa945998a647f3b9230f1d5324a626
"@eslint/js@npm:9.20.0":
version: 9.20.0
resolution: "@eslint/js@npm:9.20.0"
checksum: 10/2304cd725700046ba611f06bf9cd0941db7e02e0d602d8fd9e4734c13067699954597b9a3a2b048ce02eb0550373669d2ab7be6edaf6abf7b67eb19d1276b57b
languageName: node
linkType: hard
@@ -5105,131 +5114,131 @@ __metadata:
languageName: node
linkType: hard
"@vaadin/a11y-base@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/a11y-base@npm:24.6.3"
"@vaadin/a11y-base@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/a11y-base@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/1500447c500946bd4e3423b2117e37fb317fc348d317aee8d786260e1435b9252f5bce90b55a5b1ca8734d987a90ca55124922a5f2ae52caf809f92cf05df4d5
checksum: 10/5d0a9261f329fda7b28c5487999774fedd0e2edb339b6f7eecf6749acbddb137569bf730e0701812c52d6e23fe65101d393c0c860931257f5cdfbbedd537c1cd
languageName: node
linkType: hard
"@vaadin/combo-box@npm:24.6.3":
version: 24.6.3
resolution: "@vaadin/combo-box@npm:24.6.3"
"@vaadin/combo-box@npm:24.6.4":
version: 24.6.4
resolution: "@vaadin/combo-box@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/a11y-base": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/field-base": "npm:~24.6.3"
"@vaadin/input-container": "npm:~24.6.3"
"@vaadin/item": "npm:~24.6.3"
"@vaadin/lit-renderer": "npm:~24.6.3"
"@vaadin/overlay": "npm:~24.6.3"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.3"
"@vaadin/vaadin-material-styles": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
"@vaadin/a11y-base": "npm:~24.6.4"
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/field-base": "npm:~24.6.4"
"@vaadin/input-container": "npm:~24.6.4"
"@vaadin/item": "npm:~24.6.4"
"@vaadin/lit-renderer": "npm:~24.6.4"
"@vaadin/overlay": "npm:~24.6.4"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.4"
"@vaadin/vaadin-material-styles": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/54dbcde231f262c68abf6756ad7ceb89ed3c424b08f9995cc321920d93b8a8c3be69d0fc0d67262bd81714ea491eeb6ac60400950ea2cf6ea90bd8271a88548b
checksum: 10/9c014ab3647a1c887e7cf2f707681c453bedcfc619580900341730b1de2588ecc139ac6dd79b614a13bd1a9fa07d8499cf8bddddffd7d4eac9f30094dca152a1
languageName: node
linkType: hard
"@vaadin/component-base@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/component-base@npm:24.6.3"
"@vaadin/component-base@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/component-base@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/vaadin-development-mode-detector": "npm:^2.0.0"
"@vaadin/vaadin-usage-statistics": "npm:^2.1.0"
lit: "npm:^3.0.0"
checksum: 10/5fb9032e37448257d9dad897f45bc80230e736b0c06fe7ac1f73db831dbaa0da29589b81cc3bfb6be395de49e2a99865110f99562bff0da35ac6149e63e6549d
checksum: 10/e3fe5e74b06fa9496b5a448a1328c03385ac078b25125a610fc330a05bf26b82234a0264e4d4c9b2399e207b14c6a6ba65bd908afa05073053059b4a01115995
languageName: node
linkType: hard
"@vaadin/field-base@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/field-base@npm:24.6.3"
"@vaadin/field-base@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/field-base@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/a11y-base": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/a11y-base": "npm:~24.6.4"
"@vaadin/component-base": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/19261611de19b85eb87047053353de85c41690066474d6e4476af4f6ae5056ac187fc94b0d9bb6c388d63d9030362aafc9cd46b3eb9b7d3f563f5ca2d6ae920b
checksum: 10/ede2a08cf332e639117835dfcb2ee274b9df8c5f9af0a1611638719090cdeb29f925d11244183219c346d3bf4b6929042f8008087afe38c26ea6c6ed7d849491
languageName: node
linkType: hard
"@vaadin/icon@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/icon@npm:24.6.3"
"@vaadin/icon@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/icon@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/6b4e3e051296c2abcef01c0bbd9581058a4b530272668d3db3e3a5e97f60fbb3ff608f64ea1679550f162d5b3682b62f63e98fb232a12dadd2e038be911edb80
checksum: 10/bf611bbb43801cba77d6cebf2eb9c7de9939b238a111f1364f183d0f96775639b4312bf2e7d866684b829e028af7dca4d55b1277b780267e104b424101270daa
languageName: node
linkType: hard
"@vaadin/input-container@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/input-container@npm:24.6.3"
"@vaadin/input-container@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/input-container@npm:24.6.4"
dependencies:
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.3"
"@vaadin/vaadin-material-styles": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.4"
"@vaadin/vaadin-material-styles": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/ebe5e58bd7355aa13ea5d2ab249125a8cbfe44f42bc882facdcce80f7e3b528fbfbb54c9dda04c27853d0809dcfc1cc5359498d2724608d684a2a4e556f5697e
checksum: 10/7805a861e9f0a4644586e3b106f8a71b9c888194a212453b4856465442365c880a1655f8dbbd060d9dd5705f59ca0011dda7efdf93b4d47508d58ebcea42e4f4
languageName: node
linkType: hard
"@vaadin/item@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/item@npm:24.6.3"
"@vaadin/item@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/item@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/a11y-base": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.3"
"@vaadin/vaadin-material-styles": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
"@vaadin/a11y-base": "npm:~24.6.4"
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.4"
"@vaadin/vaadin-material-styles": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/d8d8968d3378e439fd966e210ac2445da24a807c7ff795f09576ff2681d236309331d814b12bd3d7c7ced3bfdf68dfefe67edd2d3019282deaa0e00f5612c185
checksum: 10/d44f766a92aeca1a8d96dccb40fd432846c00feeb25787962a5726a5c4c44937121537e6d6b5b1cb69baba4b905faefeec2a34e5f7bd4d405508f955398d61de
languageName: node
linkType: hard
"@vaadin/lit-renderer@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/lit-renderer@npm:24.6.3"
"@vaadin/lit-renderer@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/lit-renderer@npm:24.6.4"
dependencies:
lit: "npm:^3.0.0"
checksum: 10/21dd3cadc33a696b8f8661d2a458c6e92918a4f3c0565bc2d5a9026ed63688cd59f5404fc051c3a558181c89ea381aaa80142d0df58b221c256a0d3ff5d4437e
checksum: 10/ab99999f9e7d140abc62b3182ca5c4905b8ba794be84d9842fb4238d67400620dbe14e924f304cb2493e3bf7435f70fc9719b8f19c6161b32a59ead5b8bf7aa9
languageName: node
linkType: hard
"@vaadin/overlay@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/overlay@npm:24.6.3"
"@vaadin/overlay@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/overlay@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/a11y-base": "npm:~24.6.3"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.3"
"@vaadin/vaadin-material-styles": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
"@vaadin/a11y-base": "npm:~24.6.4"
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/vaadin-lumo-styles": "npm:~24.6.4"
"@vaadin/vaadin-material-styles": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
lit: "npm:^3.0.0"
checksum: 10/e36eeeb5b66ea4a9104fb6254edfe5f5b47743277e5c2926e0c41cdbe77580e7c5de8abf6002be40a1a00211f4484d41184f98621aa384a880035bd57bd4b9fb
checksum: 10/c237994571696c3fb5d2ac1661befabc301a5020cbf77302eb2c00c4b1e05483c96bd1d4f202efe2a25adab47889d5e851a80373787b9904987f0edd4c045fc1
languageName: node
linkType: hard
@@ -5240,36 +5249,36 @@ __metadata:
languageName: node
linkType: hard
"@vaadin/vaadin-lumo-styles@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/vaadin-lumo-styles@npm:24.6.3"
"@vaadin/vaadin-lumo-styles@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/vaadin-lumo-styles@npm:24.6.4"
dependencies:
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/icon": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
checksum: 10/825587a7996aea3ea48dfdb1e09ac9c90df8b68c8a3204beed0e7828705ce128cf0124e21622e00400b6746ed7997c225b533ee05c3aa2b7a1962b97050efe65
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/icon": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
checksum: 10/35ae45a433b864236f1bdec27111c0b25feffa16f199205c896add86d4ef1d53487cab1ef13573d60365f5b3d6cf2e0b7bcd56787a4fbe7de53d5fcfdc74e46f
languageName: node
linkType: hard
"@vaadin/vaadin-material-styles@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/vaadin-material-styles@npm:24.6.3"
"@vaadin/vaadin-material-styles@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/vaadin-material-styles@npm:24.6.4"
dependencies:
"@polymer/polymer": "npm:^3.0.0"
"@vaadin/component-base": "npm:~24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.3"
checksum: 10/d274ac0b23e6aed125c5a3ddaf4860537abf63db9969072f184dcb7cdaff6110e013f19afb8cfc52f379595b527977f0159b268f9936bc0e8b8cb6a03456fec7
"@vaadin/component-base": "npm:~24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:~24.6.4"
checksum: 10/6ce2c3017819ca03ade65107f664b3e305b4642c151f7012f769aefef50c467d5ae7f2ff92fb3d7ced9981bf8d881cf9b0c30aef561263b0348b7f215505ecb6
languageName: node
linkType: hard
"@vaadin/vaadin-themable-mixin@npm:24.6.3, @vaadin/vaadin-themable-mixin@npm:~24.6.3":
version: 24.6.3
resolution: "@vaadin/vaadin-themable-mixin@npm:24.6.3"
"@vaadin/vaadin-themable-mixin@npm:24.6.4, @vaadin/vaadin-themable-mixin@npm:~24.6.4":
version: 24.6.4
resolution: "@vaadin/vaadin-themable-mixin@npm:24.6.4"
dependencies:
"@open-wc/dedupe-mixin": "npm:^1.3.0"
lit: "npm:^3.0.0"
checksum: 10/bad0a95ab223a864251e055cff31c1129a77e8e60d868979212679d16b2f62caea63c8627e80059baf0c6e57468bdc5dbef2e2ac3065146ec2c90edb6c9fb8b6
checksum: 10/05a8bee2b254dd5c30829ebfc17a04f86e53b0fac163b7e2b53a677b8487cac859afa905a1624dc7a25e15f88bb81572de601aaa4b921fc4b99dd27df7d4a249
languageName: node
linkType: hard
@@ -8077,16 +8086,16 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:9.19.0":
version: 9.19.0
resolution: "eslint@npm:9.19.0"
"eslint@npm:9.20.0":
version: 9.20.0
resolution: "eslint@npm:9.20.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.2.0"
"@eslint-community/regexpp": "npm:^4.12.1"
"@eslint/config-array": "npm:^0.19.0"
"@eslint/core": "npm:^0.10.0"
"@eslint/core": "npm:^0.11.0"
"@eslint/eslintrc": "npm:^3.2.0"
"@eslint/js": "npm:9.19.0"
"@eslint/js": "npm:9.20.0"
"@eslint/plugin-kit": "npm:^0.2.5"
"@humanfs/node": "npm:^0.16.6"
"@humanwhocodes/module-importer": "npm:^1.0.1"
@@ -8122,7 +8131,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/850d19fd6a34702d1e3d9bdad6aef84a20a5c2de006a8fa6380843384b13944b180232ddd74b8725ffcdf8f296399037f0e8eb4783d5f7393f13c059112b843d
checksum: 10/6a7686fbda51a0dfe812ef39c251e2c2b0e828196059732d280be6aa375a1f4b5b0b34fed98de30ee363c3bf1b7e8d2a4eccf20b4112f3ad6709f067404a815c
languageName: node
linkType: hard
@@ -9359,8 +9368,8 @@ __metadata:
"@types/webspeechapi": "npm:0.0.29"
"@typescript-eslint/eslint-plugin": "npm:8.23.0"
"@typescript-eslint/parser": "npm:8.23.0"
"@vaadin/combo-box": "npm:24.6.3"
"@vaadin/vaadin-themable-mixin": "npm:24.6.3"
"@vaadin/combo-box": "npm:24.6.4"
"@vaadin/vaadin-themable-mixin": "npm:24.6.4"
"@vibrant/color": "npm:4.0.0"
"@vitest/coverage-v8": "npm:3.0.5"
"@vue/web-component-wrapper": "npm:1.3.0"
@@ -9383,7 +9392,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:5.6.0"
element-internals-polyfill: "npm:1.3.13"
eslint: "npm:9.19.0"
eslint: "npm:9.20.0"
eslint-config-airbnb-base: "npm:15.0.0"
eslint-config-prettier: "npm:10.0.1"
eslint-import-resolver-webpack: "npm:0.13.10"