Add "quick open" style dialog for selecting entities and running reload commands (#7230)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Donnie 2020-10-08 04:20:57 -07:00 committed by GitHub
parent 667c5744f2
commit 8d516ed12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 354 additions and 4 deletions

View File

@ -0,0 +1,29 @@
/**
* Determine whether a sequence of letters exists in another string,
* in that order, allowing for skipping. Ex: "chdr" exists in "chandelier")
*
* filter => sequence of letters
* word => Word to check for sequence
*
* return true if word contains sequence. Otherwise false.
*/
export const fuzzySequentialMatch = (filter: string, word: string) => {
if (filter === "") {
return true;
}
for (let i = 0; i <= filter.length; i++) {
const pos = word.indexOf(filter[0]);
if (pos < 0) {
return false;
}
const newWord = word.substring(pos + 1);
const newFilter = filter.substring(1);
return fuzzySequentialMatch(newFilter, newWord);
}
return true;
};

View File

@ -0,0 +1,225 @@
import "../../components/ha-header-bar";
import "@polymer/paper-input/paper-input";
import "@material/mwc-list/mwc-list-item";
import "@material/mwc-list/mwc-list";
import {
css,
customElement,
html,
internalProperty,
LitElement,
property,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog";
import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant, ServiceCallRequest } from "../../types";
import { PolymerChangedEvent } from "../../polymer-types";
import { fuzzySequentialMatch } from "../../common/string/sequence_matching";
import { componentsWithService } from "../../common/config/components_with_service";
import { domainIcon } from "../../common/entity/domain_icon";
import { computeDomain } from "../../common/entity/compute_domain";
import { domainToName } from "../../data/integration";
import { QuickOpenDialogParams } from "./show-dialog-quick-open";
import { HassEntity } from "home-assistant-js-websocket";
import { compare } from "../../common/string/compare";
import memoizeOne from "memoize-one";
interface CommandItem extends ServiceCallRequest {
text: string;
}
@customElement("ha-quick-open-dialog")
export class QuickOpenDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@internalProperty() private _commandItems: CommandItem[] = [];
@internalProperty() private _itemFilter = "";
@internalProperty() private _opened = false;
@internalProperty() private _commandMode = false;
public async showDialog(params: QuickOpenDialogParams) {
this._commandMode = params.commandMode || false;
this._opened = true;
this._commandItems = this._generateCommandItems();
}
public closeDialog() {
this._opened = false;
this._itemFilter = "";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._opened) {
return html``;
}
return html`
<ha-dialog .heading=${true} open @closed=${this.closeDialog} hideActions>
<paper-input
dialogInitialFocus
no-label-float
slot="heading"
class="heading"
@value-changed=${this._entityFilterChanged}
.label=${this.hass.localize(
"ui.dialogs.quick-open.filter_placeholder"
)}
type="search"
value=${this._commandMode ? `>${this._itemFilter}` : this._itemFilter}
></paper-input>
<mwc-list>
${this._commandMode
? this.renderCommandsList(this._itemFilter)
: this.renderEntityList(this._itemFilter)}
</mwc-list>
</ha-dialog>
`;
}
protected renderCommandsList = memoizeOne((filter) => {
const items = this._filterCommandItems(this._commandItems, filter);
return html`
${items.map(
({ text, domain, service, serviceData }) => html`
<mwc-list-item
@click=${this._executeCommand}
.domain=${domain}
.service=${service}
.serviceData=${serviceData}
graphic="icon"
>
<ha-icon .icon=${domainIcon(domain)} slot="graphic"></ha-icon>
${text}
</mwc-list-item>
`
)}
`;
});
protected renderEntityList = memoizeOne((filter) => {
const entities = this._filterEntityItems(
Object.keys(this.hass.states),
filter
);
return html`
${entities.map((entity_id) => {
const domain = computeDomain(entity_id);
return html`
<mwc-list-item @click=${this._entityMoreInfo} graphic="icon">
<ha-icon .icon=${domainIcon(domain)} slot="graphic"></ha-icon>
${entity_id}
</mwc-list-item>
`;
})}
`;
});
private _entityFilterChanged(ev: PolymerChangedEvent<string>) {
const newFilter = ev.detail.value;
if (newFilter.startsWith(">")) {
this._commandMode = true;
this._itemFilter = newFilter.substring(1);
} else {
this._commandMode = false;
this._itemFilter = newFilter;
}
}
private _generateCommandItems(): CommandItem[] {
const reloadableDomains = componentsWithService(this.hass, "reload").sort();
return reloadableDomains.map((domain) => ({
text:
this.hass.localize(`ui.dialogs.quick-open.commands.reload.${domain}`) ||
this.hass.localize(
"ui.dialogs.quick-open.commands.reload.reload",
"domain",
domainToName(this.hass.localize, domain)
),
domain,
service: "reload",
}));
}
private _filterCommandItems(
items: CommandItem[],
filter: string
): CommandItem[] {
return items
.filter(({ text }) =>
fuzzySequentialMatch(filter.toLowerCase(), text.toLowerCase())
)
.sort((itemA, itemB) => compare(itemA.text, itemB.text));
}
private _filterEntityItems(
entity_ids: HassEntity["entity_id"][],
filter: string
): HassEntity["entity_id"][] {
return entity_ids
.filter((entity_id) =>
fuzzySequentialMatch(filter.toLowerCase(), entity_id)
)
.sort();
}
private async _executeCommand(ev: Event) {
const target = ev.currentTarget as any;
await this.hass.callService(
target.domain,
target.service,
target.serviceData
);
this.closeDialog();
}
private _entityMoreInfo(ev: Event) {
ev.preventDefault();
fireEvent(this, "hass-more-info", {
entityId: (ev.target as any).text,
});
this.closeDialog();
}
static get styles() {
return [
haStyleDialog,
css`
.heading {
padding: 20px 20px 0px;
}
ha-dialog {
--dialog-z-index: 8;
--dialog-content-padding: 0px 24px 20px;
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-height: calc(100% - 72px);
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-quick-open-dialog": QuickOpenDialog;
}
}

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface QuickOpenDialogParams {
entityFilter?: string;
commandMode?: boolean;
}
export const loadQuickOpenDialog = () =>
import(/* webpackChunkName: "quick-open-dialog" */ "./ha-quick-open-dialog");
export const showQuickOpenDialog = (
element: HTMLElement,
dialogParams: QuickOpenDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-quick-open-dialog",
dialogImport: loadQuickOpenDialog,
dialogParams,
});
};

View File

@ -17,9 +17,10 @@ import {
import "./ha-init-page"; import "./ha-init-page";
import "./home-assistant-main"; import "./home-assistant-main";
import { storeState } from "../util/ha-pref-storage"; import { storeState } from "../util/ha-pref-storage";
import QuickOpenMixin from "../state/quick-open-mixin";
@customElement("home-assistant") @customElement("home-assistant")
export class HomeAssistantAppEl extends HassElement { export class HomeAssistantAppEl extends QuickOpenMixin(HassElement) {
@internalProperty() private _route?: Route; @internalProperty() private _route?: Route;
@internalProperty() private _error = false; @internalProperty() private _error = false;

View File

@ -0,0 +1,31 @@
import type { Constructor, PropertyValues } from "lit-element";
import { HassElement } from "./hass-element";
import {
QuickOpenDialogParams,
showQuickOpenDialog,
} from "../dialogs/quick-open/show-dialog-quick-open";
declare global {
interface HASSDomEvents {
"hass-quick-open": QuickOpenDialogParams;
}
}
export default <T extends Constructor<HassElement>>(superClass: T) =>
class extends superClass {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.code === "KeyP" && e.metaKey) {
e.preventDefault();
const eventParams: QuickOpenDialogParams = {};
if (e.shiftKey) {
eventParams.commandMode = true;
}
showQuickOpenDialog(this, eventParams);
}
});
}
};

View File

@ -434,6 +434,44 @@
} }
}, },
"dialogs": { "dialogs": {
"quick-open": {
"commands": {
"reload": {
"reload": "[%key:ui::panel::config::server_control::section::reloading::reload%]",
"core": "[%key:ui::panel::config::server_control::section::reloading::core%]",
"group": "[%key:ui::panel::config::server_control::section::reloading::group%]",
"automation": "[%key:ui::panel::config::server_control::section::reloading::automation%]",
"script": "[%key:ui::panel::config::server_control::section::reloading::script%]",
"scene": "[%key:ui::panel::config::server_control::section::reloading::scene%]",
"person": "[%key:ui::panel::config::server_control::section::reloading::person%]",
"zone": "[%key:ui::panel::config::server_control::section::reloading::zone%]",
"input_boolean": "[%key:ui::panel::config::server_control::section::reloading::input_boolean%]",
"input_text": "[%key:ui::panel::config::server_control::section::reloading::input_text%]",
"input_number": "[%key:ui::panel::config::server_control::section::reloading::input_number%]",
"input_datetime": "[%key:ui::panel::config::server_control::section::reloading::input_datetime%]",
"input_select": "[%key:ui::panel::config::server_control::section::reloading::input_select%]",
"template": "[%key:ui::panel::config::server_control::section::reloading::template%]",
"universal": "[%key:ui::panel::config::server_control::section::reloading::universal%]",
"rest": "[%key:ui::panel::config::server_control::section::reloading::rest%]",
"command_line": "[%key:ui::panel::config::server_control::section::reloading::command_line%]",
"filter": "[%key:ui::panel::config::server_control::section::reloading::filter%]",
"statistics": "[%key:ui::panel::config::server_control::section::reloading::statistics%]",
"generic": "[%key:ui::panel::config::server_control::section::reloading::generic%]",
"generic_thermostat": "[%key:ui::panel::config::server_control::section::reloading::generic_thermostat%]",
"homekit": "[%key:ui::panel::config::server_control::section::reloading::homekit%]",
"min_max": "[%key:ui::panel::config::server_control::section::reloading::min_max%]",
"history_stats": "[%key:ui::panel::config::server_control::section::reloading::history_stats%]",
"trend": "[%key:ui::panel::config::server_control::section::reloading::trend%]",
"ping": "[%key:ui::panel::config::server_control::section::reloading::ping%]",
"filesize": "[%key:ui::panel::config::server_control::section::reloading::filesize%]",
"telegram": "[%key:ui::panel::config::server_control::section::reloading::telegram%]",
"smtp": "[%key:ui::panel::config::server_control::section::reloading::smtp%]",
"mqtt": "[%key:ui::panel::config::server_control::section::reloading::mqtt%]",
"rpi_gpio": "[%key:ui::panel::config::server_control::section::reloading::rpi_gpio%]"
}
},
"filter_placeholder": "Entity Filter"
},
"voice_command": { "voice_command": {
"did_not_hear": "Home Assistant did not hear anything", "did_not_hear": "Home Assistant did not hear anything",
"found": "I found the following for you:", "found": "I found the following for you:",

View File

@ -206,6 +206,12 @@ export interface ServiceCallResponse {
context: Context; context: Context;
} }
export interface ServiceCallRequest {
domain: string;
service: string;
serviceData?: { [key: string]: any };
}
export interface HomeAssistant { export interface HomeAssistant {
auth: Auth & { external?: ExternalMessaging }; auth: Auth & { external?: ExternalMessaging };
connection: Connection; connection: Connection;
@ -239,9 +245,9 @@ export interface HomeAssistant {
userData?: CoreFrontendUserData | null; userData?: CoreFrontendUserData | null;
hassUrl(path?): string; hassUrl(path?): string;
callService( callService(
domain: string, domain: ServiceCallRequest["domain"],
service: string, service: ServiceCallRequest["service"],
serviceData?: { [key: string]: any } serviceData?: ServiceCallRequest["serviceData"]
): Promise<ServiceCallResponse>; ): Promise<ServiceCallResponse>;
callApi<T>( callApi<T>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",