mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 01:06:35 +00:00
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:
parent
667c5744f2
commit
8d516ed12a
29
src/common/string/sequence_matching.ts
Normal file
29
src/common/string/sequence_matching.ts
Normal 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;
|
||||
};
|
225
src/dialogs/quick-open/ha-quick-open-dialog.ts
Normal file
225
src/dialogs/quick-open/ha-quick-open-dialog.ts
Normal 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;
|
||||
}
|
||||
}
|
20
src/dialogs/quick-open/show-dialog-quick-open.ts
Normal file
20
src/dialogs/quick-open/show-dialog-quick-open.ts
Normal 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,
|
||||
});
|
||||
};
|
@ -17,9 +17,10 @@ import {
|
||||
import "./ha-init-page";
|
||||
import "./home-assistant-main";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import QuickOpenMixin from "../state/quick-open-mixin";
|
||||
|
||||
@customElement("home-assistant")
|
||||
export class HomeAssistantAppEl extends HassElement {
|
||||
export class HomeAssistantAppEl extends QuickOpenMixin(HassElement) {
|
||||
@internalProperty() private _route?: Route;
|
||||
|
||||
@internalProperty() private _error = false;
|
||||
|
31
src/state/quick-open-mixin.ts
Normal file
31
src/state/quick-open-mixin.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -434,6 +434,44 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"did_not_hear": "Home Assistant did not hear anything",
|
||||
"found": "I found the following for you:",
|
||||
|
12
src/types.ts
12
src/types.ts
@ -206,6 +206,12 @@ export interface ServiceCallResponse {
|
||||
context: Context;
|
||||
}
|
||||
|
||||
export interface ServiceCallRequest {
|
||||
domain: string;
|
||||
service: string;
|
||||
serviceData?: { [key: string]: any };
|
||||
}
|
||||
|
||||
export interface HomeAssistant {
|
||||
auth: Auth & { external?: ExternalMessaging };
|
||||
connection: Connection;
|
||||
@ -239,9 +245,9 @@ export interface HomeAssistant {
|
||||
userData?: CoreFrontendUserData | null;
|
||||
hassUrl(path?): string;
|
||||
callService(
|
||||
domain: string,
|
||||
service: string,
|
||||
serviceData?: { [key: string]: any }
|
||||
domain: ServiceCallRequest["domain"],
|
||||
service: ServiceCallRequest["service"],
|
||||
serviceData?: ServiceCallRequest["serviceData"]
|
||||
): Promise<ServiceCallResponse>;
|
||||
callApi<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
|
Loading…
x
Reference in New Issue
Block a user