Alow setting up integrations during onboarding (#3163)

* Allow setting up integrations during onboarding

* Fix compress static

* Don't compress static files in CI

* Remove unused file

* Fix static compress disabled in CI build

* Work with new integration step

* Import fix

* Lint

* Upgrade HAWS to 4.1.1
This commit is contained in:
Paulus Schoutsen 2019-05-07 22:27:10 -07:00 committed by GitHub
parent 8c904fb012
commit 82e8ca2754
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 513 additions and 47 deletions

View File

@ -37,7 +37,11 @@ gulp.task(
"clean",
gulp.parallel("gen-icons", "build-translations"),
"copy-static",
gulp.parallel("webpack-prod-app", "compress-static"),
gulp.parallel(
"webpack-prod-app",
// Do not compress static files in CI, it's SLOW.
...(process.env.CI === "true" ? [] : ["compress-static"])
),
gulp.parallel(
"gen-pages-prod",
"gen-index-html-prod",

View File

@ -95,7 +95,7 @@ gulp.task("copy-static", (done) => {
done();
});
gulp.task("compress-static", () => compressStatic(paths.root));
gulp.task("compress-static", () => compressStatic(paths.static));
gulp.task("copy-static-demo", (done) => {
// Copy app static files

View File

@ -75,7 +75,7 @@
"es6-object-assign": "^1.1.0",
"fecha": "^3.0.2",
"hls.js": "^0.12.4",
"home-assistant-js-websocket": "^3.4.0",
"home-assistant-js-websocket": "^4.1.1",
"intl-messageformat": "^2.2.0",
"jquery": "^3.3.1",
"js-yaml": "^3.13.0",

View File

@ -14,6 +14,8 @@ export interface SignedPath {
path: string;
}
export const hassUrl = `${location.protocol}//${location.host}`;
export const getSignedPath = (
hass: HomeAssistant,
path: string

View File

@ -1,6 +1,17 @@
import { HomeAssistant } from "../types";
import { createCollection } from "home-assistant-js-websocket";
import { debounce } from "../common/util/debounce";
import { LocalizeFunc } from "../common/translations/localize";
export interface ConfigEntry {
entry_id: string;
domain: string;
title: string;
source: string;
state: string;
connection_class: string;
supports_options: boolean;
}
export interface FieldSchema {
name: string;
@ -11,7 +22,10 @@ export interface FieldSchema {
export interface ConfigFlowProgress {
flow_id: string;
handler: string;
context: { [key: string]: any };
context: {
title_placeholders: { [key: string]: string };
[key: string]: any;
};
}
export interface ConfigFlowStepForm {
@ -106,3 +120,23 @@ export const subscribeConfigFlowInProgress = (
hass.connection,
onChange
);
export const getConfigEntries = (hass: HomeAssistant) =>
hass.callApi<ConfigEntry[]>("GET", "config/config_entries/entry");
export const localizeConfigFlowTitle = (
localize: LocalizeFunc,
flow: ConfigFlowProgress
) => {
const placeholders = flow.context.title_placeholders || {};
const placeholderKeys = Object.keys(placeholders);
if (placeholderKeys.length === 0) {
return localize(`component.${flow.handler}.config.title`);
}
const args: string[] = [];
placeholderKeys.forEach((key) => {
args.push(key);
args.push(placeholders[key]);
});
return localize(`component.${flow.handler}.config.flow_title`, ...args);
};

View File

@ -1,12 +1,17 @@
import { handleFetchPromise } from "../util/hass-call-api";
import { HomeAssistant } from "../types";
export interface OnboardingUserStepResponse {
auth_code: string;
}
export interface OnboardingIntegrationStepResponse {
auth_code: string;
}
export interface OnboardingResponses {
user: OnboardingUserStepResponse;
bla: number;
integration: OnboardingIntegrationStepResponse;
}
export type ValidOnboardingStep = keyof OnboardingResponses;
@ -24,6 +29,7 @@ export const onboardUserStep = (params: {
name: string;
username: string;
password: string;
language: string;
}) =>
handleFetchPromise<OnboardingUserStepResponse>(
fetch("/api/onboarding/users", {
@ -32,3 +38,13 @@ export const onboardUserStep = (params: {
body: JSON.stringify(params),
})
);
export const onboardIntegrationStep = (
hass: HomeAssistant,
params: { client_id: string }
) =>
hass.callApi<OnboardingIntegrationStepResponse>(
"POST",
"onboarding/integration",
params
);

View File

@ -169,6 +169,18 @@ class StepFlowCreateEntry extends LitElement {
.buttons > *:last-child {
margin-left: auto;
}
paper-dropdown-menu-light {
cursor: pointer;
}
paper-item {
cursor: pointer;
white-space: nowrap;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.device {
width: auto;
}
}
`,
];
}

View File

@ -14,6 +14,7 @@ import { subscribePanels } from "../data/ws-panels";
import { subscribeThemes } from "../data/ws-themes";
import { subscribeUser } from "../data/ws-user";
import { HomeAssistant } from "../types";
import { hassUrl } from "../data/auth";
declare global {
interface Window {
@ -21,7 +22,6 @@ declare global {
}
}
const hassUrl = `${location.protocol}//${location.host}`;
const isExternal = location.search.includes("external_auth=1");
const authProm = isExternal

View File

@ -44,7 +44,7 @@
Home Assistant
</div>
<ha-onboarding>Initializing</ha-onboarding>
<ha-onboarding></ha-onboarding>
</div>
<%= renderTemplate('_js_base') %>

View File

@ -31,10 +31,10 @@ export const localizeLiteBaseMixin = (superClass) =>
return;
}
this._updateResources();
this._downloadResources();
}
private async _updateResources() {
private async _downloadResources() {
const { language, data } = await getTranslation(
this.translationFragment,
this.language

View File

@ -1,22 +1,29 @@
import {
LitElement,
html,
PropertyValues,
customElement,
TemplateResult,
property,
} from "lit-element";
import { genClientId } from "home-assistant-js-websocket";
import {
getAuth,
createConnection,
genClientId,
Auth,
} from "home-assistant-js-websocket";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import {
OnboardingStep,
ValidOnboardingStep,
OnboardingResponses,
fetchOnboardingOverview,
} from "../data/onboarding";
import { registerServiceWorker } from "../util/register-service-worker";
import { HASSDomEvent } from "../common/dom/fire_event";
import "./onboarding-create-user";
import "./onboarding-loading";
import { hassUrl } from "../data/auth";
import { HassElement } from "../state/hass-element";
interface OnboardingEvent<T extends ValidOnboardingStep> {
type: T;
@ -34,43 +41,55 @@ declare global {
}
@customElement("ha-onboarding")
class HaOnboarding extends litLocalizeLiteMixin(LitElement) {
class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
public translationFragment = "page-onboarding";
@property() private _loading = false;
@property() private _steps?: OnboardingStep[];
protected render(): TemplateResult | void {
if (!this._steps) {
const step = this._curStep()!;
if (this._loading || !step) {
return html`
<onboarding-loading></onboarding-loading>
`;
}
const step = this._steps.find((stp) => !stp.done)!;
if (step.step === "user") {
} else if (step.step === "user") {
return html`
<onboarding-create-user
.localize=${this.localize}
.language=${this.language}
></onboarding-create-user>
`;
} else if (step.step === "integration") {
return html`
<onboarding-integrations
.hass=${this.hass}
.onboardingLocalize=${this.localize}
></onboarding-integrations>
`;
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._fetchOnboardingSteps();
import("./onboarding-integrations");
registerServiceWorker(false);
this.addEventListener("onboarding-step", (ev) => this._handleStepDone(ev));
}
private _curStep() {
return this._steps ? this._steps.find((stp) => !stp.done) : undefined;
}
private async _fetchOnboardingSteps() {
try {
const response = await window.stepsPromise;
const response = await (window.stepsPromise || fetchOnboardingOverview());
if (response.status === 404) {
// We don't load the component when onboarding is done
document.location.href = "/";
document.location.assign("/");
return;
}
@ -78,7 +97,16 @@ class HaOnboarding extends litLocalizeLiteMixin(LitElement) {
if (steps.every((step) => step.done)) {
// Onboarding is done!
document.location.href = "/";
document.location.assign("/");
return;
}
if (steps[0].done) {
// First step is already done, so we need to get auth somewhere else.
const auth = await getAuth({
hassUrl,
});
await this._connectHass(auth);
}
this._steps = steps;
@ -91,20 +119,52 @@ class HaOnboarding extends litLocalizeLiteMixin(LitElement) {
ev: HASSDomEvent<OnboardingEvent<ValidOnboardingStep>>
) {
const stepResult = ev.detail;
this._steps = this._steps!.map((step) =>
step.step === stepResult.type ? { ...step, done: true } : step
);
if (stepResult.type === "user") {
const result = stepResult.result as OnboardingResponses["user"];
this._loading = true;
try {
const auth = await getAuth({
hassUrl,
authCode: result.auth_code,
});
await this._connectHass(auth);
} catch (err) {
alert("Ah snap, something went wrong!");
location.reload();
} finally {
this._loading = false;
}
} else if (stepResult.type === "integration") {
const result = stepResult.result as OnboardingResponses["integration"];
this._loading = true;
// Revoke current auth token.
await this.hass!.auth.revoke();
const state = btoa(
JSON.stringify({
hassUrl: `${location.protocol}//${location.host}`,
clientId: genClientId(),
})
);
document.location.href = `/?auth_callback=1&code=${encodeURIComponent(
result.auth_code
)}&state=${state}`;
document.location.assign(
`/?auth_callback=1&code=${encodeURIComponent(
result.auth_code
)}&state=${state}`
);
}
}
private async _connectHass(auth: Auth) {
const conn = await createConnection({ auth });
this.initializeHass(auth, conn);
// Load config strings for integrations
(this as any)._loadFragmentTranslations(this.hass!.language, "config");
}
}
declare global {

View File

@ -0,0 +1,86 @@
import {
LitElement,
TemplateResult,
html,
customElement,
property,
CSSResult,
css,
} from "lit-element";
import "../components/ha-icon";
@customElement("integration-badge")
class IntegrationBadge extends LitElement {
@property() public icon!: string;
@property() public title!: string;
@property() public badgeIcon?: string;
@property({ type: Boolean, reflect: true }) public clickable = false;
protected render(): TemplateResult | void {
return html`
<div class="icon">
<iron-icon .icon=${this.icon}></iron-icon>
${this.badgeIcon
? html`
<ha-icon class="badge" .icon=${this.badgeIcon}></ha-icon>
`
: ""}
</div>
<div class="title">${this.title}</div>
`;
}
static get styles(): CSSResult {
return css`
:host {
display: inline-flex;
flex-direction: column;
text-align: center;
color: var(--primary-text-color);
}
:host([clickable]) {
color: var(--primary-text-color);
}
.icon {
position: relative;
margin: 0 auto 8px;
height: 40px;
width: 40px;
border-radius: 50%;
border: 1px solid var(--secondary-text-color);
display: flex;
align-items: center;
justify-content: center;
}
:host([clickable]) .icon {
border-color: var(--primary-color);
border-width: 2px;
}
.badge {
position: absolute;
color: var(--primary-color);
bottom: -5px;
right: -5px;
background-color: white;
border-radius: 50%;
width: 18px;
display: block;
height: 18px;
}
.title {
min-height: 2.3em;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"integration-badge": IntegrationBadge;
}
}

View File

@ -19,6 +19,7 @@ import { fireEvent } from "../common/dom/fire_event";
@customElement("onboarding-create-user")
class OnboardingCreateUser extends LitElement {
@property() public localize!: LocalizeFunc;
@property() public language!: string;
@property() private _name = "";
@property() private _username = "";
@ -173,6 +174,7 @@ class OnboardingCreateUser extends LitElement {
name: this._name,
username: this._username,
password: this._password,
language: this.language,
});
fireEvent(this, "onboarding-step", {

View File

@ -0,0 +1,196 @@
import {
LitElement,
TemplateResult,
html,
customElement,
PropertyValues,
property,
CSSResult,
css,
} from "lit-element";
import "@material/mwc-button/mwc-button";
import {
loadConfigFlowDialog,
showConfigFlowDialog,
} from "../dialogs/config-flow/show-dialog-config-flow";
import { HomeAssistant } from "../types";
import {
getConfigFlowsInProgress,
getConfigEntries,
ConfigEntry,
ConfigFlowProgress,
localizeConfigFlowTitle,
} from "../data/config_entries";
import { compare } from "../common/string/compare";
import "./integration-badge";
import { LocalizeFunc } from "../common/translations/localize";
import { debounce } from "../common/util/debounce";
import { fireEvent } from "../common/dom/fire_event";
import { onboardIntegrationStep } from "../data/onboarding";
import { genClientId } from "home-assistant-js-websocket";
@customElement("onboarding-integrations")
class OnboardingIntegrations extends LitElement {
@property() public hass!: HomeAssistant;
@property() public onboardingLocalize!: LocalizeFunc;
@property() private _entries?: ConfigEntry[];
@property() private _discovered?: ConfigFlowProgress[];
private _unsubEvents?: () => void;
public connectedCallback() {
super.connectedCallback();
this.hass.connection
.subscribeEvents(
debounce(() => this._loadData(), 500),
"config_entry_discovered"
)
.then((unsub) => {
this._unsubEvents = unsub;
});
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubEvents) {
this._unsubEvents();
}
}
protected render(): TemplateResult | void {
if (!this._entries || !this._discovered) {
return html``;
}
// Render discovered and existing entries together sorted by localized title.
const entries: Array<[string, TemplateResult]> = this._entries.map(
(entry) => {
const title = this.hass.localize(
`component.${entry.domain}.config.title`
);
return [
title,
html`
<integration-badge
.title=${title}
icon="hass:check"
></integration-badge>
`,
];
}
);
const discovered: Array<[string, TemplateResult]> = this._discovered.map(
(flow) => {
const title = localizeConfigFlowTitle(this.hass.localize, flow);
return [
title,
html`
<button .flowId=${flow.flow_id} @click=${this._continueFlow}>
<integration-badge
clickable
.title=${title}
icon="hass:plus"
></integration-badge>
</button>
`,
];
}
);
const content = [...entries, ...discovered]
.sort((a, b) => compare(a[0], b[0]))
.map((item) => item[1]);
return html`
<p>
${this.onboardingLocalize("ui.panel.page-onboarding.integration.intro")}
</p>
<div class="badges">
${content}
<button @click=${this._createFlow}>
<integration-badge
clickable
title=${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.more_integrations"
)}
icon="hass:dots-horizontal"
></integration-badge>
</button>
</div>
<div class="footer">
<mwc-button @click=${this._finish}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.finish"
)}
</mwc-button>
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
loadConfigFlowDialog();
this._loadData();
/* polyfill for paper-dropdown */
import(/* webpackChunkName: "polyfill-web-animations-next" */ "web-animations-js/web-animations-next-lite.min");
}
private _createFlow() {
showConfigFlowDialog(this, {
dialogClosedCallback: () => this._loadData(),
});
}
private _continueFlow(ev) {
showConfigFlowDialog(this, {
continueFlowId: ev.currentTarget.flowId,
dialogClosedCallback: () => this._loadData(),
});
}
private async _loadData() {
const [discovered, entries] = await Promise.all([
getConfigFlowsInProgress(this.hass!),
getConfigEntries(this.hass!),
]);
this._discovered = discovered;
this._entries = entries;
}
private async _finish() {
const result = await onboardIntegrationStep(this.hass, {
client_id: genClientId(),
});
fireEvent(this, "onboarding-step", {
type: "integration",
result,
});
}
static get styles(): CSSResult {
return css`
.badges {
margin-top: 24px;
}
.badges > * {
width: 24%;
min-width: 90px;
margin-bottom: 24px;
}
button {
display: inline-block;
cursor: pointer;
padding: 0;
border: 0;
background: 0;
font: inherit;
}
.footer {
text-align: right;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-integrations": OnboardingIntegrations;
}
}

View File

@ -1,10 +1,64 @@
import { LitElement, TemplateResult, html, customElement } from "lit-element";
import {
LitElement,
TemplateResult,
html,
customElement,
CSSResult,
css,
} from "lit-element";
@customElement("onboarding-loading")
class OnboardingLoading extends LitElement {
protected render(): TemplateResult | void {
return html`
Loading
<div class="loader"></div>
`;
}
static get styles(): CSSResult {
return css`
/* MIT License (MIT). Copyright (c) 2014 Luke Haas */
.loader,
.loader:after {
border-radius: 50%;
width: 40px;
height: 40px;
}
.loader {
margin: 60px auto;
font-size: 4px;
position: relative;
text-indent: -9999em;
border-top: 1.1em solid rgba(3, 169, 244, 0.2);
border-right: 1.1em solid rgba(3, 169, 244, 0.2);
border-bottom: 1.1em solid rgba(3, 169, 244, 0.2);
border-left: 1.1em solid rgb(3, 168, 244);
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load8 1.4s infinite linear;
animation: load8 1.4s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
`;
}
}

View File

@ -23,6 +23,7 @@ import {
loadConfigFlowDialog,
showConfigFlowDialog,
} from "../../../dialogs/config-flow/show-dialog-config-flow";
import { localizeConfigFlowTitle } from "../../../data/config_entries";
/*
* @appliesMixin LocalizeMixin
@ -207,21 +208,8 @@ class HaConfigManagerDashboard extends LocalizeMixin(
return localize(`component.${integration}.config.title`);
}
_computeActiveFlowTitle(localize, integration) {
const placeholders = integration.context.title_placeholders || {};
const placeholderKeys = Object.keys(placeholders);
if (placeholderKeys.length === 0) {
return localize(`component.${integration.handler}.config.title`);
}
const args = [];
placeholderKeys.forEach((key) => {
args.push(key);
args.push(placeholders[key]);
});
return localize(
`component.${integration.handler}.config.flow_title`,
...args
);
_computeActiveFlowTitle(localize, flow) {
return localizeConfigFlowTitle(localize, flow);
}
_computeConfigEntryEntities(hass, configEntry, entities) {

View File

@ -107,6 +107,7 @@ export const connectionMixin = (
return resp;
},
...getState(),
...this._pendingHass,
};
this.hassConnected();

View File

@ -10,6 +10,7 @@ import { HomeAssistant } from "../types";
export class HassBaseEl {
protected hass?: HomeAssistant;
protected _pendingHass: Partial<HomeAssistant> = {};
protected initializeHass(_auth: Auth, _conn: Connection) {}
protected hassConnected() {}
protected hassReconnected() {}
@ -23,6 +24,7 @@ export class HassBaseEl {
export default <T>(superClass: Constructor<T>): Constructor<T & HassBaseEl> =>
// @ts-ignore
class extends superClass {
protected _pendingHass: Partial<HomeAssistant> = {};
private __provideHass: HTMLElement[] = [];
// @ts-ignore
@property() protected hass: HomeAssistant;
@ -55,7 +57,11 @@ export default <T>(superClass: Constructor<T>): Constructor<T & HassBaseEl> =>
el.hass = this.hass;
}
protected async _updateHass(obj) {
protected async _updateHass(obj: Partial<HomeAssistant>) {
if (!this.hass) {
this._pendingHass = { ...this._pendingHass, ...obj };
return;
}
this.hass = { ...this.hass, ...obj };
}
};

View File

@ -115,7 +115,7 @@ export default (superClass: Constructor<LitElement & HassBaseEl>) =>
},
};
const changes: Partial<HomeAssistant> = { resources };
if (language === this.hass!.language) {
if (this.hass && language === this.hass.language) {
changes.localize = computeLocalize(this, language, resources);
}
this._updateHass(changes);

View File

@ -1172,6 +1172,11 @@
"required_fields": "Fill in all required fields",
"password_not_match": "Passwords don't match"
}
},
"integration": {
"intro": "Devices and services are represented in Home Assistant as integrations. You can set them up now, or do it later from the configuration screen.",
"more_integrations": "More",
"finish": "Finish"
}
}
}

View File

@ -7237,10 +7237,10 @@ hoek@6.x.x:
resolved "https://registry.yarnpkg.com/hoek/-/hoek-6.1.3.tgz#73b7d33952e01fe27a38b0457294b79dd8da242c"
integrity sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==
home-assistant-js-websocket@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-3.4.0.tgz#3ba47cc8f8b7620619a675e7488d6108e8733a70"
integrity sha512-Uq5/KIAh4kF13MKzMyd0efBDoU+pNF0O1CfdGpSmT3La3tpt5h+ykpUYlq/vEBj6WwzU6iv3Czt4UK1o0IJHcA==
home-assistant-js-websocket@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-4.1.1.tgz#b85152c223a20bfe8827b817b927fd97cc021157"
integrity sha512-hNk8bj9JObd3NpgQ1+KtQCbSoz/TWockC8T/L8KvsPrDtkl1oQddajirumaMDgrJg/su4QsxFNUcDPGJyJ05UA==
homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
version "1.0.3"