Add picture uploader to area (#10544)

This commit is contained in:
Bram Kragten 2021-11-10 21:42:43 +01:00 committed by GitHub
parent 6623e5f017
commit 4cb45d6313
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 254 additions and 104 deletions

View File

@ -340,7 +340,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
item-value-path="area_id"
item-id-path="area_id"
item-label-path="name"
.value=${this._value}
.value=${this.value}
.disabled=${this.disabled}
${comboBoxRenderer(rowRenderer)}
@opened-changed=${this._openedChanged}
@ -431,12 +431,24 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
name,
});
this._areas = [...this._areas!, area];
(this.comboBox as any).items = this._getAreas(
this._areas!,
this._devices!,
this._entities!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd
);
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
text: this.hass.localize(
title: this.hass.localize(
"ui.components.area-picker.add_dialog.failed_create_area"
),
text: err.message,
});
}
},

View File

@ -7,10 +7,12 @@ import { HomeAssistant } from "../types";
export interface AreaRegistryEntry {
area_id: string;
name: string;
picture?: string;
}
export interface AreaRegistryEntryMutableParams {
name: string;
picture?: string | null;
}
export const createAreaRegistryEntry = (

View File

@ -66,7 +66,7 @@ export const computeEntityRegistryName = (
return entry.name;
}
const state = hass.states[entry.entity_id];
return state ? computeStateName(state) : null;
return state ? computeStateName(state) : entry.entity_id;
};
export const getExtendedEntityRegistryEntry = (

View File

@ -4,7 +4,7 @@ export interface CropOptions {
round: boolean;
type?: "image/jpeg" | "image/png";
quality?: number;
aspectRatio: number;
aspectRatio?: number;
}
export interface HaImageCropperDialogParams {

View File

@ -3,19 +3,31 @@ import "@polymer/paper-input/paper-input";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-alert";
import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { PolymerChangedEvent } from "../../../polymer-types";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
aspectRatio: 1.78,
};
class DialogAreaDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@state() private _picture!: string | null;
@state() private _error?: string;
@state() private _params?: AreaRegistryDetailDialogParams;
@ -28,6 +40,7 @@ class DialogAreaDetail extends LitElement {
this._params = params;
this._error = undefined;
this._name = this._params.entry ? this._params.entry.name : "";
this._picture = this._params.entry?.picture || null;
await this.updateComplete;
}
@ -55,7 +68,9 @@ class DialogAreaDetail extends LitElement {
)}
>
<div>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
${this._error
? html` <ha-alert alert-type="error">${this._error}</ha-alert> `
: ""}
<div class="form">
${entry
? html`
@ -78,6 +93,13 @@ class DialogAreaDetail extends LitElement {
)}
.invalid=${nameInvalid}
></paper-input>
<ha-picture-upload
.hass=${this.hass}
.value=${this._picture}
crop
.cropOptions=${cropOptions}
@change=${this._pictureChanged}
></ha-picture-upload>
</div>
</div>
${entry
@ -120,18 +142,24 @@ class DialogAreaDetail extends LitElement {
this._name = ev.detail.value;
}
private _pictureChanged(ev: PolymerChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
}
private async _updateEntry() {
this._submitting = true;
try {
const values: AreaRegistryEntryMutableParams = {
name: this._name.trim(),
picture: this._picture,
};
if (this._params!.entry) {
await this._params!.updateEntry!(values);
} else {
await this._params!.createEntry!(values);
}
this._params = undefined;
this.closeDialog();
} catch (err: any) {
this._error =
err.message ||
@ -145,13 +173,11 @@ class DialogAreaDetail extends LitElement {
this._submitting = true;
try {
if (await this._params!.removeEntry!()) {
this._params = undefined;
this.closeDialog();
}
} finally {
this._submitting = false;
}
navigate("/config/areas/dashboard");
}
static get styles(): CSSResultGroup {
@ -161,9 +187,6 @@ class DialogAreaDetail extends LitElement {
.form {
padding-bottom: 24px;
}
.error {
color: var(--error-color);
}
`,
];
}

View File

@ -1,13 +1,17 @@
import "@material/mwc-button";
import { mdiCog } from "@mdi/js";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { mdiImagePlus, mdiPencil } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import {
AreaRegistryEntry,
deleteAreaRegistryEntry,
@ -134,25 +138,59 @@ class HaConfigAreaPage extends LitElement {
.tabs=${configSections.integrations}
.route=${this.route}
>
${this.narrow ? html` <span slot="header"> ${area.name} </span> ` : ""}
${this.narrow
? html`<span slot="header"> ${area.name} </span>
<ha-icon-button
slot="toolbar-icon"
.path=${mdiCog}
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize("ui.panel.config.areas.edit_settings")}
></ha-icon-button>
slot="toolbar-icon"
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
></ha-icon-button>`
: ""}
<div class="container">
${!this.narrow
? html`
<div class="fullwidth">
<h1>${area.name}</h1>
<h1>
${area.name}
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
></ha-icon-button>
</h1>
</div>
`
: ""}
<div class="column">
${area.picture
? html`<div class="img-container">
<img src=${area.picture} /><ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: html`<mwc-button
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
>
<ha-svg-icon .path=${mdiImagePlus} slot="icon"></ha-svg-icon>
</mwc-button>`}
<ha-card
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
@ -181,7 +219,8 @@ class HaConfigAreaPage extends LitElement {
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>${entities.length
>
${entities.length
? entities.map(
(entity) =>
html`
@ -390,6 +429,7 @@ class HaConfigAreaPage extends LitElement {
try {
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
afterNextRender(() => history.back());
return true;
} catch (err: any) {
return false;
@ -403,7 +443,7 @@ class HaConfigAreaPage extends LitElement {
haStyle,
css`
h1 {
margin-top: 0;
margin: 0;
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing
@ -413,6 +453,13 @@ class HaConfigAreaPage extends LitElement {
letter-spacing: var(--paper-font-headline_-_letter-spacing);
line-height: var(--paper-font-headline_-_line-height);
opacity: var(--dark-primary-opacity);
display: flex;
align-items: center;
}
img {
border-radius: var(--ha-card-border-radius, 4px);
width: 100%;
}
.container {
@ -458,6 +505,34 @@ class HaConfigAreaPage extends LitElement {
paper-item.no-link {
cursor: default;
}
ha-card > a:first-child {
display: block;
}
ha-card > *:first-child {
margin-top: -16px;
}
.img-container {
position: relative;
}
.img-edit-btn {
position: absolute;
top: 4px;
right: 4px;
display: none;
}
.img-container:hover .img-edit-btn {
display: block;
}
.img-edit-btn::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background-color: var(--card-background-color);
opacity: 0.5;
border-radius: 50%;
}
`,
];
}

View File

@ -1,15 +1,8 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
@ -21,7 +14,7 @@ import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import "../../../layouts/hass-tabs-subpage";
import { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
@ -53,15 +46,22 @@ export class HaConfigAreasDashboard extends LitElement {
entities: EntityRegistryEntry[]
) =>
areas.map((area) => {
let noDevicesInArea = 0;
let noServicesInArea = 0;
let noEntitiesInArea = 0;
const devicesInArea = new Set();
for (const device of devices) {
if (device.area_id === area.area_id) {
devicesInArea.add(device.id);
if (device.entry_type === "service") {
noServicesInArea++;
} else {
noDevicesInArea++;
}
}
}
let entitiesInArea = 0;
for (const entity of entities) {
if (
@ -69,81 +69,28 @@ export class HaConfigAreasDashboard extends LitElement {
? entity.area_id === area.area_id
: devicesInArea.has(entity.device_id)
) {
entitiesInArea++;
noEntitiesInArea++;
}
}
return {
...area,
devices: devicesInArea.size,
entities: entitiesInArea,
devices: noDevicesInArea,
services: noServicesInArea,
entities: noEntitiesInArea,
};
})
);
private _columns = memoizeOne(
(narrow: boolean): DataTableColumnContainer =>
narrow
? {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
}
: {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
devices: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.devices"
),
sortable: true,
type: "numeric",
width: "20%",
direction: "asc",
},
entities: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.entities"
),
sortable: true,
type: "numeric",
width: "20%",
direction: "asc",
},
}
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
back-path="/config"
.tabs=${configSections.integrations}
.route=${this.route}
.columns=${this._columns(this.narrow)}
.data=${this._areas(this.areas, this.devices, this.entities)}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
"ui.panel.config.areas.picker.no_areas"
)}
id="area_id"
hasFab
clickable
>
<ha-icon-button
slot="toolbar-icon"
@ -151,6 +98,58 @@ export class HaConfigAreasDashboard extends LitElement {
.path=${mdiHelpCircle}
@click=${this._showHelp}
></ha-icon-button>
<div class="container">
${this._areas(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>
${area.devices
? html`
${this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
"count",
area.devices
)}${area.services ? "," : ""}
`
: ""}
${area.services
? html`
${this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
"count",
area.services
)}
`
: ""}
${(area.devices || area.services) && area.entities
? this.hass.localize("ui.common.and")
: ""}
${area.entities
? html`
${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
"count",
area.entities
)}
`
: ""}
</div>
</div>
</ha-card></a
>`
)}
</div>
<ha-fab
slot="fab"
.label=${this.hass.localize(
@ -161,7 +160,7 @@ export class HaConfigAreasDashboard extends LitElement {
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
</hass-tabs-subpage>
`;
}
@ -191,11 +190,6 @@ export class HaConfigAreasDashboard extends LitElement {
});
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const areaId = ev.detail.id;
navigate(`/config/areas/area/${areaId}`);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
@ -210,6 +204,51 @@ export class HaConfigAreasDashboard extends LitElement {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
.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: 1000px;
}
.container > * {
max-width: 500px;
}
ha-card {
overflow: hidden;
}
a {
text-decoration: none;
}
h1 {
padding-bottom: 0;
}
.picture {
height: 150px;
width: 100%;
background-size: cover;
background-position: center;
position: relative;
}
.picture.placeholder::before {
position: absolute;
content: "";
width: 100%;
height: 100%;
background-color: var(--sidebar-selected-icon-color);
opacity: 0.12;
}
.card-content {
min-height: 16px;
color: var(--secondary-text-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-areas-dashboard": HaConfigAreasDashboard;
}
}

View File

@ -376,9 +376,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
result.push({
...entry,
entity,
name:
computeEntityRegistryName(this.hass!, entry) ||
this.hass.localize("state.default.unavailable"),
name: computeEntityRegistryName(this.hass!, entry),
unavailable,
restored,
area: area ? area.name : undefined,

View File

@ -930,6 +930,7 @@
"caption": "Areas",
"description": "Group devices and entities into areas",
"edit_settings": "Area settings",
"add_picture": "Add a picture",
"data_table": {
"area": "Area",
"devices": "Devices",