From eac13980ff48e6c124a0aa838b6ff9236742bdc4 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 21 Sep 2022 14:42:51 +0200 Subject: [PATCH] Add navigation picker for dashboards (#13826) * Add navigation picker for dashboards * Rename to navigation * Fix empty title and path * Use hass panels instead of fetching dashboards * Apply suggestions --- src/common/string/title-case.ts | 4 + src/components/ha-navigation-picker.ts | 221 ++++++++++++++++++ .../ha-selector/ha-selector-navigation.ts | 47 ++++ src/components/ha-selector/ha-selector.ts | 1 + src/data/selector.ts | 6 + .../lovelace/components/hui-action-editor.ts | 21 +- .../config-elements/hui-area-card-editor.ts | 6 +- 7 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 src/common/string/title-case.ts create mode 100644 src/components/ha-navigation-picker.ts create mode 100644 src/components/ha-selector/ha-selector-navigation.ts diff --git a/src/common/string/title-case.ts b/src/common/string/title-case.ts new file mode 100644 index 0000000000..f089f6520c --- /dev/null +++ b/src/common/string/title-case.ts @@ -0,0 +1,4 @@ +export const titleCase = (s) => + s.replace(/^_*(.)|_+(.)/g, (_s, c, d) => + c ? c.toUpperCase() : " " + d.toUpperCase() + ); diff --git a/src/components/ha-navigation-picker.ts b/src/components/ha-navigation-picker.ts new file mode 100644 index 0000000000..84c1084b30 --- /dev/null +++ b/src/components/ha-navigation-picker.ts @@ -0,0 +1,221 @@ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { titleCase } from "../common/string/title-case"; +import { + fetchConfig, + LovelaceConfig, + LovelaceViewConfig, +} from "../data/lovelace"; +import { PolymerChangedEvent } from "../polymer-types"; +import { HomeAssistant, PanelInfo } from "../types"; +import "./ha-combo-box"; +import type { HaComboBox } from "./ha-combo-box"; +import "./ha-icon"; + +type NavigationItem = { + path: string; + icon: string; + title: string; +}; + +const DEFAULT_ITEMS: NavigationItem[] = [{ path: "", icon: "", title: "" }]; + +// eslint-disable-next-line lit/prefer-static-styles +const rowRenderer: ComboBoxLitRenderer = (item) => html` + + + ${item.title || item.path} + ${item.path} + +`; + +const createViewNavigationItem = ( + prefix: string, + view: LovelaceViewConfig, + index: number +) => ({ + path: `/${prefix}/${view.path ?? index}`, + icon: view.icon ?? "mdi:view-compact", + title: view.title ?? (view.path ? titleCase(view.path) : `${index}`), +}); + +const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({ + path: `/${panel.url_path}`, + icon: panel.icon ?? "mdi:view-dashboard", + title: + panel.url_path === hass.defaultPanel + ? hass.localize("panel.states") + : hass.localize(`panel.${panel.title}`) || + panel.title || + (panel.url_path ? titleCase(panel.url_path) : ""), +}); + +@customElement("ha-navigation-picker") +export class HaNavigationPicker extends LitElement { + @property() public hass?: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _opened = false; + + private navigationItemsLoaded = false; + + private navigationItems: NavigationItem[] = DEFAULT_ITEMS; + + @query("ha-combo-box", true) private comboBox!: HaComboBox; + + protected render(): TemplateResult { + return html` + + + `; + } + + private async _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + if (this._opened && !this.navigationItemsLoaded) { + this._loadNavigationItems(); + } + } + + private async _loadNavigationItems() { + this.navigationItemsLoaded = true; + + const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({ + id, + ...panel, + })); + const lovelacePanels = panels.filter( + (panel) => panel.component_name === "lovelace" + ); + + const viewConfigs = await Promise.all( + lovelacePanels.map((panel) => + fetchConfig( + this.hass!.connection, + // path should be null to fetch default lovelace panel + panel.url_path === "lovelace" ? null : panel.url_path, + true + ) + .then((config) => [panel.id, config] as [string, LovelaceConfig]) + .catch((_) => [panel.id, undefined] as [string, undefined]) + ) + ); + + const panelViewConfig = new Map(viewConfigs); + + this.navigationItems = []; + + for (const panel of panels) { + this.navigationItems.push(createPanelNavigationItem(this.hass!, panel)); + + const config = panelViewConfig.get(panel.id); + + if (!config) continue; + + config.views.forEach((view, index) => + this.navigationItems.push( + createViewNavigationItem(panel.url_path, view, index) + ) + ); + } + + this.comboBox.filteredItems = this.navigationItems; + } + + protected shouldUpdate(changedProps: PropertyValues) { + return !this._opened || changedProps.has("_opened"); + } + + private _valueChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + this._setValue(ev.detail.value); + } + + private _setValue(value: string) { + this.value = value; + fireEvent( + this, + "value-changed", + { value: this._value }, + { + bubbles: false, + composed: false, + } + ); + } + + private _filterChanged(ev: CustomEvent): void { + const filterString = ev.detail.value.toLowerCase(); + const characterCount = filterString.length; + if (characterCount >= 2) { + const filteredItems: NavigationItem[] = []; + + this.navigationItems.forEach((item) => { + if ( + item.path.toLowerCase().includes(filterString) || + item.title.toLowerCase().includes(filterString) + ) { + filteredItems.push(item); + } + }); + + if (filteredItems.length > 0) { + this.comboBox.filteredItems = filteredItems; + } else { + this.comboBox.filteredItems = []; + } + } else { + this.comboBox.filteredItems = this.navigationItems; + } + } + + private get _value() { + return this.value || ""; + } + + static get styles() { + return css` + ha-icon, + ha-svg-icon { + color: var(--primary-text-color); + position: relative; + bottom: 0px; + } + *[slot="prefix"] { + margin-right: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-navigation-picker": HaNavigationPicker; + } +} diff --git a/src/components/ha-selector/ha-selector-navigation.ts b/src/components/ha-selector/ha-selector-navigation.ts new file mode 100644 index 0000000000..e275c47d3a --- /dev/null +++ b/src/components/ha-selector/ha-selector-navigation.ts @@ -0,0 +1,47 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { NavigationSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-navigation-picker"; + +@customElement("ha-selector-navigation") +export class HaNavigationSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: NavigationSelector; + + @property() public value?: string; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + return html` + + `; + } + + private _valueChanged(ev: CustomEvent) { + fireEvent(this, "value-changed", { value: ev.detail.value }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-navigation": HaNavigationSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 40f22e1a13..bc177836f5 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -16,6 +16,7 @@ import "./ha-selector-device"; import "./ha-selector-duration"; import "./ha-selector-entity"; import "./ha-selector-file"; +import "./ha-selector-navigation"; import "./ha-selector-number"; import "./ha-selector-object"; import "./ha-selector-select"; diff --git a/src/data/selector.ts b/src/data/selector.ts index 9b1e570fb4..c82a8e3a06 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -21,6 +21,7 @@ export type Selector = | IconSelector | LocationSelector | MediaSelector + | NavigationSelector | NumberSelector | ObjectSelector | SelectSelector @@ -171,6 +172,11 @@ export interface MediaSelectorValue { }; } +export interface NavigationSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + navigation: {}; +} + export interface NumberSelector { number: { min?: number; diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 0874c7e3b8..86d98726e4 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -14,6 +14,7 @@ import { import { ServiceAction } from "../../../data/script"; import { HomeAssistant } from "../../../types"; import { EditorTarget } from "../editor/types"; +import "../../../components/ha-navigation-picker"; @customElement("hui-action-editor") export class HuiActionEditor extends LitElement { @@ -89,14 +90,14 @@ export class HuiActionEditor extends LitElement { ${this.config?.action === "navigate" ? html` - + @value-changed=${this._navigateValueChanged} + > ` : ""} ${this.config?.action === "url" @@ -193,6 +194,16 @@ export class HuiActionEditor extends LitElement { fireEvent(this, "value-changed", { value }); } + private _navigateValueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const value = { + ...this.config!, + navigation_path: ev.detail.value, + }; + + fireEvent(this, "value-changed", { value }); + } + static get styles(): CSSResultGroup { return css` .dropdown { diff --git a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts index c1731bf95f..16c00d9224 100644 --- a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts @@ -26,7 +26,11 @@ const SCHEMA = [ name: "", type: "grid", schema: [ - { name: "navigation_path", required: false, selector: { text: {} } }, + { + name: "navigation_path", + required: false, + selector: { navigation: {} }, + }, { name: "theme", required: false, selector: { theme: {} } }, ], },