From 534b18ee30e500ef538de04fd55dde28f5094c7e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 23 Feb 2019 20:35:11 -0800 Subject: [PATCH] Convert config flow to Lit/TS (#2814) * Convert config flow to Lit/TS * Apply suggestions from code review Co-Authored-By: balloob * Add missing import * Apply suggestions from code review Co-Authored-By: balloob * Address comments --- src/data/config_entries.ts | 63 +++ src/dialogs/config-flow/dialog-config-flow.ts | 378 ++++++++++++++++++ .../config-flow/show-dialog-config-flow.ts | 23 ++ .../ha-config-entries-dashboard.js | 21 +- .../config/config-entries/ha-config-flow.js | 365 ----------------- src/polymer-types.ts | 15 +- 6 files changed, 484 insertions(+), 381 deletions(-) create mode 100644 src/data/config_entries.ts create mode 100644 src/dialogs/config-flow/dialog-config-flow.ts create mode 100644 src/dialogs/config-flow/show-dialog-config-flow.ts delete mode 100644 src/panels/config/config-entries/ha-config-flow.js diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts new file mode 100644 index 0000000000..2fb4f019a3 --- /dev/null +++ b/src/data/config_entries.ts @@ -0,0 +1,63 @@ +import { HomeAssistant } from "../types"; + +export interface FieldSchema { + name: string; + default?: any; + optional: boolean; +} + +export interface ConfigFlowStepForm { + type: "form"; + flow_id: string; + handler: string; + step_id: string; + data_schema: FieldSchema[]; + errors: { [key: string]: string }; + description_placeholders: { [key: string]: string }; +} + +export interface ConfigFlowStepCreateEntry { + type: "create_entry"; + version: number; + flow_id: string; + handler: string; + title: string; + data: any; + description: string; + description_placeholders: { [key: string]: string }; +} + +export interface ConfigFlowStepAbort { + type: "abort"; + flow_id: string; + handler: string; + reason: string; + description_placeholders: { [key: string]: string }; +} + +export type ConfigFlowStep = + | ConfigFlowStepForm + | ConfigFlowStepCreateEntry + | ConfigFlowStepAbort; + +export const createConfigFlow = (hass: HomeAssistant, handler: string) => + hass.callApi("POST", "config/config_entries/flow", { + handler, + }); + +export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) => + hass.callApi("GET", `config/config_entries/flow/${flowId}`); + +export const handleConfigFlowStep = ( + hass: HomeAssistant, + flowId: string, + data: { [key: string]: any } +) => + hass.callApi( + "POST", + `config/config_entries/flow/${flowId}`, + data + ); + +export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) => + hass.callApi("DELETE", `config/config_entries/flow/${flowId}`); diff --git a/src/dialogs/config-flow/dialog-config-flow.ts b/src/dialogs/config-flow/dialog-config-flow.ts new file mode 100644 index 0000000000..c72abf0949 --- /dev/null +++ b/src/dialogs/config-flow/dialog-config-flow.ts @@ -0,0 +1,378 @@ +import { + LitElement, + TemplateResult, + html, + CSSResultArray, + css, + customElement, + property, + PropertyValues, +} from "lit-element"; +import "@material/mwc-button"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-tooltip/paper-tooltip"; +import "@polymer/paper-spinner/paper-spinner"; +import "@polymer/paper-dialog/paper-dialog"; +// Not duplicate, is for typing +// tslint:disable-next-line +import { PaperDialogElement } from "@polymer/paper-dialog/paper-dialog"; + +import "../../components/ha-form"; +import "../../components/ha-markdown"; +import "../../resources/ha-style"; +import { haStyleDialog } from "../../resources/styles"; +import { + fetchConfigFlow, + createConfigFlow, + ConfigFlowStep, + handleConfigFlowStep, + deleteConfigFlow, + FieldSchema, + ConfigFlowStepForm, +} from "../../data/config_entries"; +import { PolymerChangedEvent, applyPolymerEvent } from "../../polymer-types"; +import { HaConfigFlowParams } from "./show-dialog-config-flow"; + +let instance = 0; + +@customElement("dialog-config-flow") +class ConfigFlowDialog extends LitElement { + @property() + private _params?: HaConfigFlowParams; + + @property() + private _loading = true; + + private _instance = instance; + + @property() + private _step?: ConfigFlowStep; + + @property() + private _stepData?: { [key: string]: any }; + + @property() + private _errorMsg?: string; + + public async showDialog(params: HaConfigFlowParams): Promise { + this._params = params; + this._loading = true; + this._instance = instance++; + this._step = undefined; + this._stepData = {}; + this._errorMsg = undefined; + + const fetchStep = params.continueFlowId + ? fetchConfigFlow(params.hass, params.continueFlowId) + : params.newFlowForHandler + ? createConfigFlow(params.hass, params.newFlowForHandler) + : undefined; + + if (!fetchStep) { + throw new Error(`Pass in either continueFlowId or newFlorForHandler`); + } + + const curInstance = this._instance; + + await this.updateComplete; + const step = await fetchStep; + + // Happens if second showDialog called + if (curInstance !== this._instance) { + return; + } + + this._processStep(step); + this._loading = false; + // When the flow changes, center the dialog. + // Don't do it on each step or else the dialog keeps bouncing. + setTimeout(() => this._dialog.center(), 0); + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + const localize = this._params.hass.localize; + + const step = this._step; + let headerContent: string | undefined; + let bodyContent: TemplateResult | undefined; + let buttonContent: TemplateResult | undefined; + let descriptionKey: string | undefined; + + if (!step) { + bodyContent = html` +
+ +
+ `; + } else if (step.type === "abort") { + descriptionKey = `component.${step.handler}.config.abort.${step.reason}`; + headerContent = "Aborted"; + bodyContent = html``; + buttonContent = html` + Close + `; + } else if (step.type === "create_entry") { + descriptionKey = `component.${ + step.handler + }.config.create_entry.${step.description || "default"}`; + headerContent = "Success!"; + bodyContent = html` +

Created config for ${step.title}

+ `; + buttonContent = html` + Close + `; + } else { + // form + descriptionKey = `component.${step.handler}.config.step.${ + step.step_id + }.description`; + headerContent = localize( + `component.${step.handler}.config.step.${step.step_id}.title` + ); + bodyContent = html` + + `; + + const allRequiredInfoFilledIn = + this._stepData && + step.data_schema.every( + (field) => + field.optional || + !["", undefined].includes(this._stepData![field.name]) + ); + + buttonContent = this._loading + ? html` +
+ +
+ ` + : html` +
+ + Submit + + + ${!allRequiredInfoFilledIn + ? html` + + Not all required fields are filled in. + + ` + : html``} +
+ `; + } + + let description: string | undefined; + + if (step && descriptionKey) { + const args: [string, ...string[]] = [descriptionKey]; + const placeholders = step.description_placeholders || {}; + Object.keys(placeholders).forEach((key) => { + args.push(key); + args.push(placeholders[key]); + }); + description = localize(...args); + } + + return html` + +

+ ${headerContent} +

+ + ${this._errorMsg + ? html` +
${this._errorMsg}
+ ` + : ""} + ${description + ? html` + + ` + : ""} + ${bodyContent} +
+
+ ${buttonContent} +
+
+ `; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this.addEventListener("keypress", (ev) => { + if (ev.keyCode === 13) { + this._submitStep(); + } + }); + } + + private get _dialog(): PaperDialogElement { + return this.shadowRoot!.querySelector("paper-dialog")!; + } + + private async _submitStep(): Promise { + this._loading = true; + this._errorMsg = undefined; + + const curInstance = this._instance; + const stepData = this._stepData || {}; + + const toSendData = {}; + Object.keys(stepData).forEach((key) => { + const value = stepData[key]; + const isEmpty = [undefined, ""].includes(value); + + if (!isEmpty) { + toSendData[key] = value; + } + }); + + try { + const step = await handleConfigFlowStep( + this._params!.hass, + this._step!.flow_id, + toSendData + ); + + if (curInstance !== this._instance) { + return; + } + + this._processStep(step); + } catch (err) { + this._errorMsg = + (err && err.body && err.body.message) || "Unknown error occurred"; + } finally { + this._loading = false; + } + } + + private _processStep(step: ConfigFlowStep): void { + this._step = step; + + // We got a new form if there are no errors. + if (step.type === "form") { + if (!step.errors) { + step.errors = {}; + } + + if (Object.keys(step.errors).length === 0) { + const data = {}; + step.data_schema.forEach((field) => { + if ("default" in field) { + data[field.name] = field.default; + } + }); + this._stepData = data; + } + } + } + + private _flowDone(): void { + if (!this._params) { + return; + } + const flowFinished = Boolean( + this._step && ["success", "abort"].includes(this._step.type) + ); + + // If we created this flow, delete it now. + if (this._step && !flowFinished && this._params.newFlowForHandler) { + deleteConfigFlow(this._params.hass, this._step.flow_id); + } + + this._params.dialogClosedCallback({ + flowFinished, + }); + + this._errorMsg = undefined; + this._step = undefined; + this._stepData = {}; + this._params = undefined; + } + + private _openedChanged(ev: PolymerChangedEvent): void { + // Closed dialog by clicking on the overlay + if (this._step && !ev.detail.value) { + this._flowDone(); + } + } + + private _stepDataChanged(ev: PolymerChangedEvent): void { + this._stepData = applyPolymerEvent(ev, this._stepData); + } + + private _labelCallback = (schema: FieldSchema): string => { + const step = this._step as ConfigFlowStepForm; + + return this._params!.hass.localize( + `component.${step.handler}.config.step.${step.step_id}.data.${ + schema.name + }` + ); + }; + + private _errorCallback = (error: string) => + this._params!.hass.localize( + `component.${this._step!.handler}.config.error.${error}` + ); + + static get styles(): CSSResultArray { + return [ + haStyleDialog, + css` + .error { + color: red; + } + paper-dialog { + max-width: 500px; + } + ha-markdown { + word-break: break-word; + } + ha-markdown a { + color: var(--primary-color); + } + ha-markdown img:first-child:last-child { + display: block; + margin: 0 auto; + } + .init-spinner { + padding: 10px 100px 34px; + text-align: center; + } + .submit-spinner { + margin-right: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-config-flow": ConfigFlowDialog; + } +} diff --git a/src/dialogs/config-flow/show-dialog-config-flow.ts b/src/dialogs/config-flow/show-dialog-config-flow.ts new file mode 100644 index 0000000000..35f86f8c3e --- /dev/null +++ b/src/dialogs/config-flow/show-dialog-config-flow.ts @@ -0,0 +1,23 @@ +import { HomeAssistant } from "../../types"; +import { fireEvent } from "../../common/dom/fire_event"; + +export interface HaConfigFlowParams { + hass: HomeAssistant; + continueFlowId?: string; + newFlowForHandler?: string; + dialogClosedCallback: (params: { flowFinished: boolean }) => void; +} + +export const loadConfigFlowDialog = () => + import(/* webpackChunkName: "dialog-config-flow" */ "./dialog-config-flow"); + +export const showConfigFlowDialog = ( + element: HTMLElement, + dialogParams: HaConfigFlowParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-config-flow", + dialogImport: loadConfigFlowDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/config-entries/ha-config-entries-dashboard.js b/src/panels/config/config-entries/ha-config-entries-dashboard.js index 386656f918..0f46a2eeca 100644 --- a/src/panels/config/config-entries/ha-config-entries-dashboard.js +++ b/src/panels/config/config-entries/ha-config-entries-dashboard.js @@ -17,8 +17,10 @@ import "../ha-config-section"; import EventsMixin from "../../../mixins/events-mixin"; import LocalizeMixin from "../../../mixins/localize-mixin"; import computeStateName from "../../../common/entity/compute_state_name"; - -let registeredDialog = false; +import { + loadConfigFlowDialog, + showConfigFlowDialog, +} from "../../../dialogs/config-flow/show-dialog-config-flow"; /* * @appliesMixin LocalizeMixin @@ -165,20 +167,11 @@ class HaConfigManagerDashboard extends LocalizeMixin( connectedCallback() { super.connectedCallback(); - - if (!registeredDialog) { - registeredDialog = true; - this.fire("register-dialog", { - dialogShowEvent: "show-config-flow", - dialogTag: "ha-config-flow", - dialogImport: () => - import(/* webpackChunkName: "ha-config-flow" */ "./ha-config-flow"), - }); - } + loadConfigFlowDialog(); } _createFlow(ev) { - this.fire("show-config-flow", { + showConfigFlowDialog(this, { hass: this.hass, newFlowForHandler: ev.model.item, dialogClosedCallback: () => this.fire("hass-reload-entries"), @@ -186,7 +179,7 @@ class HaConfigManagerDashboard extends LocalizeMixin( } _continueFlow(ev) { - this.fire("show-config-flow", { + showConfigFlowDialog(this, { hass: this.hass, continueFlowId: ev.model.item.flow_id, dialogClosedCallback: () => this.fire("hass-reload-entries"), diff --git a/src/panels/config/config-entries/ha-config-flow.js b/src/panels/config/config-entries/ha-config-flow.js deleted file mode 100644 index fdb37c66ef..0000000000 --- a/src/panels/config/config-entries/ha-config-flow.js +++ /dev/null @@ -1,365 +0,0 @@ -import "@material/mwc-button"; -import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; -import "@polymer/paper-dialog/paper-dialog"; -import "@polymer/paper-tooltip/paper-tooltip"; -import "@polymer/paper-spinner/paper-spinner"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; - -import "../../../components/ha-form"; -import "../../../components/ha-markdown"; -import "../../../resources/ha-style"; - -import EventsMixin from "../../../mixins/events-mixin"; -import LocalizeMixin from "../../../mixins/localize-mixin"; - -let instance = 0; - -/* - * @appliesMixin LocalizeMixin - * @appliesMixin EventsMixin - */ -class HaConfigFlow extends LocalizeMixin(EventsMixin(PolymerElement)) { - static get template() { - return html` - - -

- - - -

- - - - - -
- - - -
-
- `; - } - - static get properties() { - return { - _hass: Object, - _dialogClosedCallback: Function, - _instance: Number, - - _loading: { - type: Boolean, - value: false, - }, - - // Error message when can't talk to server etc - _errorMsg: String, - - _canSubmit: { - type: Boolean, - computed: "_computeCanSubmit(_step, _stepData, _counter)", - }, - - // Bogus counter because observing of `_stepData` doesn't seem to work - _counter: { - type: Number, - value: 0, - }, - - _opened: { - type: Boolean, - value: false, - }, - - _step: { - type: Object, - value: null, - }, - - /* - * Store user entered data. - */ - _stepData: { - type: Object, - value: null, - }, - }; - } - - ready() { - super.ready(); - this.addEventListener("keypress", (ev) => { - if (ev.keyCode === 13) { - this._submitStep(); - } - }); - } - - showDialog({ - hass, - continueFlowId, - newFlowForHandler, - dialogClosedCallback, - }) { - this.hass = hass; - this._instance = instance++; - this._dialogClosedCallback = dialogClosedCallback; - this._createdFromHandler = !!newFlowForHandler; - this._loading = true; - this._opened = true; - - const fetchStep = continueFlowId - ? this.hass.callApi("get", `config/config_entries/flow/${continueFlowId}`) - : this.hass.callApi("post", "config/config_entries/flow", { - handler: newFlowForHandler, - }); - - const curInstance = this._instance; - - fetchStep.then((step) => { - if (curInstance !== this._instance) return; - - this._processStep(step); - this._loading = false; - // When the flow changes, center the dialog. - // Don't do it on each step or else the dialog keeps bouncing. - setTimeout(() => this.$.dialog.center(), 0); - }); - } - - _submitStep() { - this._loading = true; - this._errorMsg = null; - - const curInstance = this._instance; - - const data = {}; - Object.keys(this._stepData).forEach((key) => { - const value = this._stepData[key]; - const isEmpty = [undefined, ""].includes(value); - - if (!isEmpty) { - data[key] = value; - } - }); - - this.hass - .callApi("post", `config/config_entries/flow/${this._step.flow_id}`, data) - .then( - (step) => { - if (curInstance !== this._instance) return; - - this._processStep(step); - this._loading = false; - }, - (err) => { - this._errorMsg = - (err && err.body && err.body.message) || "Unknown error occurred"; - this._loading = false; - } - ); - } - - _processStep(step) { - if (!step.errors) step.errors = {}; - this._step = step; - // We got a new form if there are no errors. - if (step.type === "form" && Object.keys(step.errors).length === 0) { - const data = {}; - step.data_schema.forEach((field) => { - if ("default" in field) { - data[field.name] = field.default; - } - }); - this._stepData = data; - } - } - - _flowDone() { - this._opened = false; - const flowFinished = - this._step && ["success", "abort"].includes(this._step.type); - - if (this._step && !flowFinished && this._createdFromHandler) { - this.hass.callApi( - "delete", - `config/config_entries/flow/${this._step.flow_id}` - ); - } - - this._dialogClosedCallback({ - flowFinished, - }); - - this._errorMsg = null; - this._step = null; - this._stepData = {}; - this._dialogClosedCallback = null; - } - - _equals(a, b) { - return a === b; - } - - _openedChanged(ev) { - // Closed dialog by clicking on the overlay - if (this._step && !ev.detail.value) { - this._flowDone(); - } - } - - _computeStepTitle(localize, step) { - return localize( - `component.${step.handler}.config.step.${step.step_id}.title` - ); - } - - _computeStepDescription(localize, step) { - const args = []; - if (step.type === "form") { - args.push( - `component.${step.handler}.config.step.${step.step_id}.description` - ); - } else if (step.type === "abort") { - args.push(`component.${step.handler}.config.abort.${step.reason}`); - } else if (step.type === "create_entry") { - args.push( - `component.${step.handler}.config.create_entry.${step.description || - "default"}` - ); - } - - const placeholders = step.description_placeholders || {}; - Object.keys(placeholders).forEach((key) => { - args.push(key); - args.push(placeholders[key]); - }); - - return localize(...args); - } - - _computeLabelCallback(localize, step) { - // Returns a callback for ha-form to calculate labels per schema object - return (schema) => - localize( - `component.${step.handler}.config.step.${step.step_id}.data.${ - schema.name - }` - ); - } - - _computeErrorCallback(localize, step) { - // Returns a callback for ha-form to calculate error messages - return (error) => - localize(`component.${step.handler}.config.error.${error}`); - } - - _computeCanSubmit(step, stepData) { - // We can submit if all required fields are filled in - return ( - step !== null && - step.type === "form" && - stepData !== null && - step.data_schema.every( - (field) => - field.optional || !["", undefined].includes(stepData[field.name]) - ) - ); - } - - _increaseCounter() { - this._counter += 1; - } -} - -customElements.define("ha-config-flow", HaConfigFlow); diff --git a/src/polymer-types.ts b/src/polymer-types.ts index c7692329a0..1b921bf1e9 100644 --- a/src/polymer-types.ts +++ b/src/polymer-types.ts @@ -1,9 +1,20 @@ -// Force file to be a module to augment global scope. -export {}; +export const applyPolymerEvent = ( + ev: PolymerChangedEvent, + curValue: T +): T => { + const { path, value } = ev.detail; + if (!path) { + return value; + } + const propName = path.split(".")[1]; + return { ...curValue, [propName]: value }; +}; export interface PolymerChangedEvent extends Event { detail: { value: T; + path?: string; + queueProperty: boolean; }; }