From 915c441a94e6581c53002ef47548a316cf4ee5a4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 10:40:18 -0700 Subject: [PATCH] Cleanup config flow (#2932) * Break up config flow dialog * Allow picking devices when config flow finishes * Lint * Tweaks --- src/common/translations/localize.ts | 22 ++ src/components/entity/state-badge.ts | 2 +- src/data/config_entries.ts | 3 +- src/dialogs/config-flow/dialog-config-flow.ts | 316 +++++------------- src/dialogs/config-flow/step-flow-abort.ts | 66 ++++ .../config-flow/step-flow-create-entry.ts | 191 +++++++++++ src/dialogs/config-flow/step-flow-form.ts | 222 ++++++++++++ src/dialogs/config-flow/step-flow-loading.ts | 35 ++ src/dialogs/config-flow/styles.ts | 33 ++ 9 files changed, 664 insertions(+), 226 deletions(-) create mode 100644 src/dialogs/config-flow/step-flow-abort.ts create mode 100644 src/dialogs/config-flow/step-flow-create-entry.ts create mode 100644 src/dialogs/config-flow/step-flow-form.ts create mode 100644 src/dialogs/config-flow/step-flow-loading.ts create mode 100644 src/dialogs/config-flow/styles.ts diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index 433e7882f8..11a5246f01 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -79,3 +79,25 @@ export const computeLocalize = ( } }; }; + +/** + * Silly helper function that converts an object of placeholders to array so we + * can convert it back to an object again inside the localize func. + * @param localize + * @param key + * @param placeholders + */ +export const localizeKey = ( + localize: LocalizeFunc, + key: string, + placeholders?: { [key: string]: string } +) => { + const args: [string, ...string[]] = [key]; + if (placeholders) { + Object.keys(placeholders).forEach((placeholderKey) => { + args.push(placeholderKey); + args.push(placeholders[placeholderKey]); + }); + } + return localize(...args); +}; diff --git a/src/components/entity/state-badge.ts b/src/components/entity/state-badge.ts index 49df5ef061..0d73d6fcd0 100644 --- a/src/components/entity/state-badge.ts +++ b/src/components/entity/state-badge.ts @@ -39,7 +39,7 @@ class StateBadge extends LitElement { } protected updated(changedProps: PropertyValues) { - if (!changedProps.has("stateObj")) { + if (!changedProps.has("stateObj") || !this.stateObj) { return; } const stateObj = this.stateObj; diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index 2fb4f019a3..48618bdc80 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -22,7 +22,8 @@ export interface ConfigFlowStepCreateEntry { flow_id: string; handler: string; title: string; - data: any; + // Config entry ID + result: string; description: string; description_placeholders: { [key: string]: string }; } diff --git a/src/dialogs/config-flow/dialog-config-flow.ts b/src/dialogs/config-flow/dialog-config-flow.ts index c72abf0949..9a98881ab5 100644 --- a/src/dialogs/config-flow/dialog-config-flow.ts +++ b/src/dialogs/config-flow/dialog-config-flow.ts @@ -25,16 +25,32 @@ import { fetchConfigFlow, createConfigFlow, ConfigFlowStep, - handleConfigFlowStep, deleteConfigFlow, - FieldSchema, - ConfigFlowStepForm, } from "../../data/config_entries"; -import { PolymerChangedEvent, applyPolymerEvent } from "../../polymer-types"; +import { PolymerChangedEvent } from "../../polymer-types"; import { HaConfigFlowParams } from "./show-dialog-config-flow"; +import "./step-flow-loading"; +import "./step-flow-form"; +import "./step-flow-abort"; +import "./step-flow-create-entry"; +import { + DeviceRegistryEntry, + fetchDeviceRegistry, +} from "../../data/device_registry"; +import { AreaRegistryEntry, fetchAreaRegistry } from "../../data/area_registry"; + let instance = 0; +declare global { + // for fire event + interface HASSDomEvents { + "flow-update": { + step?: ConfigFlowStep; + }; + } +} + @customElement("dialog-config-flow") class ConfigFlowDialog extends LitElement { @property() @@ -49,18 +65,15 @@ class ConfigFlowDialog extends LitElement { private _step?: ConfigFlowStep; @property() - private _stepData?: { [key: string]: any }; + private _devices?: DeviceRegistryEntry[]; @property() - private _errorMsg?: string; + private _areas?: AreaRegistryEntry[]; 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) @@ -93,201 +106,91 @@ class ConfigFlowDialog extends LitElement { 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} -
+ + ${this._loading + ? html` + + ` + : this._step === undefined + ? // When we are going to next step, we render 1 round of empty + // to reset the element. + "" + : this._step.type === "form" + ? html` + + ` + : this._step.type === "abort" + ? html` + + ` + : this._devices === undefined || this._areas === undefined + ? // When it's a create entry result, we will fetch device & area registry + html` + + ` + : html` + + `} `; } protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this.addEventListener("keypress", (ev) => { - if (ev.keyCode === 13) { - this._submitStep(); - } + this.addEventListener("flow-update", (ev) => { + this._processStep((ev as any).detail.step); }); } + protected updated(changedProps: PropertyValues) { + if ( + changedProps.has("_step") && + this._step && + this._step.type === "create_entry" + ) { + this._fetchDevices(this._step.result); + this._fetchAreas(); + } + } + 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 async _fetchDevices(configEntryId) { + // Wait 5 seconds to give integrations time to find devices + await new Promise((resolve) => setTimeout(resolve, 5000)); + const devices = await fetchDeviceRegistry(this._params!.hass); + this._devices = devices.filter((device) => + device.config_entries.includes(configEntryId) + ); } - private _processStep(step: ConfigFlowStep): void { - this._step = step; + private async _fetchAreas() { + this._areas = await fetchAreaRegistry(this._params!.hass); + } - // 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 async _processStep(step: ConfigFlowStep): Promise { + if (step === undefined) { + this._flowDone(); + return; } + this._step = undefined; + await this.updateComplete; + this._step = step; } private _flowDone(): void { @@ -307,10 +210,9 @@ class ConfigFlowDialog extends LitElement { flowFinished, }); - this._errorMsg = undefined; this._step = undefined; - this._stepData = {}; this._params = undefined; + this._devices = undefined; } private _openedChanged(ev: PolymerChangedEvent): void { @@ -320,51 +222,17 @@ class ConfigFlowDialog extends LitElement { } } - 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 { + paper-dialog > * { + margin: 0; display: block; - margin: 0 auto; - } - .init-spinner { - padding: 10px 100px 34px; - text-align: center; - } - .submit-spinner { - margin-right: 16px; + padding: 0; } `, ]; diff --git a/src/dialogs/config-flow/step-flow-abort.ts b/src/dialogs/config-flow/step-flow-abort.ts new file mode 100644 index 0000000000..ecd05fe2b5 --- /dev/null +++ b/src/dialogs/config-flow/step-flow-abort.ts @@ -0,0 +1,66 @@ +import { + LitElement, + TemplateResult, + html, + customElement, + property, + CSSResult, +} from "lit-element"; +import "@material/mwc-button"; + +import { ConfigFlowStepAbort } from "../../data/config_entries"; +import { HomeAssistant } from "../../types"; +import { localizeKey } from "../../common/translations/localize"; +import { fireEvent } from "../../common/dom/fire_event"; +import { configFlowContentStyles } from "./styles"; + +@customElement("step-flow-abort") +class StepFlowAbort extends LitElement { + @property() + public hass!: HomeAssistant; + + @property() + private step!: ConfigFlowStepAbort; + + protected render(): TemplateResult | void { + const localize = this.hass.localize; + const step = this.step; + + const description = localizeKey( + localize, + `component.${step.handler}.config.abort.${step.reason}`, + step.description_placeholders + ); + + return html` +

Aborted

+
+ ${ + description + ? html` + + ` + : "" + } +
+
+ Close +
+
+ `; + } + + private _flowDone(): void { + fireEvent(this, "flow-update", { step: undefined }); + } + + static get styles(): CSSResult { + return configFlowContentStyles; + } +} + +declare global { + interface HTMLElementTagNameMap { + "step-flow-abort": StepFlowAbort; + } +} diff --git a/src/dialogs/config-flow/step-flow-create-entry.ts b/src/dialogs/config-flow/step-flow-create-entry.ts new file mode 100644 index 0000000000..0935ef5d5f --- /dev/null +++ b/src/dialogs/config-flow/step-flow-create-entry.ts @@ -0,0 +1,191 @@ +import { + LitElement, + TemplateResult, + html, + customElement, + property, + CSSResultArray, + css, +} from "lit-element"; +import "@material/mwc-button"; +import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; + +import { ConfigFlowStepCreateEntry } from "../../data/config_entries"; +import { HomeAssistant } from "../../types"; +import { localizeKey } from "../../common/translations/localize"; +import { fireEvent } from "../../common/dom/fire_event"; +import { configFlowContentStyles } from "./styles"; +import { + DeviceRegistryEntry, + updateDeviceRegistryEntry, +} from "../../data/device_registry"; +import { + AreaRegistryEntry, + createAreaRegistryEntry, +} from "../../data/area_registry"; + +@customElement("step-flow-create-entry") +class StepFlowCreateEntry extends LitElement { + @property() + public hass!: HomeAssistant; + + @property() + public step!: ConfigFlowStepCreateEntry; + + @property() + public devices!: DeviceRegistryEntry[]; + + @property() + public areas!: AreaRegistryEntry[]; + + protected render(): TemplateResult | void { + const localize = this.hass.localize; + const step = this.step; + + const description = localizeKey( + localize, + `component.${step.handler}.config.create_entry.${step.description || + "default"}`, + step.description_placeholders + ); + + return html` +

Success!

+
+ ${ + description + ? html` + + ` + : "" + } +

Created config for ${step.title}.

+ ${ + this.devices.length === 0 + ? "" + : html` +

We found the following devices:

+
+ ${this.devices.map( + (device) => + html` +
+ ${device.name}
+ ${device.model} (${device.manufacturer}) + + + + + ${localize( + "ui.panel.config.integrations.config_entry.no_area" + )} + + ${this.areas.map( + (area) => html` + + ${area.name} + + ` + )} + + +
+ ` + )} +
+ ` + } +
+
+ ${ + this.devices.length > 0 + ? html` + Add Area + ` + : "" + } + + Finish +
+ + `; + } + + private _flowDone(): void { + fireEvent(this, "flow-update", { step: undefined }); + } + + private async _addArea() { + const name = prompt("Name of the new area?"); + if (!name) { + return; + } + try { + const area = await createAreaRegistryEntry(this.hass, { + name, + }); + this.areas = [...this.areas, area]; + } catch (err) { + alert("Failed to create area."); + } + } + + private async _handleAreaChanged(ev: Event) { + const dropdown = ev.currentTarget as any; + const device = dropdown.device; + + // Item first becomes null, then new item. + if (!dropdown.selectedItem) { + return; + } + + const area = dropdown.selectedItem.area; + try { + await updateDeviceRegistryEntry(this.hass, device, { + area_id: area, + }); + } catch (err) { + alert(`Error saving area: ${err.message}`); + dropdown.value = null; + } + } + + static get styles(): CSSResultArray { + return [ + configFlowContentStyles, + css` + .devices { + display: flex; + flex-wrap: wrap; + margin: -4px; + } + .device { + border: 1px solid var(--divider-color); + padding: 5px; + border-radius: 4px; + margin: 4px; + display: inline-block; + width: 200px; + } + .buttons > *:last-child { + margin-left: auto; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "step-flow-create-entry": StepFlowCreateEntry; + } +} diff --git a/src/dialogs/config-flow/step-flow-form.ts b/src/dialogs/config-flow/step-flow-form.ts new file mode 100644 index 0000000000..0a2df43164 --- /dev/null +++ b/src/dialogs/config-flow/step-flow-form.ts @@ -0,0 +1,222 @@ +import { + LitElement, + TemplateResult, + html, + CSSResultArray, + css, + customElement, + property, + PropertyValues, +} from "lit-element"; +import "@material/mwc-button"; +import "@polymer/paper-tooltip/paper-tooltip"; +import "@polymer/paper-spinner/paper-spinner"; + +import "../../components/ha-form"; +import "../../components/ha-markdown"; +import "../../resources/ha-style"; +import { + handleConfigFlowStep, + FieldSchema, + ConfigFlowStepForm, +} from "../../data/config_entries"; +import { PolymerChangedEvent, applyPolymerEvent } from "../../polymer-types"; +import { HomeAssistant } from "../../types"; +import { fireEvent } from "../../common/dom/fire_event"; +import { localizeKey } from "../../common/translations/localize"; +import { configFlowContentStyles } from "./styles"; + +@customElement("step-flow-form") +class StepFlowForm extends LitElement { + @property() + public step!: ConfigFlowStepForm; + + @property() + public hass!: HomeAssistant; + + @property() + private _loading = false; + + @property() + private _stepData?: { [key: string]: any }; + + @property() + private _errorMsg?: string; + + protected render(): TemplateResult | void { + const localize = this.hass.localize; + const step = this.step; + + const allRequiredInfoFilledIn = + this._stepData === undefined + ? // If no data filled in, just check that any field is required + step.data_schema.find((field) => !field.optional) === undefined + : // If data is filled in, make sure all required fields are + this._stepData && + step.data_schema.every( + (field) => + field.optional || + !["", undefined].includes(this._stepData![field.name]) + ); + + const description = localizeKey( + localize, + `component.${step.handler}.config.step.${step.step_id}.description`, + step.description_placeholders + ); + + return html` +

+ ${localize( + `component.${step.handler}.config.step.${step.step_id}.title` + )} +

+
+ ${this._errorMsg + ? html` +
${this._errorMsg}
+ ` + : ""} + ${description + ? html` + + ` + : ""} + +
+
+ ${this._loading + ? html` +
+ +
+ ` + : html` +
+ + Submit + + + ${!allRequiredInfoFilledIn + ? html` + + Not all required fields are filled in. + + ` + : html``} +
+ `} +
+ `; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this.addEventListener("keypress", (ev) => { + if (ev.keyCode === 13) { + this._submitStep(); + } + }); + } + + private get _stepDataProcessed() { + if (this._stepData !== undefined) { + return this._stepData; + } + + const data = {}; + this.step.data_schema.forEach((field) => { + if ("default" in field) { + data[field.name] = field.default; + } + }); + return data; + } + + private async _submitStep(): Promise { + this._loading = true; + this._errorMsg = undefined; + + const flowId = this.step.flow_id; + 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.hass, + this.step.flow_id, + toSendData + ); + + if (!this.step || flowId !== this.step.flow_id) { + return; + } + + fireEvent(this, "flow-update", { + step, + }); + } catch (err) { + this._errorMsg = + (err && err.body && err.body.message) || "Unknown error occurred"; + } finally { + this._loading = false; + } + } + + private _stepDataChanged(ev: PolymerChangedEvent): void { + this._stepData = applyPolymerEvent(ev, this._stepData); + } + + private _labelCallback = (schema: FieldSchema): string => { + const step = this.step as ConfigFlowStepForm; + + return this.hass.localize( + `component.${step.handler}.config.step.${step.step_id}.data.${ + schema.name + }` + ); + }; + + private _errorCallback = (error: string) => + this.hass.localize(`component.${this.step.handler}.config.error.${error}`); + + static get styles(): CSSResultArray { + return [ + configFlowContentStyles, + css` + .error { + color: red; + } + + .submit-spinner { + margin-right: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "step-flow-form": StepFlowForm; + } +} diff --git a/src/dialogs/config-flow/step-flow-loading.ts b/src/dialogs/config-flow/step-flow-loading.ts new file mode 100644 index 0000000000..aebee5db4a --- /dev/null +++ b/src/dialogs/config-flow/step-flow-loading.ts @@ -0,0 +1,35 @@ +import { + LitElement, + TemplateResult, + html, + css, + customElement, + CSSResult, +} from "lit-element"; +import "@polymer/paper-spinner/paper-spinner-lite"; + +@customElement("step-flow-loading") +class StepFlowLoading extends LitElement { + protected render(): TemplateResult | void { + return html` +
+ +
+ `; + } + + static get styles(): CSSResult { + return css` + .init-spinner { + padding: 50px 100px; + text-align: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "step-flow-loading": StepFlowLoading; + } +} diff --git a/src/dialogs/config-flow/styles.ts b/src/dialogs/config-flow/styles.ts new file mode 100644 index 0000000000..86bb0872bb --- /dev/null +++ b/src/dialogs/config-flow/styles.ts @@ -0,0 +1,33 @@ +import { css } from "lit-element"; + +export const configFlowContentStyles = css` + h2 { + margin-top: 24px; + padding: 0 24px; + } + + .content { + margin-top: 20px; + padding: 0 24px; + } + + .buttons { + position: relative; + padding: 8px 8px 8px 24px; + margin: 0; + color: var(--primary-color); + display: flex; + justify-content: flex-end; + } + + 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; + } +`;