Add Cloud Webhook management (#2102)

* Add Cloud Webhook support

* Lint

* Tweak text

* Rename it to cloudhook

* Fix final type

* fix type

* Catch null
This commit is contained in:
Paulus Schoutsen 2018-11-26 14:09:27 +01:00 committed by GitHub
parent 8ad5280501
commit 07b65f37db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 472 additions and 12 deletions

19
src/data/cloud.ts Normal file
View File

@ -0,0 +1,19 @@
import { HomeAssistant } from "../types";
export interface CloudWebhook {
webhook_id: string;
cloudhook_id: string;
cloudhook_url: string;
}
export const createCloudhook = (hass: HomeAssistant, webhookId: string) =>
hass.callWS<CloudWebhook>({
type: "cloud/cloudhook/create",
webhook_id: webhookId,
});
export const deleteCloudhook = (hass: HomeAssistant, webhookId: string) =>
hass.callWS({
type: "cloud/cloudhook/delete",
webhook_id: webhookId,
});

12
src/data/webhook.ts Normal file
View File

@ -0,0 +1,12 @@
import { HomeAssistant } from "../types";
export interface Webhook {
webhook_id: string;
domain: string;
name: string;
}
export const fetchWebhooks = (hass: HomeAssistant): Promise<Webhook[]> =>
hass.callWS({
type: "webhook/list",
});

View File

@ -24,6 +24,10 @@ export class CloudAlexaPref extends LitElement {
}
protected render(): TemplateResult {
if (!this.cloudStatus) {
return html``;
}
const enabled = this.cloudStatus!.prefs.alexa_enabled;
return html`

View File

@ -25,7 +25,11 @@ export class CloudGooglePref extends LitElement {
}
protected render(): TemplateResult {
const { google_enabled, google_allow_unlock } = this.cloudStatus!.prefs;
if (!this.cloudStatus) {
return html``;
}
const { google_enabled, google_allow_unlock } = this.cloudStatus.prefs;
return html`
${this.renderStyle()}

View File

@ -0,0 +1,142 @@
import { html, LitElement, PropertyDeclarations } from "@polymer/lit-element";
import "@polymer/paper-button/paper-button";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import "@polymer/paper-dialog/paper-dialog";
// This is not a duplicate import, one is for types, one is for element.
// tslint:disable-next-line
import { PaperDialogElement } from "@polymer/paper-dialog/paper-dialog";
// tslint:disable-next-line
import { PaperInputElement } from "@polymer/paper-input/paper-input";
import { buttonLink } from "../../../resources/ha-style";
import { HomeAssistant } from "../../../types";
import { WebhookDialogParams } from "./types";
const inputLabel = "Public URL Click to copy to clipboard";
export class CloudWebhookManageDialog extends LitElement {
protected hass?: HomeAssistant;
private _params?: WebhookDialogParams;
static get properties(): PropertyDeclarations {
return {
_params: {},
};
}
public async showDialog(params: WebhookDialogParams) {
this._params = params;
// Wait till dialog is rendered.
await this.updateComplete;
this._dialog.open();
}
protected render() {
if (!this._params) {
return html``;
}
const { webhook, cloudhook } = this._params;
const docsUrl =
webhook.domain === "automation"
? "https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger"
: `https://www.home-assistant.io/components/${webhook.domain}/`;
return html`
${this._renderStyle()}
<paper-dialog with-backdrop>
<h2>Webhook for ${webhook.name}</h2>
<div>
<p>The webhook is available at the following url:</p>
<paper-input
label="${inputLabel}"
value="${cloudhook.cloudhook_url}"
@click="${this._copyClipboard}"
@blur="${this._restoreLabel}"
></paper-input>
<p>
If you no longer want to use this webhook, you can
<button class="link" @click="${this._disableWebhook}">
disable it</button
>.
</p>
</div>
<div class="paper-dialog-buttons">
<a href="${docsUrl}" target="_blank"
><paper-button>VIEW DOCUMENTATION</paper-button></a
>
<paper-button @click="${this._closeDialog}">CLOSE</paper-button>
</div>
</paper-dialog>
`;
}
private get _dialog(): PaperDialogElement {
return this.shadowRoot!.querySelector("paper-dialog")!;
}
private get _paperInput(): PaperInputElement {
return this.shadowRoot!.querySelector("paper-input")!;
}
private _closeDialog() {
this._dialog.close();
}
private async _disableWebhook() {
if (!confirm("Are you sure you want to disable this webhook?")) {
return;
}
this._params!.disableHook();
this._closeDialog();
}
private _copyClipboard(ev: FocusEvent) {
// paper-input -> iron-input -> input
const paperInput = ev.currentTarget as PaperInputElement;
const input = (paperInput.inputElement as any)
.inputElement as HTMLInputElement;
input.setSelectionRange(0, input.value.length);
try {
document.execCommand("copy");
paperInput.label = "COPIED TO CLIPBOARD";
} catch (err) {
// Copying failed. Oh no
}
}
private _restoreLabel() {
this._paperInput.label = inputLabel;
}
private _renderStyle() {
return html`
<style>
paper-dialog {
width: 650px;
}
paper-input {
margin-top: -8px;
}
${buttonLink} button.link {
color: var(--primary-color);
}
paper-button {
color: var(--primary-color);
font-weight: 500;
}
</style>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-webhook-manage-dialog": CloudWebhookManageDialog;
}
}
customElements.define("cloud-webhook-manage-dialog", CloudWebhookManageDialog);

View File

@ -0,0 +1,234 @@
import {
html,
LitElement,
PropertyDeclarations,
PropertyValues,
} from "@polymer/lit-element";
import "@polymer/paper-toggle-button/paper-toggle-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-spinner/paper-spinner";
import "../../../components/ha-card";
import { fireEvent } from "../../../common/dom/fire_event";
import { HomeAssistant, WebhookError } from "../../../types";
import { WebhookDialogParams, CloudStatusLoggedIn } from "./types";
import { Webhook, fetchWebhooks } from "../../../data/webhook";
import {
createCloudhook,
deleteCloudhook,
CloudWebhook,
} from "../../../data/cloud";
declare global {
// for fire event
interface HASSDomEvents {
"manage-cloud-webhook": WebhookDialogParams;
}
}
export class CloudWebhooks extends LitElement {
public hass?: HomeAssistant;
public cloudStatus?: CloudStatusLoggedIn;
private _cloudHooks?: { [webhookId: string]: CloudWebhook };
private _localHooks?: Webhook[];
private _progress: string[];
static get properties(): PropertyDeclarations {
return {
hass: {},
cloudStatus: {},
_cloudHooks: {},
_localHooks: {},
_progress: {},
};
}
constructor() {
super();
this._progress = [];
}
public connectedCallback() {
super.connectedCallback();
this._fetchData();
}
protected render() {
return html`
${this.renderStyle()}
<ha-card header="Webhooks">
<div class="body">
Anything that is configured to be triggered by a webhook can be given
a publicly accessible URL to allow you to send data back to Home
Assistant from anywhere, without exposing your instance to the
internet.
</div>
${this._renderBody()}
<div class="footer">
<a href="https://www.nabucasa.com/config/webhooks" target="_blank">
Learn more about creating webhook-powered automations.
</a>
</div>
</ha-card>
`;
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("cloudStatus") && this.cloudStatus) {
this._cloudHooks = this.cloudStatus.prefs.cloudhooks || {};
}
}
private _renderBody() {
if (!this.cloudStatus || !this._localHooks || !this._cloudHooks) {
return html`
<div class="loading">Loading</div>
`;
}
return this._localHooks.map(
(entry) => html`
<div class="webhook" .entry="${entry}">
<paper-item-body two-line>
<div>
${entry.name}
${
entry.domain === entry.name.toLowerCase()
? ""
: ` (${entry.domain})`
}
</div>
<div secondary>${entry.webhook_id}</div>
</paper-item-body>
${
this._progress.includes(entry.webhook_id)
? html`
<div class="progress">
<paper-spinner active></paper-spinner>
</div>
`
: this._cloudHooks![entry.webhook_id]
? html`
<paper-button @click="${this._handleManageButton}"
>Manage</paper-button
>
`
: html`
<paper-toggle-button
@click="${this._enableWebhook}"
></paper-toggle-button>
`
}
</div>
`
);
}
private _showDialog(webhookId: string) {
const webhook = this._localHooks!.find(
(ent) => ent.webhook_id === webhookId
);
const cloudhook = this._cloudHooks![webhookId];
const params: WebhookDialogParams = {
webhook: webhook!,
cloudhook,
disableHook: () => this._disableWebhook(webhookId),
};
fireEvent(this, "manage-cloud-webhook", params);
}
private _handleManageButton(ev: MouseEvent) {
const entry = (ev.currentTarget as any).parentElement.entry as Webhook;
this._showDialog(entry.webhook_id);
}
private async _enableWebhook(ev: MouseEvent) {
const entry = (ev.currentTarget as any).parentElement.entry;
this._progress = [...this._progress, entry.webhook_id];
let updatedWebhook;
try {
updatedWebhook = await createCloudhook(this.hass!, entry.webhook_id);
} catch (err) {
alert((err as WebhookError).message);
return;
} finally {
this._progress = this._progress.filter((wid) => wid !== entry.webhook_id);
}
this._cloudHooks = {
...this._cloudHooks,
[entry.webhook_id]: updatedWebhook,
};
// Only open dialog if we're not also enabling others, otherwise it's confusing
if (this._progress.length === 0) {
this._showDialog(entry.webhook_id);
}
}
private async _disableWebhook(webhookId: string) {
this._progress = [...this._progress, webhookId];
try {
await deleteCloudhook(this.hass!, webhookId!);
} catch (err) {
alert(`Failed to disable webhook: ${(err as WebhookError).message}`);
return;
} finally {
this._progress = this._progress.filter((wid) => wid !== webhookId);
}
// Remove cloud related parts from entry.
const { [webhookId]: disabledHook, ...newHooks } = this._cloudHooks!;
this._cloudHooks = newHooks;
}
private async _fetchData() {
this._localHooks = await fetchWebhooks(this.hass!);
}
private renderStyle() {
return html`
<style>
.body {
padding: 0 16px 8px;
}
.loading {
padding: 0 16px;
}
.webhook {
display: flex;
padding: 4px 16px;
}
.progress {
margin-right: 16px;
display: flex;
flex-direction: column;
justify-content: center;
}
paper-button {
font-weight: 500;
color: var(--primary-color);
}
.footer {
padding: 16px;
}
.footer a {
color: var(--primary-color);
}
</style>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-webhooks": CloudWebhooks;
}
}
customElements.define("cloud-webhooks", CloudWebhooks);

View File

@ -10,15 +10,19 @@ import "../../../layouts/hass-subpage";
import "../../../resources/ha-style";
import "../ha-config-section";
import "./cloud-webhooks";
import formatDateTime from "../../../common/datetime/format_date_time";
import EventsMixin from "../../../mixins/events-mixin";
import LocalizeMixin from "../../../mixins/localize-mixin";
import { fireEvent } from "../../../common/dom/fire_event";
import { fetchSubscriptionInfo } from "./data";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
let registeredWebhookDialog = false;
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
@ -129,6 +133,11 @@ class HaConfigCloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
></cloud-google-pref>
<cloud-webhooks
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
></cloud-webhooks>
</ha-config-section>
</div>
</hass-subpage>
@ -152,9 +161,26 @@ class HaConfigCloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
this._fetchSubscriptionInfo();
}
connectedCallback() {
super.connectedCallback();
if (!registeredWebhookDialog) {
registeredWebhookDialog = true;
fireEvent(this, "register-dialog", {
dialogShowEvent: "manage-cloud-webhook",
dialogTag: "cloud-webhook-manage-dialog",
dialogImport: () => import("./cloud-webhook-manage-dialog"),
});
}
}
async _fetchSubscriptionInfo() {
this._subscription = await fetchSubscriptionInfo(this.hass);
if (this._subscription.provider && this.cloudStatus.cloud !== "connected") {
if (
this._subscription.provider &&
this.cloudStatus &&
this.cloudStatus.cloud !== "connected"
) {
this.fire("ha-refresh-cloud-status");
}
}

View File

@ -1,3 +1,6 @@
import { CloudWebhook } from "../../../data/cloud";
import { Webhook } from "../../../data/webhook";
export interface EntityFilter {
include_domains: string[];
include_entities: string[];
@ -19,6 +22,7 @@ export type CloudStatusLoggedIn = CloudStatusBase & {
google_enabled: boolean;
alexa_enabled: boolean;
google_allow_unlock: boolean;
cloudhooks: { [webhookId: string]: CloudWebhook };
};
};
@ -27,3 +31,9 @@ export type CloudStatus = CloudStatusBase | CloudStatusLoggedIn;
export interface SubscriptionInfo {
human_description: string;
}
export interface WebhookDialogParams {
webhook: Webhook;
cloudhook: CloudWebhook;
disableHook: () => void;
}

View File

@ -1,6 +1,19 @@
import "@polymer/paper-styles/paper-styles";
import "@polymer/polymer/polymer-legacy";
export const buttonLink = `
button.link {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
text-align: left;
text-decoration: underline;
cursor: pointer;
}
`;
const documentContainer = document.createElement("template");
documentContainer.setAttribute("style", "display: none;");
@ -162,16 +175,7 @@ documentContainer.innerHTML = `<custom-style>
@apply --paper-font-title;
}
button.link {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
text-align: left;
text-decoration: underline;
cursor: pointer;
}
${buttonLink}
.card-actions a {
text-decoration: none;

View File

@ -15,6 +15,11 @@ declare global {
var __VERSION__: string;
}
export interface WebhookError {
code: number;
message: string;
}
export interface Credential {
auth_provider_type: string;
auth_provider_id: string;