Add icon to areas (#19585)

* Add icon to areas

* Fix gallery

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2024-01-31 14:18:43 +01:00 committed by GitHub
parent b159f4c074
commit f4859320eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 176 additions and 168 deletions

View File

@ -10,6 +10,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../../src/types";
@ -97,22 +98,25 @@ const DEVICES = [
},
];
const AREAS = [
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
name: "Backyard",
icon: null,
picture: null,
aliases: [],
},
{
area_id: "bedroom",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
},
{
area_id: "livingroom",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
},

View File

@ -9,6 +9,7 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import { BlueprintInput } from "../../../../src/data/blueprint";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import { getEntity } from "../../../../src/fake_data/entity";
@ -93,22 +94,25 @@ const DEVICES = [
},
];
const AREAS = [
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
name: "Backyard",
icon: null,
picture: null,
aliases: [],
},
{
area_id: "bedroom",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
},
{
area_id: "livingroom",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
},

View File

@ -1,6 +1,6 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
@ -36,8 +36,12 @@ type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.area_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@ -135,6 +139,7 @@ export class HaAreaPicker extends LitElement {
area_id: "no_areas",
name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null,
icon: null,
aliases: [],
},
];
@ -262,7 +267,9 @@ export class HaAreaPicker extends LitElement {
}
if (areaIds) {
outputAreas = areas.filter((area) => areaIds!.includes(area.area_id));
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
@ -277,6 +284,7 @@ export class HaAreaPicker extends LitElement {
area_id: "no_areas",
name: this.hass.localize("ui.components.area-picker.no_match"),
picture: null,
icon: null,
aliases: [],
},
];
@ -290,6 +298,7 @@ export class HaAreaPicker extends LitElement {
area_id: "add_new",
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
},
];

View File

@ -98,6 +98,7 @@ export class HaTargetPicker extends LitElement {
area_id,
area?.name || area_id,
undefined,
area?.icon,
mdiSofa
);
})
@ -110,6 +111,7 @@ export class HaTargetPicker extends LitElement {
device_id,
device ? computeDeviceName(device, this.hass) : device_id,
undefined,
undefined,
mdiDevices
);
})
@ -209,7 +211,8 @@ export class HaTargetPicker extends LitElement {
id: string,
name: string,
entityState?: HassEntity,
iconPath?: string
icon?: string | null,
fallbackIconPath?: string
) {
return html`
<div
@ -217,12 +220,17 @@ export class HaTargetPicker extends LitElement {
[type]: true,
})}"
>
${iconPath
? html`<ha-svg-icon
${icon
? html`<ha-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${iconPath}
></ha-svg-icon>`
: ""}
.icon=${icon}
></ha-icon>`
: fallbackIconPath
? html`<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${fallbackIconPath}
></ha-svg-icon>`
: ""}
${entityState
? html`<ha-state-icon
class="mdc-chip__icon mdc-chip__icon--leading"

View File

@ -9,6 +9,7 @@ export interface AreaRegistryEntry {
area_id: string;
name: string;
picture: string | null;
icon: string | null;
aliases: string[];
}
@ -23,6 +24,7 @@ export interface AreaDeviceLookup {
export interface AreaRegistryEntryMutableParams {
name: string;
picture?: string | null;
icon?: string | null;
aliases?: string[];
}

View File

@ -1,19 +1,12 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { AreaRegistryEntry } from "./area_registry";
import { debounce } from "../common/util/debounce";
import { AreaRegistryEntry } from "./area_registry";
const fetchAreaRegistry = (conn: Connection) =>
conn
.sendMessagePromise({
type: "config/area_registry/list",
})
.then((areas) =>
(areas as AreaRegistryEntry[]).sort((ent1, ent2) =>
stringCompare(ent1.name, ent2.name)
)
);
conn.sendMessagePromise<AreaRegistryEntry[]>({
type: "config/area_registry/list",
});
const subscribeAreaRegistryUpdates = (
conn: Connection,

View File

@ -9,6 +9,7 @@ import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-icon-picker";
import "../../../components/ha-textfield";
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
@ -32,6 +33,8 @@ class DialogAreaDetail extends LitElement {
@state() private _picture!: string | null;
@state() private _icon!: string | null;
@state() private _error?: string;
@state() private _params?: AreaRegistryDetailDialogParams;
@ -46,6 +49,7 @@ class DialogAreaDetail extends LitElement {
this._name = this._params.entry ? this._params.entry.name : "";
this._aliases = this._params.entry ? this._params.entry.aliases : [];
this._picture = this._params.entry?.picture || null;
this._icon = this._params.entry?.icon || null;
await this.updateComplete;
}
@ -101,6 +105,13 @@ class DialogAreaDetail extends LitElement {
dialogInitialFocus
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
></ha-icon-picker>
<ha-picture-upload
.hass=${this.hass}
.value=${this._picture}
@ -152,23 +163,30 @@ class DialogAreaDetail extends LitElement {
this._name = ev.target.value;
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
}
private async _updateEntry() {
const create = !this._params!.entry;
this._submitting = true;
try {
const values: AreaRegistryEntryMutableParams = {
name: this._name.trim(),
picture: this._picture,
picture: this._picture || (create ? undefined : null),
icon: this._icon || (create ? undefined : null),
aliases: this._aliases,
};
if (this._params!.entry) {
await this._params!.updateEntry!(values);
} else {
if (create) {
await this._params!.createEntry!(values);
} else {
await this._params!.updateEntry!(values);
}
this.closeDialog();
} catch (err: any) {
@ -189,6 +207,7 @@ class DialogAreaDetail extends LitElement {
haStyleDialog,
css`
ha-textfield,
ha-icon-picker,
ha-picture-upload {
display: block;
margin-bottom: 16px;

View File

@ -1,11 +1,9 @@
import { consume } from "@lit-labs/context";
import "@material/mwc-button";
import "@material/mwc-list";
import { mdiDelete, mdiDotsVertical, mdiImagePlus, mdiPencil } from "@mdi/js";
import {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { HassEntity } from "home-assistant-js-websocket/dist/types";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
@ -18,33 +16,31 @@ import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import {
AreaRegistryEntry,
deleteAreaRegistryEntry,
subscribeAreaRegistry,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
import { AutomationEntity } from "../../../data/automation";
import { fullEntitiesContext } from "../../../data/context";
import {
computeDeviceName,
DeviceRegistryEntry,
computeDeviceName,
sortDeviceRegistryByName,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
computeEntityRegistryName,
EntityRegistryEntry,
computeEntityRegistryName,
sortEntityRegistryByName,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { SceneEntity } from "../../../data/scene";
import { ScriptEntity } from "../../../data/script";
import { findRelated, RelatedResult } from "../../../data/search";
import { RelatedResult, findRelated } from "../../../data/search";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook";
@ -52,7 +48,6 @@ import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import "../../../components/ha-list-item";
declare type NameAndEntity<EntityType extends HassEntity> = {
name: string;
@ -60,7 +55,7 @@ declare type NameAndEntity<EntityType extends HassEntity> = {
};
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class HaConfigAreaPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public areaId!: string;
@ -71,24 +66,14 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public showAdvanced = false;
@state() public _areas!: AreaRegistryEntry[];
@state() public _devices!: DeviceRegistryEntry[];
@state() public _entities!: EntityRegistryEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state() private _related?: RelatedResult;
private _logbookTime = { recent: 86400 };
private _area = memoizeOne(
(
areaId: string,
areas: AreaRegistryEntry[]
): AreaRegistryEntry | undefined =>
areas.find((area) => area.area_id === areaId)
);
private _memberships = memoizeOne(
(
areaId: string,
@ -150,26 +135,12 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._devices = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entities = entries;
}),
];
}
protected render() {
if (!this._areas || !this._devices || !this._entities) {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
}
const area = this._area(this.areaId, this._areas);
const area = this.hass.areas[this.areaId];
if (!area) {
return html`
@ -182,8 +153,8 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
const memberships = this._memberships(
this.areaId,
this._devices,
this._entities
Object.values(this.hass.devices),
this._entityReg
);
const { devices, entities } = memberships;
@ -617,7 +588,7 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
private _renderScript(name: string, entityState: ScriptEntity) {
const entry = this._entities.find(
const entry = this._entityReg.find(
(e) => e.entity_id === entityState.entity_id
);
let url = `/config/script/show/${entityState.entity_id}`;
@ -657,7 +628,7 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
private async _deleteConfirm() {
const area = this._area(this.areaId, this._areas);
const area = this.hass.areas[this.areaId];
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_title",
@ -686,7 +657,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
font-weight: 500;
color: var(--secondary-text-color);
}
img {
border-radius: var(--ha-card-border-radius, 12px);
width: 100%;

View File

@ -1,7 +1,13 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
CSSResultGroup,
LitElement,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { formatListWithAnds } from "../../../common/string/format-list";
@ -11,19 +17,9 @@ import "../../../components/ha-svg-icon";
import {
AreaRegistryEntry,
createAreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
@ -33,7 +29,7 @@ import {
} from "./show-dialog-area-registry-detail";
@customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@ -42,24 +38,18 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@state() private _areas!: AreaRegistryEntry[];
@state() private _devices!: DeviceRegistryEntry[];
@state() private _entities!: EntityRegistryEntry[];
private _processAreas = memoizeOne(
(
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryEntry[]
) =>
areas.map((area) => {
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"]
) => {
const processArea = (area: AreaRegistryEntry) => {
let noDevicesInArea = 0;
let noServicesInArea = 0;
let noEntitiesInArea = 0;
for (const device of devices) {
for (const device of Object.values(devices)) {
if (device.area_id === area.area_id) {
if (device.entry_type === "service") {
noServicesInArea++;
@ -69,7 +59,7 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
}
}
for (const entity of entities) {
for (const entity of Object.values(entities)) {
if (entity.area_id === area.area_id) {
noEntitiesInArea++;
}
@ -81,24 +71,22 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
services: noServicesInArea,
entities: noEntitiesInArea,
};
})
};
return Object.values(areas).map(processArea);
}
);
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._devices = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entities = entries;
}),
];
}
protected render(): TemplateResult {
const areas =
!this.hass.areas || !this.hass.devices || !this.hass.entities
? undefined
: this._processAreas(
this.hass.areas,
this.hass.devices,
this.hass.entities
);
return html`
<hass-tabs-subpage
.hass=${this.hass}
@ -115,52 +103,11 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
@click=${this._showHelp}
></ha-icon-button>
<div class="container">
${!this._areas || !this._devices || !this._entities
? ""
: this._processAreas(
this._areas,
this._devices,
this._entities
).map(
(area) =>
html`<a href=${`/config/areas/area/${area.area_id}`}
><ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture
? `url(${area.picture})`
: undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
></div>
<h1 class="card-header">${area.name}</h1>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
area.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: area.devices }
),
area.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: area.services }
),
area.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: area.entities }
),
].filter((v): v is string => Boolean(v))
)}
</div>
</div>
</ha-card></a
>`
)}
${areas?.length
? html`<div class="areas">
${areas.map((area) => this._renderArea(area))}
</div>`
: nothing}
</div>
<ha-fab
slot="fab"
@ -176,13 +123,55 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
`;
}
private _renderArea(area) {
return html`<a href=${`/config/areas/area/${area.area_id}`}>
<ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture ? `url(${area.picture})` : undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
>
${!area.picture && area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: ""}
</div>
<h1 class="card-header">${area.name}</h1>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
area.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: area.devices }
),
area.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: area.services }
),
area.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: area.entities }
),
].filter((v): v is string => Boolean(v))
)}
</div>
</div>
</ha-card>
</a>`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
private _createArea() {
this._openDialog();
this._openAreaDialog();
}
private _showHelp() {
@ -202,7 +191,7 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
});
}
private _openDialog(entry?: AreaRegistryEntry) {
private _openAreaDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
@ -213,14 +202,17 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return css`
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 16px 16px;
padding: 8px 16px 16px;
margin: 0 auto 64px auto;
max-width: 2000px;
}
.container > * {
.areas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 16px 16px;
max-width: 2000px;
margin-bottom: 16px;
}
.areas > * {
max-width: 500px;
}
ha-card {
@ -239,6 +231,12 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
background-position: center;
position: relative;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 48px;
}
.picture.placeholder::before {
position: absolute;
content: "";

View File

@ -1771,6 +1771,7 @@
"update_area": "Update area",
"delete": "Delete",
"name": "Name",
"icon": "Icon",
"name_required": "Name is required",
"area_id": "Area ID",
"unknown_error": "Unknown error",