Convert config flow to Lit/TS (#2814)

* Convert config flow to Lit/TS

* Apply suggestions from code review

Co-Authored-By: balloob <paulus@home-assistant.io>

* Add missing import

* Apply suggestions from code review

Co-Authored-By: balloob <paulus@home-assistant.io>

* Address comments
This commit is contained in:
Paulus Schoutsen 2019-02-23 20:35:11 -08:00 committed by GitHub
parent e406a50b50
commit 534b18ee30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 484 additions and 381 deletions

View File

@ -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<ConfigFlowStep>("POST", "config/config_entries/flow", {
handler,
});
export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi<ConfigFlowStep>("GET", `config/config_entries/flow/${flowId}`);
export const handleConfigFlowStep = (
hass: HomeAssistant,
flowId: string,
data: { [key: string]: any }
) =>
hass.callApi<ConfigFlowStep>(
"POST",
`config/config_entries/flow/${flowId}`,
data
);
export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi("DELETE", `config/config_entries/flow/${flowId}`);

View File

@ -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<void> {
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`
<div class="init-spinner">
<paper-spinner active></paper-spinner>
</div>
`;
} else if (step.type === "abort") {
descriptionKey = `component.${step.handler}.config.abort.${step.reason}`;
headerContent = "Aborted";
bodyContent = html``;
buttonContent = html`
<mwc-button @click="${this._flowDone}">Close</mwc-button>
`;
} else if (step.type === "create_entry") {
descriptionKey = `component.${
step.handler
}.config.create_entry.${step.description || "default"}`;
headerContent = "Success!";
bodyContent = html`
<p>Created config for ${step.title}</p>
`;
buttonContent = html`
<mwc-button @click="${this._flowDone}">Close</mwc-button>
`;
} 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`
<ha-form
.data=${this._stepData}
@data-changed=${this._stepDataChanged}
.schema=${step.data_schema}
.error=${step.errors}
.computeLabel=${this._labelCallback}
.computeError=${this._errorCallback}
></ha-form>
`;
const allRequiredInfoFilledIn =
this._stepData &&
step.data_schema.every(
(field) =>
field.optional ||
!["", undefined].includes(this._stepData![field.name])
);
buttonContent = this._loading
? html`
<div class="submit-spinner">
<paper-spinner active></paper-spinner>
</div>
`
: html`
<div>
<mwc-button
@click=${this._submitStep}
.disabled=${!allRequiredInfoFilledIn}
>
Submit
</mwc-button>
${!allRequiredInfoFilledIn
? html`
<paper-tooltip position="left">
Not all required fields are filled in.
</paper-tooltip>
`
: html``}
</div>
`;
}
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`
<paper-dialog
with-backdrop
.opened=${true}
@opened-changed=${this._openedChanged}
>
<h2>
${headerContent}
</h2>
<paper-dialog-scrollable>
${this._errorMsg
? html`
<div class="error">${this._errorMsg}</div>
`
: ""}
${description
? html`
<ha-markdown .content=${description} allow-svg></ha-markdown>
`
: ""}
${bodyContent}
</paper-dialog-scrollable>
<div class="buttons">
${buttonContent}
</div>
</paper-dialog>
`;
}
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<void> {
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<boolean>): void {
// Closed dialog by clicking on the overlay
if (this._step && !ev.detail.value) {
this._flowDone();
}
}
private _stepDataChanged(ev: PolymerChangedEvent<any>): 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;
}
}

View File

@ -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,
});
};

View File

@ -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"),

View File

@ -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`
<style include="ha-style-dialog">
.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;
}
</style>
<paper-dialog
id="dialog"
with-backdrop=""
opened="{{_opened}}"
on-opened-changed="_openedChanged"
>
<h2>
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
Aborted
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
Success!
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
[[_computeStepTitle(localize, _step)]]
</template>
</h2>
<paper-dialog-scrollable>
<template is="dom-if" if="[[_errorMsg]]">
<div class="error">[[_errorMsg]]</div>
</template>
<template is="dom-if" if="[[!_step]]">
<div class="init-spinner">
<paper-spinner active></paper-spinner>
</div>
</template>
<template is="dom-if" if="[[_step]]">
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<p>Created config for [[_step.title]]</p>
</template>
<template
is="dom-if"
if="[[_computeStepDescription(localize, _step)]]"
>
<ha-markdown
content="[[_computeStepDescription(localize, _step)]]"
allow-svg
></ha-markdown>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<ha-form
data="{{_stepData}}"
on-data-changed="_increaseCounter"
schema="[[_step.data_schema]]"
error="[[_step.errors]]"
compute-label="[[_computeLabelCallback(localize, _step)]]"
compute-error="[[_computeErrorCallback(localize, _step)]]"
></ha-form>
</template>
</template>
</paper-dialog-scrollable>
<div class="buttons">
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
<mwc-button on-click="_flowDone">Close</mwc-button>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<mwc-button on-click="_flowDone">Close</mwc-button>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<template is="dom-if" if="[[_loading]]">
<div class="submit-spinner">
<paper-spinner active></paper-spinner>
</div>
</template>
<template is="dom-if" if="[[!_loading]]">
<div>
<mwc-button on-click="_submitStep" disabled="[[!_canSubmit]]"
>Submit</mwc-button
>
<template is="dom-if" if="[[!_canSubmit]]">
<paper-tooltip position="left">
Not all required fields are filled in.
</paper-tooltip>
</template>
</div>
</template>
</template>
</div>
</paper-dialog>
`;
}
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);

View File

@ -1,9 +1,20 @@
// Force file to be a module to augment global scope.
export {};
export const applyPolymerEvent = <T>(
ev: PolymerChangedEvent<T>,
curValue: T
): T => {
const { path, value } = ev.detail;
if (!path) {
return value;
}
const propName = path.split(".")[1];
return { ...curValue, [propName]: value };
};
export interface PolymerChangedEvent<T> extends Event {
detail: {
value: T;
path?: string;
queueProperty: boolean;
};
}