Cleanup config flow (#2932)

* Break up config flow dialog

* Allow picking devices when config flow finishes

* Lint

* Tweaks
This commit is contained in:
Paulus Schoutsen 2019-03-15 10:40:18 -07:00 committed by GitHub
parent 2aec877310
commit 915c441a94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 664 additions and 226 deletions

View File

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

View File

@ -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;

View File

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

View File

@ -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<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)
@ -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`
<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 with-backdrop opened @opened-changed=${this._openedChanged}>
${this._loading
? html`
<step-flow-loading></step-flow-loading>
`
: 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`
<step-flow-form
.step=${this._step}
.hass=${this._params.hass}
></step-flow-form>
`
: this._step.type === "abort"
? html`
<step-flow-abort
.step=${this._step}
.hass=${this._params.hass}
></step-flow-abort>
`
: this._devices === undefined || this._areas === undefined
? // When it's a create entry result, we will fetch device & area registry
html`
<step-flow-loading></step-flow-loading>
`
: html`
<step-flow-create-entry
.step=${this._step}
.hass=${this._params.hass}
.devices=${this._devices}
.areas=${this._areas}
></step-flow-create-entry>
`}
</paper-dialog>
`;
}
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<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 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<void> {
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<boolean>): void {
@ -320,51 +222,17 @@ class ConfigFlowDialog extends LitElement {
}
}
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 {
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;
}
`,
];

View File

@ -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`
<h2>Aborted</h2>
<div class="content">
${
description
? html`
<ha-markdown .content=${description} allow-svg></ha-markdown>
`
: ""
}
</div>
<div class="buttons">
<mwc-button @click="${this._flowDone}">Close</mwc-button>
</div>
</paper-dialog>
`;
}
private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined });
}
static get styles(): CSSResult {
return configFlowContentStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"step-flow-abort": StepFlowAbort;
}
}

View File

@ -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`
<h2>Success!</h2>
<div class="content">
${
description
? html`
<ha-markdown .content=${description} allow-svg></ha-markdown>
`
: ""
}
<p>Created config for ${step.title}.</p>
${
this.devices.length === 0
? ""
: html`
<p>We found the following devices:</p>
<div class="devices">
${this.devices.map(
(device) =>
html`
<div class="device">
<b>${device.name}</b><br />
${device.model} (${device.manufacturer})
<paper-dropdown-menu-light
label="Area"
.device=${device.id}
@selected-item-changed=${this._handleAreaChanged}
>
<paper-listbox
slot="dropdown-content"
selected="0"
>
<paper-item>
${localize(
"ui.panel.config.integrations.config_entry.no_area"
)}
</paper-item>
${this.areas.map(
(area) => html`
<paper-item .area=${area.area_id}>
${area.name}
</paper-item>
`
)}
</paper-listbox>
</paper-dropdown-menu-light>
</div>
`
)}
</div>
`
}
</div>
<div class="buttons">
${
this.devices.length > 0
? html`
<mwc-button @click="${this._addArea}">Add Area</mwc-button>
`
: ""
}
<mwc-button @click="${this._flowDone}">Finish</mwc-button>
</div>
</paper-dialog>
`;
}
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;
}
}

View File

@ -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`
<h2>
${localize(
`component.${step.handler}.config.step.${step.step_id}.title`
)}
</h2>
<div class="content">
${this._errorMsg
? html`
<div class="error">${this._errorMsg}</div>
`
: ""}
${description
? html`
<ha-markdown .content=${description} allow-svg></ha-markdown>
`
: ""}
<ha-form
.data=${this._stepDataProcessed}
@data-changed=${this._stepDataChanged}
.schema=${step.data_schema}
.error=${step.errors}
.computeLabel=${this._labelCallback}
.computeError=${this._errorCallback}
></ha-form>
</div>
<div class="buttons">
${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>
`}
</div>
`;
}
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<void> {
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<any>): 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;
}
}

View File

@ -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`
<div class="init-spinner">
<paper-spinner-lite active></paper-spinner-lite>
</div>
`;
}
static get styles(): CSSResult {
return css`
.init-spinner {
padding: 50px 100px;
text-align: center;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"step-flow-loading": StepFlowLoading;
}
}

View File

@ -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;
}
`;