Compare commits

...

8 Commits

Author SHA1 Message Date
Zack Arnett
c22d8ee669 fix rebasing 2021-10-23 15:13:56 -05:00
Zack Arnett
183dc3cfbe Stages 2021-10-23 14:57:53 -05:00
Zack Arnett
e3bbe0e93b Add Trend to Entity and Sensor Card 2021-10-23 14:54:01 -05:00
Bram Kragten
05711b4636 Catch error if input_datetime state is incorrect (#10237) 2021-10-22 09:46:58 -07:00
Kyle Niewiada
2c2809573f Add to do list support to markdown (#10129) 2021-10-22 08:49:00 -07:00
Philip Allgaier
bbbeafcc92 Restore proper state badge image behavior (#10369) 2021-10-22 14:09:23 +02:00
Bram Kragten
95c6adc739 Convert cloud account config to Lit (#10350) 2021-10-21 09:49:55 -07:00
Philip Allgaier
7c2e0aea92 Correct automation editor event action translation (#10355) 2021-10-21 15:14:26 +02:00
20 changed files with 1285 additions and 1001 deletions

View File

@@ -78,7 +78,6 @@
"@polymer/paper-input": "^3.2.1",
"@polymer/paper-item": "^3.0.1",
"@polymer/paper-listbox": "^3.0.1",
"@polymer/paper-ripple": "^3.0.2",
"@polymer/paper-slider": "^3.0.1",
"@polymer/paper-styles": "^3.0.1",
"@polymer/paper-tabs": "^3.1.0",

View File

@@ -39,7 +39,7 @@ export const computeStateDisplay = (
const domain = computeStateDomain(stateObj);
if (domain === "input_datetime") {
if (state) {
if (state !== undefined) {
// If trying to display an explicit state, need to parse the explict state to `Date` then format.
// Attributes aren't available, we have to use `state`.
try {
@@ -63,7 +63,7 @@ export const computeStateDisplay = (
}
}
return state;
} catch {
} catch (_e) {
// Formatting methods may throw error if date parsing doesn't go well,
// just return the state string in that case.
return state;
@@ -71,7 +71,17 @@ export const computeStateDisplay = (
} else {
// If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format.
let date: Date;
if (!stateObj.attributes.has_time) {
if (stateObj.attributes.has_date && stateObj.attributes.has_time) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatDateTime(date, locale);
}
if (stateObj.attributes.has_date) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
@@ -79,20 +89,12 @@ export const computeStateDisplay = (
);
return formatDate(date, locale);
}
if (!stateObj.attributes.has_date) {
if (stateObj.attributes.has_time) {
date = new Date();
date.setHours(stateObj.attributes.hour, stateObj.attributes.minute);
return formatTime(date, locale);
}
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatDateTime(date, locale);
return stateObj.state;
}
}

View File

@@ -77,7 +77,7 @@ export class HaStateLabelBadge extends LitElement {
const domain = computeStateDomain(entityState);
const showIcon = this.icon || this._computeShowIcon(domain, entityState);
const image = showIcon
const image = this.icon
? ""
: this.image
? this.image

View File

@@ -62,6 +62,8 @@ export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
export interface SubscriptionInfo {
human_description: string;
provider: string;
plan_renewal_date?: number;
}
export interface CloudWebhook {
@@ -76,6 +78,39 @@ export interface ThingTalkConversion {
placeholders: PlaceholderContainer;
}
export const cloudLogin = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/login", {
email,
password,
});
export const cloudLogout = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/logout");
export const cloudForgotPassword = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/forgot_password", {
email,
});
export const cloudRegister = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register", {
email,
password,
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
});
export const fetchCloudStatus = (hass: HomeAssistant) =>
hass.callWS<CloudStatus>({ type: "cloud/status" });

View File

@@ -1,246 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-call-api-button";
import "../../../../components/ha-card";
import { fetchCloudSubscriptionInfo } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
import "../../ha-config-section";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
.content {
padding-bottom: 24px;
}
.account-row {
display: flex;
padding: 0 16px;
}
mwc-button {
align-self: center;
}
.soon {
font-style: italic;
margin-top: 24px;
text-align: center;
}
.nowrap {
white-space: nowrap;
}
.wrap {
white-space: normal;
}
.status {
text-transform: capitalize;
padding: 16px;
}
a {
color: var(--primary-color);
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.account.thank_you_note')]]
</p>
</div>
<ha-card
header="[[localize('ui.panel.config.cloud.account.nabu_casa_account')]]"
>
<div class="account-row">
<paper-item-body two-line="">
[[cloudStatus.email]]
<div secondary class="wrap">
[[_formatSubscription(_subscription)]]
</div>
</paper-item-body>
</div>
<div class="account-row">
<paper-item-body
>[[localize('ui.panel.config.cloud.account.connection_status')]]</paper-item-body
>
<div class="status">
[[_computeConnectionStatus(cloudStatus.cloud)]]
</div>
</div>
<div class="card-actions">
<a
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button
>[[localize('ui.panel.config.cloud.account.manage_account')]]</mwc-button
>
</a>
<mwc-button style="float: right" on-click="handleLogout"
>[[localize('ui.panel.config.cloud.account.sign_out')]]</mwc-button
>
</div>
</ha-card>
</ha-config-section>
<ha-config-section is-wide="[[isWide]]">
<span slot="header"
>[[localize('ui.panel.config.cloud.account.integrations')]]</span
>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.account.integrations_introduction')]]
</p>
<p>
[[localize('ui.panel.config.cloud.account.integrations_introduction2')]]
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
[[localize('ui.panel.config.cloud.account.integrations_link_all_features')]]</a
>.
</p>
</div>
<cloud-remote-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-remote-pref>
<cloud-tts-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-tts-pref>
<cloud-alexa-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-alexa-pref>
<cloud-google-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-google-pref>
<cloud-webhooks
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-webhooks>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
cloudStatus: Object,
_subscription: {
type: Object,
value: null,
},
_rtlDirection: {
type: Boolean,
computed: "_computeRTLDirection(hass)",
},
};
}
ready() {
super.ready();
this._fetchSubscriptionInfo();
}
_computeConnectionStatus(status) {
return status === "connected"
? this.hass.localize("ui.panel.config.cloud.account.connected")
: status === "disconnected"
? this.hass.localize("ui.panel.config.cloud.account.not_connected")
: this.hass.localize("ui.panel.config.cloud.account.connecting");
}
async _fetchSubscriptionInfo() {
this._subscription = await fetchCloudSubscriptionInfo(this.hass);
if (
this._subscription.provider &&
this.cloudStatus &&
this.cloudStatus.cloud !== "connected"
) {
this.fire("ha-refresh-cloud-status");
}
}
handleLogout() {
this.hass
.callApi("post", "cloud/logout")
.then(() => this.fire("ha-refresh-cloud-status"));
}
_formatSubscription(subInfo) {
if (subInfo === null) {
return this.hass.localize(
"ui.panel.config.cloud.account.fetching_subscription"
);
}
let description = subInfo.human_description;
if (subInfo.plan_renewal_date) {
description = description.replace(
"{periodEnd}",
formatDateTime(
new Date(subInfo.plan_renewal_date * 1000),
this.hass.locale
)
);
}
return description;
}
_computeRTLDirection(hass) {
return computeRTLDirection(hass);
}
}
customElements.define("cloud-account", CloudAccount);

View File

@@ -0,0 +1,268 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item-body";
import { LitElement, css, html, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-call-api-button";
import "../../../../components/ha-card";
import {
cloudLogout,
CloudStatusLoggedIn,
fetchCloudSubscriptionInfo,
SubscriptionInfo,
} from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
@customElement("cloud-account")
export class CloudAccount extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription?: SubscriptionInfo;
@state() private _rtlDirection: "rtl" | "ltr" = "rtl";
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.thank_you_note"
)}
</p>
</div>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.account.nabu_casa_account"
)}
>
<div class="account-row">
<paper-item-body two-line>
${this.cloudStatus.email}
<div secondary class="wrap">
${this._subscription
? this._subscription.human_description.replace(
"{periodEnd}",
this._subscription.plan_renewal_date
? formatDateTime(
new Date(
this._subscription.plan_renewal_date * 1000
),
this.hass.locale
)
: ""
)
: this.hass.localize(
"ui.panel.config.cloud.account.fetching_subscription"
)}
</div>
</paper-item-body>
</div>
<div class="account-row">
<paper-item-body>
${this.hass.localize(
"ui.panel.config.cloud.account.connection_status"
)}
</paper-item-body>
<div class="status">
${this.cloudStatus.cloud === "connected"
? this.hass.localize(
"ui.panel.config.cloud.account.connected"
)
: this.cloudStatus.cloud === "disconnected"
? this.hass.localize(
"ui.panel.config.cloud.account.not_connected"
)
: this.hass.localize(
"ui.panel.config.cloud.account.connecting"
)}
</div>
</div>
<div class="card-actions">
<a
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button>
${this.hass.localize(
"ui.panel.config.cloud.account.manage_account"
)}
</mwc-button>
</a>
<mwc-button @click=${this._handleLogout}
>${this.hass.localize(
"ui.panel.config.cloud.account.sign_out"
)}</mwc-button
>
</div>
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.account.integrations"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_link_all_features"
)}</a
>.
</p>
</div>
<cloud-remote-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-remote-pref>
<cloud-tts-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-tts-pref>
<cloud-alexa-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-alexa-pref>
<cloud-google-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-google-pref>
<cloud-webhooks
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-webhooks>
</ha-config-section>
</div>
</hass-subpage>
`;
}
firstUpdated() {
this._fetchSubscriptionInfo();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass.locale) {
this._rtlDirection = computeRTLDirection(this.hass);
}
}
}
private async _fetchSubscriptionInfo() {
this._subscription = await fetchCloudSubscriptionInfo(this.hass);
if (
this._subscription.provider &&
this.cloudStatus &&
this.cloudStatus.cloud !== "connected"
) {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _handleLogout() {
await cloudLogout(this.hass);
fireEvent(this, "ha-refresh-cloud-status");
}
_computeRTLDirection(hass) {
return computeRTLDirection(hass);
}
static get styles() {
return css`
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
.content {
padding-bottom: 24px;
}
.account-row {
display: flex;
padding: 0 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
.card-actions a {
text-decoration: none;
}
mwc-button {
align-self: center;
}
.wrap {
white-space: normal;
}
.status {
text-transform: capitalize;
padding: 16px;
}
a {
color: var(--primary-color);
}
`;
}
}
customElements.define("cloud-account", CloudAccount);
declare global {
interface HTMLElementTagNameMap {
"cloud-account": CloudAccount;
}
}

View File

@@ -1,152 +0,0 @@
import "@polymer/paper-input/paper-input";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudForgotPassword extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
.content {
padding-bottom: 24px;
}
ha-card {
max-width: 600px;
margin: 0 auto;
margin-top: 24px;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions a {
color: var(--primary-text-color);
}
[hidden] {
display: none;
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="[[localize('ui.panel.config.cloud.forgot_password.title')]]"
>
<div class="content">
<ha-card
header="[[localize('ui.panel.config.cloud.forgot_password.subtitle')]]"
>
<div class="card-content">
<p>
[[localize('ui.panel.config.cloud.forgot_password.instructions')]]
</p>
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
<paper-input
autofocus=""
id="email"
label="[[localize('ui.panel.config.cloud.forgot_password.email')]]"
value="{{email}}"
type="email"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.forgot_password.email_error_msg')]]"
></paper-input>
</div>
<div class="card-actions">
<ha-progress-button
on-click="_handleEmailPasswordReset"
progress="[[_requestInProgress]]"
>[[localize('ui.panel.config.cloud.forgot_password.send_reset_email')]]</ha-progress-button
>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
narrow: Boolean,
email: {
type: String,
notify: true,
observer: "_emailChanged",
},
_requestInProgress: {
type: Boolean,
value: false,
},
_error: {
type: String,
value: "",
},
};
}
_emailChanged() {
this._error = "";
this.$.email.invalid = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleEmailPasswordReset();
ev.preventDefault();
}
}
_handleEmailPasswordReset() {
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
}
if (this.$.email.invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/forgot_password", {
email: this.email,
})
.then(
() => {
this._requestInProgress = false;
this.fire("cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
},
(err) =>
this.setProperties({
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
})
);
}
}
customElements.define("cloud-forgot-password", CloudForgotPassword);

View File

@@ -0,0 +1,156 @@
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import { cloudForgotPassword } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
@customElement("cloud-forgot-password")
export class CloudForgotPassword extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@state() public _requestInProgress = false;
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.subtitle"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.forgot_password.instructions"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
autofocus
id="email"
label=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.email"
)}
.value=${this.email}
type="email"
required
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.email_error_msg"
)}
></mwc-textfield>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleEmailPasswordReset}
.progress=${this._requestInProgress}
>
${this.hass.localize(
"ui.panel.config.cloud.forgot_password.send_reset_email"
)}
</ha-progress-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleEmailPasswordReset();
}
}
private async _handleEmailPasswordReset() {
const emailField = this._emailField;
const email = emailField.value;
if (!emailField.reportValidity()) {
emailField.focus();
return;
}
this._requestInProgress = true;
try {
await cloudForgotPassword(this.hass, email);
// @ts-ignore
fireEvent(this, "email-changed", { value: email });
this._requestInProgress = false;
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
} catch (err: any) {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
static get styles() {
return [
haStyle,
css`
.content {
padding-bottom: 24px;
}
ha-card {
max-width: 600px;
margin: 0 auto;
margin-top: 24px;
}
h1 {
margin: 0;
}
mwc-textfield {
width: 100%;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions a {
color: var(--primary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-forgot-password": CloudForgotPassword;
}
}

View File

@@ -1,336 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-ripple/paper-ripple";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import NavigateMixin from "../../../../mixins/navigate-mixin";
import "../../../../styles/polymer-ha-style";
import "../../ha-config-section";
/*
* @appliesMixin NavigateMixin
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudLogin extends LocalizeMixin(
NavigateMixin(EventsMixin(PolymerElement))
) {
static get template() {
return html`
<style include="iron-flex ha-style">
.content {
padding-bottom: 24px;
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
[hidden] {
display: none;
}
.flash-msg {
padding-right: 44px;
}
.flash-msg ha-icon-button {
position: absolute;
top: 4px;
right: 8px;
color: var(--secondary-text-color);
}
:host([rtl]) .flash-msg ha-icon-button {
right: auto;
left: 8px;
}
.login-form {
display: flex;
flex-direction: column;
}
.pwd-forgot-link {
color: var(--secondary-text-color) !important;
text-align: right !important;
align-self: flex-end;
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>[[localize('ui.panel.config.cloud.login.introduction')]]</p>
<p>
[[localize('ui.panel.config.cloud.login.introduction2')]]
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>[[localize('ui.panel.config.cloud.login.introduction2a')]]
</p>
<p>[[localize('ui.panel.config.cloud.login.introduction3')]]</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
[[localize('ui.panel.config.cloud.login.learn_more_link')]]
</a>
</p>
</div>
<ha-card hidden$="[[!flashMessage]]">
<div class="card-content flash-msg">
[[flashMessage]]
<ha-icon-button
label="[[localize('ui.panel.config.cloud.login.dismiss')]]"
on-click="_dismissFlash"
>
<ha-icon icon="hass:close"></ha-icon>
</ha-icon-button>
<paper-ripple id="flashRipple" noink=""></paper-ripple>
</div>
</ha-card>
<ha-card
header="[[localize('ui.panel.config.cloud.login.sign_in')]]"
>
<div class="card-content login-form">
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
<paper-input
label="[[localize('ui.panel.config.cloud.login.email')]]"
id="email"
type="email"
value="{{email}}"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.login.email_error_msg')]]"
></paper-input>
<paper-input
id="password"
label="[[localize('ui.panel.config.cloud.login.password')]]"
value="{{_password}}"
type="password"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.login.password_error_msg')]]"
></paper-input>
<button
class="link pwd-forgot-link"
hidden="[[_requestInProgress]]"
on-click="_handleForgotPassword"
>
[[localize('ui.panel.config.cloud.login.forgot_password')]]
</button>
</div>
<div class="card-actions">
<ha-progress-button
on-click="_handleLogin"
progress="[[_requestInProgress]]"
>[[localize('ui.panel.config.cloud.login.sign_in')]]</ha-progress-button
>
</div>
</ha-card>
<ha-card>
<paper-item on-click="_handleRegister">
<paper-item-body two-line="">
[[localize('ui.panel.config.cloud.login.start_trial')]]
<div secondary="">
[[localize('ui.panel.config.cloud.login.trial_info')]]
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
email: {
type: String,
notify: true,
},
_password: {
type: String,
value: "",
},
_requestInProgress: {
type: Boolean,
value: false,
},
flashMessage: {
type: String,
notify: true,
},
rtl: {
type: Boolean,
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
_error: String,
};
}
static get observers() {
return ["_inputChanged(email, _password)"];
}
connectedCallback() {
super.connectedCallback();
if (this.flashMessage) {
// Wait for DOM to be drawn
requestAnimationFrame(() =>
requestAnimationFrame(() => this.$.flashRipple.simulatedRipple())
);
}
}
_inputChanged() {
this.$.email.invalid = false;
this.$.password.invalid = false;
this._error = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleLogin();
ev.preventDefault();
}
}
_handleLogin() {
let invalid = false;
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
this.$.email.focus();
invalid = true;
}
if (this._password.length < 8) {
this.$.password.invalid = true;
if (!invalid) {
invalid = true;
this.$.password.focus();
}
}
if (invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/login", {
email: this.email,
password: this._password,
})
.then(
() => {
this.fire("ha-refresh-cloud-status");
this.setProperties({
email: "",
_password: "",
});
},
(err) => {
// Do this before setProperties because changing it clears errors.
this._password = "";
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
alert(
"[[localize('ui.panel.config.cloud.login.alert_password_change_required')]]"
);
this.navigate("/config/cloud/forgot-password");
return;
}
const props = {
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
};
if (errCode === "UserNotConfirmed") {
props._error =
"[[localize('ui.panel.config.cloud.login.alert_email_confirm_necessary')]]";
}
this.setProperties(props);
this.$.email.focus();
}
);
}
_handleRegister() {
this.flashMessage = "";
this.navigate("/config/cloud/register");
}
_handleForgotPassword() {
this.flashMessage = "";
this.navigate("/config/cloud/forgot-password");
}
_dismissFlash() {
// give some time to let the ripple finish.
setTimeout(() => {
this.flashMessage = "";
}, 200);
}
_computeRTL(hass) {
return computeRTL(hass);
}
}
customElements.define("cloud-login", CloudLogin);

View File

@@ -0,0 +1,310 @@
import "@material/mwc-button";
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import { cloudLogin } from "../../../../data/cloud";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import "../../../../styles/polymer-ha-style";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property() public flashMessage?: string;
@state() private _password?: string;
@state() private _requestInProgress = false;
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
@query("#password", true) private _passwordField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>${this.hass.localize(
"ui.panel.config.cloud.login.introduction2a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction3"
)}
</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.login.learn_more_link"
)}
</a>
</p>
</div>
${this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: ""}
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}
>
<div class="card-content login-form">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
.label=${this.hass.localize(
"ui.panel.config.cloud.login.email"
)}
id="email"
type="email"
required
.value=${this.email}
@keydown=${this._keyDown}
.disabled=${this._requestInProgress}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.login.email_error_msg"
)}
></mwc-textfield>
<mwc-textfield
id="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.login.password"
)}
.value=${this._password || ""}
type="password"
required
minlength="8"
@keydown=${this._keyDown}
.disabled=${this._requestInProgress}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.login.password_error_msg"
)}
></mwc-textfield>
<button
class="link pwd-forgot-link"
.disabled=${this._requestInProgress}
@click=${this._handleForgotPassword}
>
${this.hass.localize(
"ui.panel.config.cloud.login.forgot_password"
)}
</button>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleLogin}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}</ha-progress-button
>
</div>
</ha-card>
<ha-card>
<paper-item @click=${this._handleRegister}>
<paper-item-body two-line>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<div secondary>
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleLogin();
}
}
private async _handleLogin() {
const emailField = this._emailField;
const passwordField = this._passwordField;
const email = emailField.value;
const password = passwordField.value;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
this._requestInProgress = true;
try {
await cloudLogin(this.hass, email, password);
fireEvent(this, "ha-refresh-cloud-status");
this.email = "";
this._password = "";
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
return;
}
this._password = "";
this._requestInProgress = false;
if (errCode === "UserNotConfirmed") {
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_email_confirm_necessary"
);
} else {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
emailField.focus();
}
}
private _handleRegister() {
this._dismissFlash();
// @ts-ignore
fireEvent(this, "email-changed", { value: this._emailField.value });
navigate("/config/cloud/register");
}
private _handleForgotPassword() {
this._dismissFlash();
// @ts-ignore
fireEvent(this, "email-changed", { value: this._emailField.value });
navigate("/config/cloud/forgot-password");
}
private _dismissFlash() {
// @ts-ignore
fireEvent(this, "flash-message-changed", { value: "" });
}
static get styles() {
return [
haStyle,
css`
.content {
padding-bottom: 24px;
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
margin: 0;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.login-form {
display: flex;
flex-direction: column;
}
.pwd-forgot-link {
color: var(--secondary-text-color) !important;
text-align: right !important;
align-self: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-login": CloudLogin;
}
}

View File

@@ -1,229 +0,0 @@
import "@polymer/paper-input/paper-input";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
import { documentationUrl } from "../../../../util/documentation-url";
import "../../ha-config-section";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudRegister extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
[slot=introduction] {
margin: -1em 0;
}
[slot=introduction] a {
color: var(--primary-color);
}
a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
[hidden] {
display: none;
}
</style>
<hass-subpage hass="[[hass]]" narrow="[[narrow]]" header="[[localize('ui.panel.config.cloud.register.title')]]">
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">[[localize('ui.panel.config.cloud.register.headline')]]</span>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.register.information')]]
</p>
<p>
[[localize('ui.panel.config.cloud.register.information2')]]
</p>
<ul>
<li>[[localize('ui.panel.config.cloud.register.feature_remote_control')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_google_home')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_amazon_alexa')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_webhook_apps')]]</li>
</ul>
<p>
[[localize('ui.panel.config.cloud.register.information3')]] <a href='https://www.nabucasa.com' target='_blank'>Nabu&nbsp;Casa,&nbsp;Inc</a>[[localize('ui.panel.config.cloud.register.information3a')]]
</p>
<p>
[[localize('ui.panel.config.cloud.register.information4')]]
</p><ul>
<li><a href="[[_computeDocumentationUrlTos(hass)]]" target="_blank" rel="noreferrer">[[localize('ui.panel.config.cloud.register.link_terms_conditions')]]</a></li>
<li><a href="[[_computeDocumentationUrlPrivacy(hass)]]" target="_blank" rel="noreferrer">[[localize('ui.panel.config.cloud.register.link_privacy_policy')]]</a></li>
</ul>
</p>
</div>
<ha-card header="[[localize('ui.panel.config.cloud.register.create_account')]]">
<div class="card-content">
<div class="header">
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
</div>
<paper-input autofocus="" id="email" label="[[localize('ui.panel.config.cloud.register.email_address')]]" type="email" value="{{email}}" on-keydown="_keyDown" error-message="[[localize('ui.panel.config.cloud.register.email_error_msg')]]"></paper-input>
<paper-input id="password" label="Password" value="{{_password}}" type="password" on-keydown="_keyDown" error-message="[[localize('ui.panel.config.cloud.register.password_error_msg')]]"></paper-input>
</div>
<div class="card-actions">
<ha-progress-button on-click="_handleRegister" progress="[[_requestInProgress]]">[[localize('ui.panel.config.cloud.register.start_trial')]]</ha-progress-button>
<button class="link" hidden="[[_requestInProgress]]" on-click="_handleResendVerifyEmail">[[localize('ui.panel.config.cloud.register.resend_confirmation_email')]]</button>
</div>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
email: {
type: String,
notify: true,
},
_requestInProgress: {
type: Boolean,
value: false,
},
_password: {
type: String,
value: "",
},
_error: {
type: String,
value: "",
},
};
}
static get observers() {
return ["_inputChanged(email, _password)"];
}
_inputChanged() {
this._error = "";
this.$.email.invalid = false;
this.$.password.invalid = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleRegister();
ev.preventDefault();
}
}
_computeDocumentationUrlTos(hass) {
return documentationUrl(hass, "/tos/");
}
_computeDocumentationUrlPrivacy(hass) {
return documentationUrl(hass, "/privacy/");
}
_handleRegister() {
let invalid = false;
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
this.$.email.focus();
invalid = true;
}
if (this._password.length < 8) {
this.$.password.invalid = true;
if (!invalid) {
invalid = true;
this.$.password.focus();
}
}
if (invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/register", {
email: this.email,
password: this._password,
})
.then(
() => this._verificationEmailSent(),
(err) => {
// Do this before setProperties because changing it clears errors.
this._password = "";
this.setProperties({
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
});
}
);
}
_handleResendVerifyEmail() {
if (!this.email) {
this.$.email.invalid = true;
return;
}
this.hass
.callApi("post", "cloud/resend_confirm", {
email: this.email,
})
.then(
() => this._verificationEmailSent(),
(err) =>
this.setProperties({
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
})
);
}
_verificationEmailSent() {
this.setProperties({
_requestInProgress: false,
_password: "",
});
this.fire("cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.register.account_created"
),
});
}
}
customElements.define("cloud-register", CloudRegister);

View File

@@ -0,0 +1,293 @@
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
@customElement("cloud-register")
export class CloudRegister extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@state() private _requestInProgress = false;
@state() private _password = "";
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
@query("#password", true) private _passwordField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information2"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_remote_control"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_google_home"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_amazon_alexa"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_webhook_apps"
)}
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a href="https://www.nabucasa.com" target="_blank"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
"ui.panel.config.cloud.register.information3a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information4"
)}
</p>
<ul>
<li>
<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}
</a>
</li>
</ul>
</div>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
><div class="card-content register-form">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
autofocus
id="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
required
.value=${this.email}
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></mwc-textfield>
<mwc-textfield
id="password"
label="Password"
.value=${this._password}
type="password"
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></mwc-textfield>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
<button
class="link"
.disabled=${this._requestInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}
</button>
</div>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
const email = emailField.value;
const password = passwordField.value;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
this._requestInProgress = true;
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private async _handleResendVerifyEmail() {
const emailField = this._emailField;
const email = emailField.value;
if (!emailField.reportValidity()) {
emailField.focus();
return;
}
try {
await cloudResendVerification(this.hass, email);
this._verificationEmailSent(email);
} catch (err: any) {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private _verificationEmailSent(email: string) {
this._requestInProgress = false;
this._password = "";
// @ts-ignore
fireEvent(this, "email-changed", { value: email });
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.register.account_created"
),
});
}
static get styles() {
return [
haStyle,
css`
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
h1 {
margin: 0;
}
.register-form {
display: flex;
flex-direction: column;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-register": CloudRegister;
}
}

View File

@@ -1,3 +1,4 @@
import { mdiArrowDown, mdiArrowUp } from "@mdi/js";
import {
css,
CSSResultGroup,
@@ -7,6 +8,7 @@ import {
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -16,10 +18,12 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
import { computeStateName } from "../../../common/entity/compute_state_name";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { formatNumber } from "../../../common/number/format_number";
import { round } from "../../../common/number/round";
import { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card";
import "../../../components/ha-icon";
import { UNAVAILABLE_STATES } from "../../../data/entity";
import { fetchRecent } from "../../../data/history";
import { HomeAssistant } from "../../../types";
import { formatAttributeValue } from "../../../util/hass-attributes-util";
import { computeCardSize } from "../common/compute-card-size";
@@ -66,8 +70,14 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
@state() private _config?: EntityCardConfig;
@state() private _lastState?: number;
private _footerElement?: HuiErrorCard | LovelaceHeaderFooter;
private _date?: Date;
private _fetching = false;
public setConfig(config: EntityCardConfig): void {
if (!config.entity) {
throw new Error("Entity must be specified");
@@ -76,7 +86,10 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
throw new Error("Invalid entity");
}
this._config = config;
this._config = {
hours_to_show: 24,
...config,
};
if (this._config.footer) {
this._footerElement = createHeaderFooterElement(this._config.footer);
@@ -115,23 +128,38 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
: !UNAVAILABLE_STATES.includes(stateObj.state);
const name = this._config.name || computeStateName(stateObj);
const trend = this._lastState
? round((Number(stateObj.state) / this._lastState) * 100, 0)
: undefined;
return html`
<ha-card @click=${this._handleClick} tabindex="0">
<div class="header">
<div class="name" .title=${name}>${name}</div>
<div class="icon">
<ha-state-icon
.icon=${this._config.icon}
.state=${stateObj}
data-domain=${ifDefined(
this._config.state_color ||
(domain === "light" && this._config.state_color !== false)
? domain
: undefined
)}
data-state=${stateObj ? computeActiveState(stateObj) : ""}
></ha-state-icon>
${this._config.show_trend && trend
? html`
<div class="trend ${classMap({ error: trend < 100 })}">
<ha-svg-icon
.path=${trend < 100 ? mdiArrowDown : mdiArrowUp}
></ha-svg-icon>
${trend} %
</div>
`
: html`
<ha-state-icon
.icon=${this._config.icon}
.state=${stateObj}
data-domain=${ifDefined(
this._config.state_color ||
(domain === "light" &&
this._config.state_color !== false)
? domain
: undefined
)}
data-state=${stateObj ? computeActiveState(stateObj) : ""}
></ha-state-icon>
`}
</div>
</div>
<div class="info">
@@ -177,7 +205,11 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this._config || !this.hass) {
if (
!this._config ||
!this.hass ||
(this._fetching && !changedProps.has("_config"))
) {
return;
}
@@ -194,12 +226,46 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
) {
applyThemesOnElement(this, this.hass.themes, this._config!.theme);
}
if (changedProps.has("_config")) {
if (!oldConfig || oldConfig.entity !== this._config.entity) {
this._lastState = undefined;
}
this._getStateHistory();
} else if (Date.now() - this._date!.getTime() >= 60000) {
this._getStateHistory();
}
}
private _handleClick(): void {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
}
private async _getStateHistory(): Promise<void> {
if (this._fetching) {
return;
}
this._fetching = true;
const now = new Date();
const startTime = new Date(
new Date().setHours(now.getHours() - this._config!.hours_to_show!)
);
const stateHistory = await fetchRecent(
this.hass!,
this._config!.entity,
startTime,
startTime
);
this._lastState = Number(stateHistory[0][0].state);
this._date = now;
this._fetching = false;
}
static get styles(): CSSResultGroup {
return [
iconColorCSS,
@@ -234,6 +300,17 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
line-height: 40px;
}
.trend {
font-size: 16px;
color: var(--success-color);
display: flex;
align-items: center;
}
.trend.error {
color: var(--error-color);
}
.info {
padding: 0px 16px 16px;
margin-top: -4px;

View File

@@ -51,6 +51,7 @@ class HuiSensorCard extends HuiEntityCard {
const entityCardConfig: EntityCardConfig = {
...cardConfig,
hours_to_show,
type: "entity",
};

View File

@@ -40,6 +40,8 @@ export interface EntityCardConfig extends LovelaceCardConfig {
unit?: string;
theme?: string;
state_color?: boolean;
hours_to_show?: number;
show_trend?: boolean;
}
export interface EntitiesCardEntityConfig extends EntityConfig {
@@ -357,6 +359,7 @@ export interface SensorCardConfig extends LovelaceCardConfig {
detail?: number;
theme?: string;
hours_to_show?: number;
show_trend?: boolean;
limits?: {
min?: number;
max?: number;

View File

@@ -1,7 +1,15 @@
import "@polymer/paper-input/paper-input";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import {
assert,
assign,
boolean,
number,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIcon } from "../../../../common/entity/domain_icon";
@@ -29,6 +37,8 @@ const cardConfigStruct = assign(
theme: optional(string()),
state_color: optional(boolean()),
footer: optional(headerFooterConfigStructs),
hours_to_show: optional(number()),
show_trend: optional(boolean()),
})
);
@@ -74,6 +84,14 @@ export class HuiEntityCardEditor
return this._config!.theme || "";
}
get _hours_to_show(): number | string {
return this._config!.hours_to_show || "24";
}
get _show_trend(): boolean {
return this._config!.show_trend || false;
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
@@ -167,6 +185,36 @@ export class HuiEntityCardEditor
</ha-switch>
</ha-formfield>
</div>
<div class="side-by-side">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.entity.show_trend"
)}
>
<ha-switch
.checked=${this._show_trend}
.configValue=${"show_trend"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
${this._show_trend
? html`
<paper-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.hours_to_show"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
type="number"
.value=${this._hours_to_show}
min="1"
.configValue=${"hours_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
`
: ""}
</div>
</div>
`;
}

View File

@@ -4,7 +4,15 @@ import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert, assign, number, object, optional, string } from "superstruct";
import {
assert,
assign,
boolean,
number,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIcon } from "../../../../common/entity/domain_icon";
@@ -31,6 +39,7 @@ const cardConfigStruct = assign(
detail: optional(number()),
theme: optional(string()),
hours_to_show: optional(number()),
show_trend: optional(boolean()),
})
);
@@ -82,6 +91,10 @@ export class HuiSensorCardEditor
return this._config!.hours_to_show || "24";
}
get _show_trend(): boolean {
return this._config!.show_trend || false;
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
@@ -193,6 +206,17 @@ export class HuiSensorCardEditor
@value-changed=${this._valueChanged}
></paper-input>
</div>
<ha-formfield
label=${this.hass.localize(
"ui.panel.lovelace.editor.card.sensor.show_trend"
)}
>
<ha-switch
.checked=${this._show_trend}
.configValue=${"show_trend"}
@change=${this._change}
></ha-switch>
</ha-formfield>
</div>
`;
}
@@ -202,15 +226,21 @@ export class HuiSensorCardEditor
return;
}
const value = (ev.target! as EditorTarget).checked ? 2 : 1;
const target = ev.target! as EditorTarget;
const value =
target.configValue === "detail"
? (ev.target! as EditorTarget).checked
? 2
: 1
: target.checked;
if (this._detail === value) {
if (this[`_${target.configValue}`] === value) {
return;
}
this._config = {
...this._config,
detail: value,
[target.configValue!]: value,
};
fireEvent(this, "config-changed", { config: this._config });

View File

@@ -11,6 +11,28 @@ interface WhiteList {
let whiteListNormal: WhiteList | undefined;
let whiteListSvg: WhiteList | undefined;
// Override the default `onTagAttr` behavior to only render
// our markdown checkboxes.
// Returning undefined causes the default measure to be taken
// in the xss library.
const onTagAttr = (
tag: string,
name: string,
value: string
): string | undefined => {
if (tag === "input") {
if (
(name === "type" && value === "checkbox") ||
name === "checked" ||
name === "disabled"
) {
return undefined;
}
return "";
}
return undefined;
};
const renderMarkdown = (
content: string,
markedOptions: marked.MarkedOptions,
@@ -22,6 +44,7 @@ const renderMarkdown = (
if (!whiteListNormal) {
whiteListNormal = {
...(getDefaultWhiteList() as WhiteList),
input: ["type", "disabled", "checked"],
"ha-icon": ["icon"],
"ha-svg-icon": ["path"],
};
@@ -45,6 +68,7 @@ const renderMarkdown = (
return filterXSS(marked(content, markedOptions), {
whiteList,
onTagAttr,
});
};

View File

@@ -1715,7 +1715,7 @@
},
"event": {
"label": "Fire event",
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"event": "[%key:ui::panel::config::automation::editor::triggers::type::event::label%]",
"service_data": "[%key:ui::components::service-control::service_data%]"
},
"device_id": {
@@ -3277,7 +3277,8 @@
},
"entity": {
"name": "Entity",
"description": "The Entity card gives you a quick overview of your entitys state."
"description": "The Entity card gives you a quick overview of your entitys state.",
"show_trend": "Show Trend?"
},
"button": {
"name": "Button",
@@ -3415,7 +3416,8 @@
"name": "Sensor",
"show_more_detail": "Show more detail",
"graph_type": "Graph Type",
"description": "The Sensor card gives you a quick overview of your sensors state with an optional graph to visualize change over time."
"description": "The Sensor card gives you a quick overview of your sensors state with an optional graph to visualize change over time.",
"show_trend": "Show Trend?"
},
"shopping-list": {
"name": "Shopping List",

View File

@@ -3343,7 +3343,7 @@ __metadata:
languageName: node
linkType: hard
"@polymer/paper-ripple@npm:^3.0.0-pre.26, @polymer/paper-ripple@npm:^3.0.2":
"@polymer/paper-ripple@npm:^3.0.0-pre.26":
version: 3.0.2
resolution: "@polymer/paper-ripple@npm:3.0.2"
dependencies:
@@ -9038,7 +9038,6 @@ fsevents@^1.2.7:
"@polymer/paper-input": ^3.2.1
"@polymer/paper-item": ^3.0.1
"@polymer/paper-listbox": ^3.0.1
"@polymer/paper-ripple": ^3.0.2
"@polymer/paper-slider": ^3.0.1
"@polymer/paper-styles": ^3.0.1
"@polymer/paper-tabs": ^3.1.0