Merge pull request #5024 from home-assistant/dev

20200228.0
This commit is contained in:
Bram Kragten 2020-02-28 22:59:33 +01:00 committed by GitHub
commit 7b057eaa77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
178 changed files with 6612 additions and 1026 deletions

127
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,127 @@
name: CI
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build icons
run: ./node_modules/.bin/gulp gen-icons-hassio gen-icons-mdi gen-icons-app
- name: Build translations
run: ./node_modules/.bin/gulp build-translations
- name: Run eslint
run: ./node_modules/.bin/eslint src hassio/src gallery/src
- name: Run tslint
run: ./node_modules/.bin/tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'cast/src/**/*.ts' 'test-mocha/**/*.ts'
- name: Run tsc
run: ./node_modules/.bin/tsc
test:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Run Mocha
run: npm run mocha
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
TRAVIS: "true"
supervisor:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Application
run: ./node_modules/.bin/gulp build-hassio
env:
TRAVIS: "true"

39
.github/workflows/demo.yaml vendored Normal file
View File

@ -0,0 +1,39 @@
name: Demo
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
- name: Deploy to Netlify
uses: netlify/actions/cli@master
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
with:
args: deploy --dir=demo/dist --prod

View File

@ -1,18 +0,0 @@
sudo: false
language: node_js
cache:
yarn: true
directories:
- bower_components
install: yarn install
script:
- npm run build
- hassio/script/build_hassio
# Because else eslint fails because hassio has cleaned that build
- ./node_modules/.bin/gulp gen-icons-app
- npm run test
# - xvfb-run wct --module-resolution=node --npm
# - 'if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then wct --module-resolution=node --npm --plugin sauce; fi'
dist: trusty
addons:
sauce_connect: true

View File

@ -15,6 +15,7 @@ import {
import {
LovelaceConfig,
getLovelaceCollection,
fetchResources,
} from "../../../../src/data/lovelace";
import "./hc-launch-screen";
import { castContext } from "../cast_context";
@ -23,6 +24,8 @@ import { ReceiverStatusMessage } from "../../../../src/cast/sender_messages";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
let resourcesLoaded = false;
@customElement("hc-main")
export class HcMain extends HassElement {
@property() private _showDemo = false;
@ -34,6 +37,7 @@ export class HcMain extends HassElement {
@property() private _error?: string;
private _unsubLovelace?: UnsubscribeFunc;
private _urlPath?: string | null;
public processIncomingMessage(msg: HassMessage) {
if (msg.type === "connect") {
@ -108,6 +112,7 @@ export class HcMain extends HassElement {
if (this.hass) {
status.hassUrl = this.hass.auth.data.hassUrl;
status.lovelacePath = this._lovelacePath!;
status.urlPath = this._urlPath;
}
if (senderId) {
@ -163,8 +168,19 @@ export class HcMain extends HassElement {
this._error = "Cannot show Lovelace because we're not connected.";
return;
}
if (!this._unsubLovelace) {
const llColl = getLovelaceCollection(this.hass!.connection);
if (!resourcesLoaded) {
resourcesLoaded = true;
loadLovelaceResources(
await fetchResources(this.hass!.connection),
this.hass!.auth.data.hassUrl
);
}
if (!this._unsubLovelace || this._urlPath !== msg.urlPath) {
this._urlPath = msg.urlPath;
if (this._unsubLovelace) {
this._unsubLovelace();
}
const llColl = getLovelaceCollection(this.hass!.connection, msg.urlPath);
// We first do a single refresh because we need to check if there is LL
// configuration.
try {
@ -194,12 +210,6 @@ export class HcMain extends HassElement {
private _handleNewLovelaceConfig(lovelaceConfig: LovelaceConfig) {
castContext.setApplicationState(lovelaceConfig.title!);
this._lovelaceConfig = lovelaceConfig;
if (lovelaceConfig.resources) {
loadLovelaceResources(
lovelaceConfig.resources,
this.hass!.auth.data.hassUrl
);
}
}
private _handleShowDemo(_msg: ShowDemoMessage) {

View File

@ -128,22 +128,27 @@ class HassioAddonAudio extends LitElement {
private _setInputDevice(ev): void {
const device = ev.detail.item.getAttribute("device");
this._selectedInput = device || null;
this._selectedInput = device;
}
private _setOutputDevice(ev): void {
const device = ev.detail.item.getAttribute("device");
this._selectedOutput = device || null;
this._selectedOutput = device;
}
private async _addonChanged(): Promise<void> {
this._selectedInput = this.addon.audio_input;
this._selectedOutput = this.addon.audio_output;
this._selectedInput =
this.addon.audio_input === null ? "default" : this.addon.audio_input;
this._selectedOutput =
this.addon.audio_output === null ? "default" : this.addon.audio_output;
if (this._outputDevices) {
return;
}
const noDevice: HassioHardwareAudioDevice = { device: null, name: "-" };
const noDevice: HassioHardwareAudioDevice = {
device: "default",
name: "Default",
};
try {
const { audio } = await fetchHassioHardwareAudio(this.hass);
@ -168,8 +173,10 @@ class HassioAddonAudio extends LitElement {
private async _saveSettings(): Promise<void> {
this._error = undefined;
const data: HassioAddonSetOptionParams = {
audio_input: this._selectedInput || null,
audio_output: this._selectedOutput || null,
audio_input:
this._selectedInput === "default" ? null : this._selectedInput,
audio_output:
this._selectedOutput === "default" ? null : this._selectedOutput,
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);

View File

@ -452,7 +452,7 @@ class HassioAddonInfo extends LitElement {
`
: ""}
<ha-progress-button
.disabled=${!this.addon.available}
.disabled=${!this.addon.available || this._installing}
.progress=${this._installing}
@click=${this._installClicked}
>

View File

@ -42,7 +42,9 @@ export class HassioUpdate extends LitElement {
!!value &&
(value.last_version
? value.version !== value.last_version
: value.version !== value.version_latest)
: value.version_latest
? value.version !== value.version_latest
: false)
);
}).length;
@ -102,7 +104,7 @@ export class HassioUpdate extends LitElement {
releaseNotesUrl: string,
icon?: string
): TemplateResult {
if (lastVersion === curVersion) {
if (!lastVersion || lastVersion === curVersion) {
return html``;
}
return html`

View File

@ -148,7 +148,7 @@ class HassioSupervisorInfo extends LitElement {
!confirm(`WARNING:
Beta releases are for testers and early adopters and can contain unstable code changes. Make sure you have backups of your data before you activate this feature.
This inludes beta releases for:
This includes beta releases for:
- Home Assistant (Release Candidates)
- Hass.io supervisor
- Host system`)

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="home-assistant-frontend",
version="20200220.5",
version="20200228.0",
description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors",

View File

@ -21,6 +21,7 @@ export interface ConnectMessage extends BaseCastMessage {
export interface ShowLovelaceViewMessage extends BaseCastMessage {
type: "show_lovelace_view";
viewPath: string | number | null;
urlPath: string | null;
}
export interface ShowDemoMessage extends BaseCastMessage {
@ -43,11 +44,13 @@ export const castSendAuth = (cast: CastManager, auth: Auth) =>
export const castSendShowLovelaceView = (
cast: CastManager,
viewPath: ShowLovelaceViewMessage["viewPath"]
viewPath: ShowLovelaceViewMessage["viewPath"],
urlPath?: string | null
) =>
cast.sendMessage({
type: "show_lovelace_view",
viewPath,
urlPath: urlPath || null,
});
export const castSendShowDemo = (cast: CastManager) =>

View File

@ -8,6 +8,7 @@ export interface ReceiverStatusMessage extends BaseCastMessage {
showDemo: boolean;
hassUrl?: string;
lovelacePath?: string | number | null;
urlPath?: string | null;
}
export type SenderMessage = ReceiverStatusMessage;

View File

@ -4,7 +4,7 @@ export const dynamicElement = directive(
(tag: string, properties?: { [key: string]: any }) => (part: Part): void => {
if (!(part instanceof NodePart)) {
throw new Error(
"dynamicContentDirective can only be used in content bindings"
"dynamicElementDirective can only be used in content bindings"
);
}

View File

@ -23,7 +23,7 @@ const fixedIcons = {
homeassistant: "hass:home-assistant",
homekit: "hass:home-automation",
image_processing: "hass:image-filter-frames",
input_boolean: "hass:drawing",
input_boolean: "hass:toggle-switch-outline",
input_datetime: "hass:calendar-clock",
input_number: "hass:ray-vertex",
input_select: "hass:format-list-bulleted",

View File

@ -29,6 +29,10 @@ export const iconColorCSS = css`
color: var(--heat-color, #ff8100);
}
ha-icon[data-domain="climate"][data-state="drying"] {
color: var(--dry-color, #efbd07);
}
ha-icon[data-domain="alarm_control_panel"] {
color: var(--alarm-color-armed, var(--label-badge-red));
}

View File

@ -571,6 +571,18 @@ export class HaDataTable extends BaseElement {
width: 24px;
}
.mdc-data-table__header-cell--icon {
text-align: center;
}
.mdc-data-table__cell--icon:first-child ha-icon {
margin-left: 8px;
}
.mdc-data-table__cell--icon:first-child state-badge {
margin-right: -8px;
}
.mdc-data-table__header-cell {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
@ -598,10 +610,6 @@ export class HaDataTable extends BaseElement {
text-align: left;
}
.mdc-data-table__header-cell--icon {
text-align: center;
}
/* custom from here */
:host {
@ -615,27 +623,39 @@ export class HaDataTable extends BaseElement {
}
.mdc-data-table__header-cell {
overflow: hidden;
position: relative;
}
.mdc-data-table__header-cell span {
position: relative;
left: 0px;
}
.mdc-data-table__header-cell.sortable {
cursor: pointer;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon)
span {
position: relative;
left: -24px;
}
.mdc-data-table__header-cell.not-sorted > * {
.mdc-data-table__header-cell > * {
transition: left 0.2s ease 0s;
}
.mdc-data-table__header-cell ha-icon {
top: 15px;
position: absolute;
}
.mdc-data-table__header-cell.not-sorted ha-icon {
left: -36px;
left: -20px;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon):hover
.mdc-data-table__header-cell:not(.not-sorted) span,
.mdc-data-table__header-cell.not-sorted:hover span {
left: 24px;
}
.mdc-data-table__header-cell.mdc-data-table__header-cell--numeric:not(.not-sorted)
span,
.mdc-data-table__header-cell.mdc-data-table__header-cell--numeric.not-sorted:hover
span {
left: 0px;
left: 12px;
}
.mdc-data-table__header-cell:not(.not-sorted) ha-icon,
.mdc-data-table__header-cell:hover.not-sorted ha-icon {
left: 0px;
left: 12px;
}
.table-header {
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);

View File

@ -1,12 +1,23 @@
import { customElement, CSSResult, css } from "lit-element";
import { customElement, CSSResult, css, html } from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button";
import "@material/mwc-dialog";
import { style } from "@material/mwc-dialog/mwc-dialog-css";
// tslint:disable-next-line
import { Dialog } from "@material/mwc-dialog";
import { Constructor } from "../types";
import { Constructor, HomeAssistant } from "../types";
// tslint:disable-next-line
const MwcDialog = customElements.get("mwc-dialog") as Constructor<Dialog>;
export const createCloseHeading = (hass: HomeAssistant, title: string) => html`
${title}
<paper-icon-button
aria-label=${hass.localize("ui.dialogs.generic.close")}
icon="hass:close"
dialogAction="close"
class="close_button"
></paper-icon-button>
`;
@customElement("ha-dialog")
export class HaDialog extends MwcDialog {
protected static get styles(): CSSResult[] {
@ -19,6 +30,15 @@ export class HaDialog extends MwcDialog {
.mdc-dialog__container {
align-items: var(--vertial-align-dialog, center);
}
.mdc-dialog__title::before {
display: block;
height: 20px;
}
.close_button {
position: absolute;
right: 16px;
top: 12px;
}
`,
];
}

View File

@ -0,0 +1,65 @@
import {
html,
css,
LitElement,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "./ha-icon";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-icon-input")
export class HaIconInput extends LitElement {
@property() public value?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public disabled = false;
protected render(): TemplateResult {
return html`
<paper-input
.value=${this.value}
.label=${this.label}
.placeholder=${this.placeholder}
@value-changed=${this._valueChanged}
.disabled=${this.disabled}
auto-validate
.errorMessage=${this.errorMessage}
pattern="^\\S+:\\S+$"
>
${this.value || this.placeholder
? html`
<ha-icon .icon=${this.value || this.placeholder} slot="suffix">
</ha-icon>
`
: ""}
</paper-input>
`;
}
private _valueChanged(ev: CustomEvent) {
this.value = ev.detail.value;
fireEvent(
this,
"value-changed",
{ value: ev.detail.value },
{
bubbles: false,
composed: false,
}
);
}
static get styles() {
return css`
ha-icon {
position: relative;
bottom: 4px;
}
`;
}
}

View File

@ -70,9 +70,7 @@ export class HaRelatedItems extends SubscribeMixin(LitElement) {
}
if (Object.keys(this._related).length === 0) {
return html`
<p>
${this.hass.localize("ui.components.related-items.no_related_found")}
</p>
${this.hass.localize("ui.components.related-items.no_related_found")}
`;
}
return html`

View File

@ -46,7 +46,18 @@ const SORT_VALUE_URL_PATHS = {
config: 11,
};
const panelSorter = (a, b) => {
const panelSorter = (a: PanelInfo, b: PanelInfo) => {
// Put all the Lovelace at the top.
const aLovelace = a.component_name === "lovelace";
const bLovelace = b.component_name === "lovelace";
if (aLovelace && !bLovelace) {
return -1;
}
if (bLovelace) {
return 1;
}
const aBuiltIn = a.url_path in SORT_VALUE_URL_PATHS;
const bBuiltIn = b.url_path in SORT_VALUE_URL_PATHS;

View File

@ -6,6 +6,13 @@ import { afterNextRender } from "../common/util/render-status";
// tslint:disable-next-line
import { HaCodeEditor } from "./ha-code-editor";
declare global {
// for fire event
interface HASSDomEvents {
"editor-refreshed": undefined;
}
}
const isEmpty = (obj: object) => {
if (typeof obj !== "object") {
return false;
@ -37,6 +44,7 @@ export class HaYamlEditor extends LitElement {
if (this._editor?.codemirror) {
this._editor.codemirror.refresh();
}
afterNextRender(() => fireEvent(this, "editor-refreshed"));
});
}

View File

@ -6,14 +6,23 @@ import { debounce } from "../common/util/debounce";
export interface EntityRegistryEntry {
entity_id: string;
name: string;
icon?: string;
platform: string;
config_entry_id?: string;
device_id?: string;
disabled_by: string | null;
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
unique_id: string;
capabilities: object;
original_name?: string;
original_icon?: string;
}
export interface EntityRegistryEntryUpdateParams {
name?: string | null;
icon?: string | null;
disabled_by?: string | null;
new_entity_id?: string;
}
@ -29,12 +38,21 @@ export const computeEntityRegistryName = (
return state ? computeStateName(state) : null;
};
export const getExtendedEntityRegistryEntry = (
hass: HomeAssistant,
entityId: string
): Promise<ExtEntityRegistryEntry> =>
hass.callWS({
type: "config/entity_registry/get",
entity_id: entityId,
});
export const updateEntityRegistryEntry = (
hass: HomeAssistant,
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<EntityRegistryEntry> =>
hass.callWS<EntityRegistryEntry>({
): Promise<ExtEntityRegistryEntry> =>
hass.callWS({
type: "config/entity_registry/update",
entity_id: entityId,
...updates,

View File

@ -59,3 +59,12 @@ export const getOptimisticFrontendUserDataCollection = <
`_frontendUserData-${userDataKey}`,
() => fetchFrontendUserData(conn, userDataKey)
);
export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
conn: Connection,
userDataKey: UserDataKey,
onChange: (state: FrontendUserData[UserDataKey] | null) => void
) =>
getOptimisticFrontendUserDataCollection(conn, userDataKey).subscribe(
onChange
);

View File

@ -1,11 +0,0 @@
import { HomeAssistant } from "../types";
export const setInputSelectOption = (
hass: HomeAssistant,
entity: string,
option: string
) =>
hass.callService("input_select", "select_option", {
option,
entity_id: entity,
});

43
src/data/input_boolean.ts Normal file
View File

@ -0,0 +1,43 @@
import { HomeAssistant } from "../types";
export interface InputBoolean {
id: string;
name: string;
icon?: string;
initial?: boolean;
}
export interface InputBooleanMutableParams {
name: string;
icon: string;
initial: boolean;
}
export const fetchInputBoolean = (hass: HomeAssistant) =>
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
export const createInputBoolean = (
hass: HomeAssistant,
values: InputBooleanMutableParams
) =>
hass.callWS<InputBoolean>({
type: "input_boolean/create",
...values,
});
export const updateInputBoolean = (
hass: HomeAssistant,
id: string,
updates: Partial<InputBooleanMutableParams>
) =>
hass.callWS<InputBoolean>({
type: "input_boolean/update",
input_boolean_id: id,
...updates,
});
export const deleteInputBoolean = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_boolean/delete",
input_boolean_id: id,
});

View File

@ -1,5 +1,22 @@
import { HomeAssistant } from "../types";
export interface InputDateTime {
id: string;
name: string;
icon?: string;
initial?: string;
has_time: boolean;
has_date: boolean;
}
export interface InputDateTimeMutableParams {
name: string;
icon: string;
initial: string;
has_time: boolean;
has_date: boolean;
}
export const setInputDateTimeValue = (
hass: HomeAssistant,
entityId: string,
@ -9,3 +26,32 @@ export const setInputDateTimeValue = (
const param = { entity_id: entityId, time, date };
hass.callService(entityId.split(".", 1)[0], "set_datetime", param);
};
export const fetchInputDateTime = (hass: HomeAssistant) =>
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
export const createInputDateTime = (
hass: HomeAssistant,
values: InputDateTimeMutableParams
) =>
hass.callWS<InputDateTime>({
type: "input_datetime/create",
...values,
});
export const updateInputDateTime = (
hass: HomeAssistant,
id: string,
updates: Partial<InputDateTimeMutableParams>
) =>
hass.callWS<InputDateTime>({
type: "input_datetime/update",
input_datetime_id: id,
...updates,
});
export const deleteInputDateTime = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_datetime/delete",
input_datetime_id: id,
});

53
src/data/input_number.ts Normal file
View File

@ -0,0 +1,53 @@
import { HomeAssistant } from "../types";
export interface InputNumber {
id: string;
name: string;
min: number;
max: number;
icon?: string;
initial?: number;
step?: number;
mode?: "box" | "slider";
unit_of_measurement?: string;
}
export interface InputNumberMutableParams {
name: string;
icon: string;
initial: number;
min: number;
max: number;
step: number;
mode: "box" | "slider";
unit_of_measurement?: string;
}
export const fetchInputNumber = (hass: HomeAssistant) =>
hass.callWS<InputNumber[]>({ type: "input_number/list" });
export const createInputNumber = (
hass: HomeAssistant,
values: InputNumberMutableParams
) =>
hass.callWS<InputNumber>({
type: "input_number/create",
...values,
});
export const updateInputNumber = (
hass: HomeAssistant,
id: string,
updates: Partial<InputNumberMutableParams>
) =>
hass.callWS<InputNumber>({
type: "input_number/update",
input_number_id: id,
...updates,
});
export const deleteInputNumber = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_number/delete",
input_number_id: id,
});

55
src/data/input_select.ts Normal file
View File

@ -0,0 +1,55 @@
import { HomeAssistant } from "../types";
export interface InputSelect {
id: string;
name: string;
options: string[];
icon?: string;
initial?: string;
}
export interface InputSelectMutableParams {
name: string;
icon: string;
initial: string;
options: string[];
}
export const setInputSelectOption = (
hass: HomeAssistant,
entity: string,
option: string
) =>
hass.callService("input_select", "select_option", {
option,
entity_id: entity,
});
export const fetchInputSelect = (hass: HomeAssistant) =>
hass.callWS<InputSelect[]>({ type: "input_select/list" });
export const createInputSelect = (
hass: HomeAssistant,
values: InputSelectMutableParams
) =>
hass.callWS<InputSelect>({
type: "input_select/create",
...values,
});
export const updateInputSelect = (
hass: HomeAssistant,
id: string,
updates: Partial<InputSelectMutableParams>
) =>
hass.callWS<InputSelect>({
type: "input_select/update",
input_select_id: id,
...updates,
});
export const deleteInputSelect = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_select/delete",
input_select_id: id,
});

View File

@ -1,7 +1,57 @@
import { HomeAssistant } from "../types";
export interface InputText {
id: string;
name: string;
icon?: string;
initial?: string;
min?: number;
max?: number;
pattern?: string;
mode?: "text" | "password";
}
export interface InputTextMutableParams {
name: string;
icon: string;
initial: string;
min: number;
max: number;
pattern: string;
mode: "text" | "password";
}
export const setValue = (hass: HomeAssistant, entity: string, value: string) =>
hass.callService(entity.split(".", 1)[0], "set_value", {
value,
entity_id: entity,
});
export const fetchInputText = (hass: HomeAssistant) =>
hass.callWS<InputText[]>({ type: "input_text/list" });
export const createInputText = (
hass: HomeAssistant,
values: InputTextMutableParams
) =>
hass.callWS<InputText>({
type: "input_text/create",
...values,
});
export const updateInputText = (
hass: HomeAssistant,
id: string,
updates: Partial<InputTextMutableParams>
) =>
hass.callWS<InputText>({
type: "input_text/update",
input_text_id: id,
...updates,
});
export const deleteInputText = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_text/delete",
input_text_id: id,
});

View File

@ -1,12 +1,57 @@
import { HomeAssistant } from "../types";
import { Connection, getCollection } from "home-assistant-js-websocket";
import {
Connection,
getCollection,
HassEventBase,
} from "home-assistant-js-websocket";
import { HASSDomEvent } from "../common/dom/fire_event";
export interface LovelaceConfig {
title?: string;
views: LovelaceViewConfig[];
background?: string;
resources?: Array<{ type: "css" | "js" | "module" | "html"; url: string }>;
}
export interface LovelaceResource {
id: string;
type: "css" | "js" | "module" | "html";
url: string;
}
export interface LovelaceResourcesMutableParams {
res_type: "css" | "js" | "module" | "html";
url: string;
}
export type LovelaceDashboard =
| LovelaceYamlDashboard
| LovelaceStorageDashboard;
interface LovelaceGenericDashboard {
id: string;
url_path: string;
require_admin: boolean;
sidebar?: { icon: string; title: string };
}
export interface LovelaceYamlDashboard extends LovelaceGenericDashboard {
mode: "yaml";
filename: string;
}
export interface LovelaceStorageDashboard extends LovelaceGenericDashboard {
mode: "storage";
}
export interface LovelaceDashboardMutableParams {
require_admin: boolean;
sidebar: { icon: string; title: string } | null;
}
export interface LovelaceDashboardCreateParams
extends LovelaceDashboardMutableParams {
url_path: string;
mode: "storage";
}
export interface LovelaceViewConfig {
@ -95,47 +140,139 @@ export type ActionConfig =
| NoActionConfig
| CustomActionConfig;
type LovelaceUpdatedEvent = HassEventBase & {
event_type: "lovelace_updated";
data: {
url_path: string | null;
mode: "yaml" | "storage";
};
};
export const fetchResources = (conn: Connection): Promise<LovelaceResource[]> =>
conn.sendMessagePromise({
type: "lovelace/resources",
});
export const createResource = (
hass: HomeAssistant,
values: LovelaceResourcesMutableParams
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/create",
...values,
});
export const updateResource = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceResourcesMutableParams>
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/update",
resource_id: id,
...updates,
});
export const deleteResource = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/resources/delete",
resource_id: id,
});
export const fetchDashboards = (
hass: HomeAssistant
): Promise<LovelaceDashboard[]> =>
hass.callWS({
type: "lovelace/dashboards/list",
});
export const createDashboard = (
hass: HomeAssistant,
values: LovelaceDashboardCreateParams
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/create",
...values,
});
export const updateDashboard = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceDashboardMutableParams>
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/update",
dashboard_id: id,
...updates,
});
export const deleteDashboard = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/dashboards/delete",
dashboard_id: id,
});
export const fetchConfig = (
conn: Connection,
urlPath: string | null,
force: boolean
): Promise<LovelaceConfig> =>
conn.sendMessagePromise({
type: "lovelace/config",
url_path: urlPath,
force,
});
export const saveConfig = (
hass: HomeAssistant,
urlPath: string | null,
config: LovelaceConfig
): Promise<void> =>
hass.callWS({
type: "lovelace/config/save",
url_path: urlPath,
config,
});
export const deleteConfig = (hass: HomeAssistant): Promise<void> =>
export const deleteConfig = (
hass: HomeAssistant,
urlPath: string | null
): Promise<void> =>
hass.callWS({
type: "lovelace/config/delete",
url_path: urlPath,
});
export const subscribeLovelaceUpdates = (
conn: Connection,
urlPath: string | null,
onChange: () => void
) => conn.subscribeEvents(onChange, "lovelace_updated");
) =>
conn.subscribeEvents<LovelaceUpdatedEvent>((ev) => {
if (ev.data.url_path === urlPath) {
onChange();
}
}, "lovelace_updated");
export const getLovelaceCollection = (conn: Connection) =>
export const getLovelaceCollection = (
conn: Connection,
urlPath: string | null = null
) =>
getCollection(
conn,
"_lovelace",
(conn2) => fetchConfig(conn2, false),
`_lovelace_${urlPath ?? ""}`,
(conn2) => fetchConfig(conn2, urlPath, false),
(_conn, store) =>
subscribeLovelaceUpdates(conn, () =>
fetchConfig(conn, false).then((config) => store.setState(config, true))
subscribeLovelaceUpdates(conn, urlPath, () =>
fetchConfig(conn, urlPath, false).then((config) =>
store.setState(config, true)
)
)
);
export interface WindowWithLovelaceProm extends Window {
llConfProm?: Promise<LovelaceConfig>;
llResProm?: Promise<LovelaceResource[]>;
}
export interface ActionHandlerOptions {

View File

@ -1 +1,2 @@
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";

View File

@ -129,6 +129,10 @@ class DialogBox extends LitElement {
return [
haStyleDialog,
css`
:host([inert]) {
pointer-events: initial !important;
cursor: initial !important;
}
ha-paper-dialog {
min-width: 400px;
max-width: 500px;

View File

@ -8,7 +8,6 @@ import "../resources/ha-style";
import "./more-info/more-info-controls";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import DialogMixin from "../mixins/dialog-mixin";
@ -81,7 +80,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
hass="[[hass]]"
state-obj="[[stateObj]]"
dialog-element="[[_dialogElement()]]"
registry-entry="[[_registryInfo]]"
large="{{large}}"
></more-info-controls>
`;
@ -102,8 +100,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
observer: "_largeChanged",
},
_registryInfo: Object,
dataDomain: {
computed: "_computeDomain(stateObj)",
reflectToAttribute: true,
@ -127,11 +123,10 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
return hass.states[hass.moreInfoEntityId] || null;
}
async _stateObjChanged(newVal, oldVal) {
async _stateObjChanged(newVal) {
if (!newVal) {
this.setProperties({
opened: false,
_registryInfo: null,
large: false,
});
return;
@ -144,25 +139,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
this.opened = true;
})
);
if (
!isComponentLoaded(this.hass, "config") ||
(oldVal && oldVal.entity_id === newVal.entity_id)
) {
return;
}
if (this.hass.user.is_admin) {
try {
const info = await this.hass.callWS({
type: "config/entity_registry/get",
entity_id: newVal.entity_id,
});
this._registryInfo = info;
} catch (err) {
this._registryInfo = null;
}
}
}
_dialogOpenChanged(newVal) {

View File

@ -45,7 +45,7 @@ class MoreInfoCamera extends LitElement {
return html`
<ha-camera-stream
.hass="${this.hass}"
.hass=${this.hass}
.stateObj="${this.stateObj}"
showcontrols
></ha-camera-stream>

View File

@ -39,7 +39,8 @@ class MoreInfoPerson extends LitElement {
></ha-map>
`
: ""}
${this.hass.user?.is_admin &&
${!__DEMO__ &&
this.hass.user?.is_admin &&
this.stateObj.state === "not_home" &&
this.stateObj.attributes.latitude &&
this.stateObj.attributes.longitude

View File

@ -22,7 +22,7 @@ import LocalizeMixin from "../../mixins/localize-mixin";
import { computeRTL } from "../../common/util/compute_rtl";
import { removeEntityRegistryEntry } from "../../data/entity_registry";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showEntityRegistryDetailDialog } from "../../panels/config/entities/show-dialog-entity-registry-detail";
import { showEntityEditorDialog } from "../../panels/config/entities/show-dialog-entity-editor";
const DOMAINS_NO_INFO = ["camera", "configurator", "history_graph"];
const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"];
@ -88,7 +88,7 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
<div class="main-title" main-title="" on-click="enlarge">
[[_computeStateName(stateObj)]]
</div>
<template is="dom-if" if="[[registryEntry]]">
<template is="dom-if" if="[[_computeConfig(hass)]]">
<paper-icon-button
aria-label$="[[localize('ui.dialogs.more_info_control.settings')]]"
icon="hass:settings"
@ -221,6 +221,10 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
return stateObj ? computeStateName(stateObj) : "";
}
_computeConfig(hass) {
return hass.user.is_admin && isComponentLoaded(hass, "config");
}
_computeEdit(hass, stateObj) {
const domain = this._computeDomain(stateObj);
return (
@ -260,7 +264,9 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
}
_gotoSettings() {
showEntityRegistryDetailDialog(this, { entry: this.registryEntry });
showEntityEditorDialog(this, {
entity_id: this.stateObj.entity_id,
});
this.fire("hass-more-info", { entityId: null });
}

View File

@ -36,13 +36,13 @@ export class HuiNotificationItem extends LitElement {
return "entity_id" in this.notification
? html`
<configurator-notification-item
.hass="${this.hass}"
.hass=${this.hass}
.notification="${this.notification}"
></configurator-notification-item>
`
: html`
<persistent-notification-item
.hass="${this.hass}"
.hass=${this.hass}
.notification="${this.notification}"
></persistent-notification-item>
`;

View File

@ -39,7 +39,7 @@ export class HuiPersistentNotificationItem extends LitElement {
<div class="time">
<span>
<ha-relative-time
.hass="${this.hass}"
.hass=${this.hass}
.datetime="${this.notification.created_at}"
></ha-relative-time>
<paper-tooltip

View File

@ -16,7 +16,12 @@ import { subscribeThemes } from "../data/ws-themes";
import { subscribeUser } from "../data/ws-user";
import { HomeAssistant } from "../types";
import { hassUrl } from "../data/auth";
import { fetchConfig, WindowWithLovelaceProm } from "../data/lovelace";
import { subscribeFrontendUserData } from "../data/frontend";
import {
fetchConfig,
fetchResources,
WindowWithLovelaceProm,
} from "../data/lovelace";
declare global {
interface Window {
@ -84,9 +89,15 @@ window.hassConnection.then(({ conn }) => {
subscribePanels(conn, noop);
subscribeThemes(conn, noop);
subscribeUser(conn, noop);
subscribeFrontendUserData(conn, "core", noop);
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
(window as WindowWithLovelaceProm).llConfProm = fetchConfig(conn, false);
(window as WindowWithLovelaceProm).llConfProm = fetchConfig(
conn,
null,
false
);
(window as WindowWithLovelaceProm).llResProm = fetchResources(conn);
}
});

View File

@ -15,6 +15,7 @@ import { Route, HomeAssistant } from "../types";
import { navigate } from "../common/navigate";
import "@material/mwc-ripple";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import memoizeOne from "memoize-one";
export interface PageNavigation {
path: string;
@ -22,7 +23,7 @@ export interface PageNavigation {
component?: string;
name?: string;
core?: boolean;
exportOnly?: boolean;
advancedOnly?: boolean;
icon?: string;
info?: any;
}
@ -33,12 +34,57 @@ class HassTabsSubpage extends LitElement {
@property({ type: String, attribute: "back-path" }) public backPath?: string;
@property() public backCallback?: () => void;
@property({ type: Boolean }) public hassio = false;
@property({ type: Boolean }) public showAdvanced = false;
@property() public route!: Route;
@property() public tabs!: PageNavigation[];
@property({ type: Boolean, reflect: true }) public narrow = false;
@property() private _activeTab: number = -1;
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
activeTab: number,
showAdvanced: boolean | undefined,
_components,
_language
) => {
const shownTabs = tabs.filter(
(page) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.advancedOnly || showAdvanced)
);
return shownTabs.map(
(page, index) => html`
<div
class="tab ${classMap({
active: index === activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
);
}
);
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("route")) {
@ -49,6 +95,14 @@ class HassTabsSubpage extends LitElement {
}
protected render(): TemplateResult {
const tabs = this._getTabs(
this.tabs,
this._activeTab,
this.hass.userData?.showAdvanced,
this.hass.config.components,
this.hass.language
);
return html`
<div class="toolbar">
<ha-paper-icon-button-arrow-prev
@ -61,41 +115,13 @@ class HassTabsSubpage extends LitElement {
<div main-title><slot name="header"></slot></div>
`
: ""}
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${this.tabs.map((page, index) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.exportOnly || this.showAdvanced)
? html`
<div
class="tab ${classMap({
active: index === this._activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === this._activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
: ""
)}
</div>
${tabs.length > 1 || !this.narrow
? html`
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${tabs}
</div>
`
: ""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>

View File

@ -9,7 +9,7 @@ import {
} from "./hass-router-page";
import { removeInitSkeleton } from "../util/init-skeleton";
const CACHE_COMPONENTS = ["lovelace", "states", "developer-tools"];
const CACHE_URL_PATHS = ["lovelace", "states", "developer-tools"];
const COMPONENTS = {
calendar: () =>
import(
@ -69,11 +69,10 @@ const COMPONENTS = {
const getRoutes = (panels: Panels): RouterOptions => {
const routes: RouterOptions["routes"] = {};
Object.values(panels).forEach((panel) => {
const data: RouteOptions = {
tag: `ha-panel-${panel.component_name}`,
cache: CACHE_COMPONENTS.includes(panel.component_name),
cache: CACHE_URL_PATHS.includes(panel.url_path),
};
if (panel.component_name in COMPONENTS) {
data.load = COMPONENTS[panel.component_name];

View File

@ -82,6 +82,10 @@ class NotificationManager extends LitElement {
static get styles(): CSSResult {
return css`
:host {
display: flex;
align-items: center;
}
mwc-button {
color: var(--primary-color);
font-weight: bold;

View File

@ -26,7 +26,7 @@ export default class HaNumericStateCondition extends LitElement {
<ha-entity-picker
.value="${entity_id}"
@value-changed="${this._entityPicked}"
.hass="${this.hass}"
.hass=${this.hass}
allow-custom-entity
></ha-entity-picker>
<paper-input

View File

@ -42,7 +42,7 @@ export default class HaNumericStateTrigger extends LitElement {
<ha-entity-picker
.value="${entity_id}"
@value-changed="${this._entityPicked}"
.hass="${this.hass}"
.hass=${this.hass}
allow-custom-entity
></ha-entity-picker>
<paper-input

View File

@ -118,7 +118,7 @@ export class CloudGooglePref extends LitElement {
</div>
<div class="card-actions">
<ha-call-api-button
.hass="${this.hass}"
.hass=${this.hass}
.disabled="${!google_enabled}"
@hass-api-called=${this._syncEntitiesCalled}
path="cloud/google_actions/sync"

View File

@ -31,7 +31,7 @@ class HaConfigNavigation extends LitElement {
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.exportOnly || this.showAdvanced)
(!page.advancedOnly || this.showAdvanced)
? html`
<a
href=${`/config/${page.component}`}

View File

@ -1,7 +1,6 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
@ -22,6 +21,7 @@ import {
fetchDeviceConditions,
fetchDeviceActions,
} from "../../../../data/device_automation";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-device-automation")
export class DialogDeviceAutomation extends LitElement {
@ -129,16 +129,7 @@ export class DialogDeviceAutomation extends LitElement {
}
static get styles(): CSSResult {
return css`
ha-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
}
@media only screen and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 600px;
}
}
`;
return haStyleDialog;
}
}

View File

@ -20,7 +20,7 @@ import "@polymer/paper-item/paper-item-body";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import { showEntityRegistryDetailDialog } from "../../entities/show-dialog-entity-registry-detail";
import { showEntityEditorDialog } from "../../entities/show-dialog-entity-editor";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIcon } from "../../../../common/entity/domain_icon";
import { EntityRegistryStateEntry } from "../ha-config-device-page";
@ -150,7 +150,7 @@ export class HaDeviceEntitiesCard extends LitElement {
private _openEditEntry(ev: Event): void {
ev.stopPropagation();
const entry = (ev.currentTarget! as any).entry;
showEntityRegistryDetailDialog(this, {
showEntityEditorDialog(this, {
entry,
entity_id: entry.entity_id,
});

View File

@ -6,8 +6,6 @@ import {
TemplateResult,
property,
customElement,
CSSResult,
css,
} from "lit-element";
import { HomeAssistant, Route } from "../../../types";
import {
@ -253,17 +251,6 @@ export class HaConfigDeviceDashboard extends LitElement {
const deviceId = (ev.detail as RowClickedEvent).id;
navigate(this, `/config/devices/device/${deviceId}`);
}
static get styles(): CSSResult {
return css`
.content {
padding: 4px;
}
ha-devices-data-table {
width: 100%;
}
`;
}
}
declare global {

View File

@ -1,5 +1,3 @@
import "@polymer/app-route/app-route";
import "./ha-config-devices-dashboard";
import "./ha-config-device-page";
import { compare } from "../../../common/string/compare";

View File

@ -0,0 +1,8 @@
/** Platforms that have a settings tab. */
export const PLATFORMS_WITH_SETTINGS_TAB = {
input_number: "entity-settings-helper-tab",
input_select: "entity-settings-helper-tab",
input_text: "entity-settings-helper-tab",
input_boolean: "entity-settings-helper-tab",
input_datetime: "entity-settings-helper-tab",
};

View File

@ -13,25 +13,45 @@ import {
TemplateResult,
} from "lit-element";
import { cache } from "lit-html/directives/cache";
import { PLATFORMS_WITH_SETTINGS_TAB } from "./const";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/dialog/ha-paper-dialog";
// tslint:disable-next-line: no-duplicate-imports
import { HaPaperDialog } from "../../../components/dialog/ha-paper-dialog";
import "../../../components/ha-related-items";
import "../../../dialogs/more-info/controls/more-info-content";
import {
EntityRegistryEntry,
ExtEntityRegistryEntry,
getExtendedEntityRegistryEntry,
} from "../../../data/entity_registry";
import { PolymerChangedEvent } from "../../../polymer-types";
import { haStyleDialog } from "../../../resources/styles";
import "../../../state-summary/state-card-content";
import { HomeAssistant } from "../../../types";
import "./entity-registry-settings";
import { EntityRegistryDetailDialogParams } from "./show-dialog-entity-registry-detail";
import { EntityRegistryDetailDialogParams } from "./show-dialog-entity-editor";
@customElement("dialog-entity-registry-detail")
export class DialogEntityRegistryDetail extends LitElement {
interface Tabs {
[key: string]: Tab;
}
interface Tab {
component: string;
translationKey: string;
}
@customElement("dialog-entity-editor")
export class DialogEntityEditor extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _params?: EntityRegistryDetailDialogParams;
@property() private _entry?:
| EntityRegistryEntry
| ExtEntityRegistryEntry
| null;
@property() private _curTab?: string;
@property() private _extraTabs: Tabs = {};
@property() private _settingsElementTag?: string;
@query("ha-paper-dialog") private _dialog!: HaPaperDialog;
private _curTabIndex = 0;
@ -39,6 +59,10 @@ export class DialogEntityRegistryDetail extends LitElement {
params: EntityRegistryDetailDialogParams
): Promise<void> {
this._params = params;
this._entry = undefined;
this._settingsElementTag = undefined;
this._extraTabs = {};
this._getEntityReg();
await this.updateComplete;
}
@ -47,11 +71,11 @@ export class DialogEntityRegistryDetail extends LitElement {
}
protected render(): TemplateResult {
if (!this._params) {
if (!this._params || this._entry === undefined) {
return html``;
}
const entry = this._params.entry;
const entityId = this._params.entity_id;
const entry = this._entry;
const stateObj: HassEntity | undefined = this.hass.states[entityId];
return html`
@ -59,6 +83,7 @@ export class DialogEntityRegistryDetail extends LitElement {
with-backdrop
opened
@opened-changed=${this._openedChanged}
@close-dialog=${this.closeDialog}
>
<app-toolbar>
<paper-icon-button
@ -92,6 +117,13 @@ export class DialogEntityRegistryDetail extends LitElement {
<paper-tab id="tab-settings">
${this.hass.localize("ui.dialogs.entity_registry.settings")}
</paper-tab>
${Object.entries(this._extraTabs).map(
([key, tab]) => html`
<paper-tab id=${key}>
${this.hass.localize(tab.translationKey) || key}
</paper-tab>
`
)}
<paper-tab id="tab-related">
${this.hass.localize("ui.dialogs.entity_registry.related")}
</paper-tab>
@ -99,14 +131,16 @@ export class DialogEntityRegistryDetail extends LitElement {
${cache(
this._curTab === "tab-settings"
? entry
? html`
<entity-registry-settings
.hass=${this.hass}
.entry=${entry}
.dialogElement=${this._dialog}
@close-dialog=${this._closeDialog}
></entity-registry-settings>
`
? this._settingsElementTag
? html`
${dynamicElement(this._settingsElementTag, {
hass: this.hass,
entry,
entityId,
dialogElement: this._dialog,
})}
`
: ""
: html`
<paper-dialog-scrollable>
${this.hass.localize(
@ -121,7 +155,6 @@ export class DialogEntityRegistryDetail extends LitElement {
.hass=${this.hass}
.itemId=${entityId}
itemType="entity"
@close-dialog=${this._closeDialog}
></ha-related-items>
</paper-dialog-scrollable>
`
@ -131,6 +164,18 @@ export class DialogEntityRegistryDetail extends LitElement {
`;
}
private async _getEntityReg() {
try {
this._entry = await getExtendedEntityRegistryEntry(
this.hass,
this._params!.entity_id
);
this._loadPlatformSettingTabs();
} catch {
this._entry = null;
}
}
private _handleTabSelected(ev: CustomEvent): void {
if (!ev.detail.value) {
return;
@ -144,15 +189,26 @@ export class DialogEntityRegistryDetail extends LitElement {
fireEvent(this._dialog as HTMLElement, "iron-resize");
}
private async _loadPlatformSettingTabs(): Promise<void> {
if (!this._entry) {
return;
}
if (
!Object.keys(PLATFORMS_WITH_SETTINGS_TAB).includes(this._entry.platform)
) {
this._settingsElementTag = "entity-registry-settings";
return;
}
const tag = PLATFORMS_WITH_SETTINGS_TAB[this._entry.platform];
await import(`./editor-tabs/settings/${tag}`);
this._settingsElementTag = tag;
}
private _openMoreInfo(): void {
fireEvent(this, "hass-more-info", {
entityId: this._params!.entity_id,
});
this._params = undefined;
}
private _closeDialog(): void {
this._params = undefined;
this.closeDialog();
}
private _openedChanged(ev: PolymerChangedEvent<boolean>): void {
@ -250,6 +306,6 @@ export class DialogEntityRegistryDetail extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"dialog-entity-registry-detail": DialogEntityRegistryDetail;
"dialog-entity-editor": DialogEntityEditor;
}
}

View File

@ -0,0 +1,260 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
PropertyValues,
query,
TemplateResult,
} from "lit-element";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { HaPaperDialog } from "../../../../../components/dialog/ha-paper-dialog";
import { ExtEntityRegistryEntry } from "../../../../../data/entity_registry";
import {
deleteInputBoolean,
fetchInputBoolean,
updateInputBoolean,
} from "../../../../../data/input_boolean";
import {
deleteInputDateTime,
fetchInputDateTime,
updateInputDateTime,
} from "../../../../../data/input_datetime";
import {
deleteInputNumber,
fetchInputNumber,
updateInputNumber,
} from "../../../../../data/input_number";
import {
deleteInputSelect,
fetchInputSelect,
updateInputSelect,
} from "../../../../../data/input_select";
import {
deleteInputText,
fetchInputText,
updateInputText,
} from "../../../../../data/input_text";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../../../../../types";
import "../../../helpers/forms/ha-input_boolean-form";
import "../../../helpers/forms/ha-input_text-form";
import "../../../helpers/forms/ha-input_datetime-form";
import "../../../helpers/forms/ha-input_select-form";
import "../../../helpers/forms/ha-input_number-form";
import { Helper } from "../../../helpers/const";
import "../../entity-registry-basic-editor";
// tslint:disable-next-line: no-duplicate-imports
import { HaEntityRegistryBasicEditor } from "../../entity-registry-basic-editor";
const HELPERS = {
input_boolean: {
fetch: fetchInputBoolean,
update: updateInputBoolean,
delete: deleteInputBoolean,
},
input_text: {
fetch: fetchInputText,
update: updateInputText,
delete: deleteInputText,
},
input_number: {
fetch: fetchInputNumber,
update: updateInputNumber,
delete: deleteInputNumber,
},
input_datetime: {
fetch: fetchInputDateTime,
update: updateInputDateTime,
delete: deleteInputDateTime,
},
input_select: {
fetch: fetchInputSelect,
update: updateInputSelect,
delete: deleteInputSelect,
},
};
@customElement("entity-settings-helper-tab")
export class EntityRegistrySettingsHelper extends LitElement {
@property() public hass!: HomeAssistant;
@property() public entry!: ExtEntityRegistryEntry;
@property() public dialogElement!: HaPaperDialog;
@property() private _error?: string;
@property() private _item?: Helper | null;
@property() private _submitting?: boolean;
@property() private _componentLoaded?: boolean;
@query("ha-registry-basic-editor")
private _registryEditor?: HaEntityRegistryBasicEditor;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._componentLoaded = isComponentLoaded(this.hass, this.entry.platform);
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("entry")) {
this._error = undefined;
this._item = undefined;
this._getItem();
}
}
protected render(): TemplateResult {
if (this._item === undefined) {
return html``;
}
if (!this._componentLoaded) {
return html`
<paper-dialog-scrollable .dialogElement=${this.dialogElement}>
The ${this.entry.platform} component is not loaded, please add it your
configuration. Either by adding 'default_config:' or
'${this.entry.platform}:'.
</paper-dialog-scrollable>
`;
}
if (this._item === null) {
return html`
<paper-dialog-scrollable .dialogElement=${this.dialogElement}>
This entity can not be edited from the UI. Only entities setup from
the UI are editable.
</paper-dialog-scrollable>
`;
}
return html`
<paper-dialog-scrollable .dialogElement=${this.dialogElement}>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<div @value-changed=${this._valueChanged}>
${dynamicElement(`ha-${this.entry.platform}-form`, {
hass: this.hass,
item: this._item,
entry: this.entry,
})}
</div>
<ha-registry-basic-editor
.hass=${this.hass}
.entry=${this.entry}
></ha-registry-basic-editor>
</div>
</paper-dialog-scrollable>
<div class="buttons">
<mwc-button
class="warning"
@click=${this._confirmDeleteItem}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.delete")}
</mwc-button>
<mwc-button
@click=${this._updateItem}
.disabled=${this._submitting || !this._item.name}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
</mwc-button>
</div>
`;
}
private _valueChanged(ev: CustomEvent): void {
this._error = undefined;
this._item = ev.detail.value;
}
private async _getItem() {
const items = await HELPERS[this.entry.platform].fetch(this.hass!);
this._item = items.find((item) => item.id === this.entry.unique_id) || null;
await this.updateComplete;
fireEvent(this.dialogElement as HTMLElement, "iron-resize");
}
private async _updateItem(): Promise<void> {
if (!this._item) {
return;
}
this._submitting = true;
try {
await HELPERS[this.entry.platform].update(
this.hass!,
this._item.id,
this._item
);
await this._registryEditor?.updateEntry();
fireEvent(this, "close-dialog");
} catch (err) {
this._error = err.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _confirmDeleteItem(): Promise<void> {
if (!this._item) {
return;
}
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.dialogs.entity_registry.editor.confirm_delete"
),
}))
) {
return;
}
this._submitting = true;
try {
await HELPERS[this.entry.platform].delete(this.hass!, this._item.id);
fireEvent(this, "close-dialog");
} finally {
this._submitting = false;
}
}
static get styles(): CSSResult {
return css`
:host {
display: block;
padding: 0 !important;
}
.form {
padding-bottom: 24px;
}
.buttons {
display: flex;
justify-content: space-between;
padding: 8px;
margin-bottom: -20px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
.row {
margin-top: 8px;
color: var(--primary-text-color);
}
.secondary {
color: var(--secondary-text-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"entity-platform-helper-tab": EntityRegistrySettingsHelper;
}
}

View File

@ -0,0 +1,138 @@
import {
html,
css,
LitElement,
TemplateResult,
property,
customElement,
PropertyValues,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../components/ha-switch";
import {
ExtEntityRegistryEntry,
EntityRegistryEntryUpdateParams,
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
import { HomeAssistant } from "../../../types";
import { PolymerChangedEvent } from "../../../polymer-types";
// tslint:disable-next-line: no-duplicate-imports
import { HaSwitch } from "../../../components/ha-switch";
import { computeDomain } from "../../../common/entity/compute_domain";
@customElement("ha-registry-basic-editor")
export class HaEntityRegistryBasicEditor extends LitElement {
@property() public hass!: HomeAssistant;
@property() public entry!: ExtEntityRegistryEntry;
@property() private _origEntityId!: string;
@property() private _entityId!: string;
@property() private _disabledBy!: string | null;
@property() private _submitting?: boolean;
public async updateEntry(): Promise<void> {
this._submitting = true;
const params: Partial<EntityRegistryEntryUpdateParams> = {
new_entity_id: this._entityId.trim(),
};
if (this._disabledBy === null || this._disabledBy === "user") {
params.disabled_by = this._disabledBy;
}
try {
await updateEntityRegistryEntry(this.hass!, this._origEntityId, params);
} catch (err) {
throw err;
} finally {
this._submitting = false;
}
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!changedProperties.has("entry")) {
return;
}
if (this.entry) {
this._origEntityId = this.entry.entity_id;
this._entityId = this.entry.entity_id;
this._disabledBy = this.entry.disabled_by;
}
}
protected render(): TemplateResult {
if (
!this.hass ||
!this.entry ||
this.entry.entity_id !== this._origEntityId
) {
return html``;
}
const invalidDomainUpdate =
computeDomain(this._entityId.trim()) !==
computeDomain(this.entry.entity_id);
return html`
<paper-input
.value=${this._entityId}
@value-changed=${this._entityIdChanged}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_id"
)}
error-message="Domain needs to stay the same"
.invalid=${invalidDomainUpdate}
.disabled=${this._submitting}
></paper-input>
<div class="row">
<ha-switch
.checked=${!this._disabledBy}
@change=${this._disabledByChanged}
>
<div>
<div>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.enabled_label"
)}
</div>
<div class="secondary">
${this._disabledBy && this._disabledBy !== "user"
? this.hass.localize(
"ui.dialogs.entity_registry.editor.enabled_cause",
"cause",
this.hass.localize(
`config_entry.disabled_by.${this._disabledBy}`
)
)
: ""}
${this.hass.localize(
"ui.dialogs.entity_registry.editor.enabled_description"
)}
<br />${this.hass.localize(
"ui.dialogs.entity_registry.editor.note"
)}
</div>
</div>
</ha-switch>
</div>
`;
}
private _entityIdChanged(ev: PolymerChangedEvent<string>): void {
this._entityId = ev.detail.value;
}
private _disabledByChanged(ev: Event): void {
this._disabledBy = (ev.target as HaSwitch).checked ? null : "user";
}
static get styles() {
return css`
.row {
margin-top: 8px;
color: var(--primary-text-color);
}
.secondary {
color: var(--secondary-text-color);
}
`;
}
}

View File

@ -12,26 +12,28 @@ import {
} from "lit-element";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-switch";
import "../../../components/ha-icon-input";
// tslint:disable-next-line: no-duplicate-imports
import { HaSwitch } from "../../../components/ha-switch";
import {
EntityRegistryEntry,
removeEntityRegistryEntry,
updateEntityRegistryEntry,
EntityRegistryEntryUpdateParams,
ExtEntityRegistryEntry,
} from "../../../data/entity_registry";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { PolymerChangedEvent } from "../../../polymer-types";
import { HomeAssistant } from "../../../types";
import { haStyle } from "../../../resources/styles";
@customElement("entity-registry-settings")
export class EntityRegistrySettings extends LitElement {
@property() public hass!: HomeAssistant;
@property() public entry!: EntityRegistryEntry;
@property() public entry!: ExtEntityRegistryEntry;
@property() public dialogElement!: HTMLElement;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _entityId!: string;
@property() private _disabledBy!: string | null;
@property() private _error?: string;
@ -43,6 +45,7 @@ export class EntityRegistrySettings extends LitElement {
if (changedProperties.has("entry")) {
this._error = undefined;
this._name = this.entry.name || "";
this._icon = this.entry.icon || "";
this._origEntityId = this.entry.entity_id;
this._entityId = this.entry.entity_id;
this._disabledBy = this.entry.disabled_by;
@ -59,7 +62,6 @@ export class EntityRegistrySettings extends LitElement {
const invalidDomainUpdate =
computeDomain(this._entityId.trim()) !==
computeDomain(this.entry.entity_id);
return html`
<paper-dialog-scrollable .dialogElement=${this.dialogElement}>
${!stateObj
@ -83,9 +85,21 @@ export class EntityRegistrySettings extends LitElement {
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.placeholder=${stateObj ? computeStateName(stateObj) : ""}
.placeholder=${this.entry.original_name}
.disabled=${this._submitting}
></paper-input>
<ha-icon-input
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.icon"
)}
.placeholder=${this.entry.original_icon}
.disabled=${this._submitting}
.errorMessage=${this.hass.localize(
"ui.dialogs.entity_registry.editor.icon_error"
)}
></ha-icon-input>
<paper-input
.value=${this._entityId}
@value-changed=${this._entityIdChanged}
@ -153,6 +167,11 @@ export class EntityRegistrySettings extends LitElement {
this._name = ev.detail.value;
}
private _iconChanged(ev: PolymerChangedEvent<string>): void {
this._error = undefined;
this._icon = ev.detail.value;
}
private _entityIdChanged(ev: PolymerChangedEvent<string>): void {
this._error = undefined;
this._entityId = ev.detail.value;
@ -162,6 +181,7 @@ export class EntityRegistrySettings extends LitElement {
this._submitting = true;
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
new_entity_id: this._entityId.trim(),
};
if (this._disabledBy === null || this._disabledBy === "user") {
@ -192,7 +212,7 @@ export class EntityRegistrySettings extends LitElement {
try {
await removeEntityRegistryEntry(this.hass!, this._origEntityId);
fireEvent(this as HTMLElement, "close-dialog");
fireEvent(this, "close-dialog");
} finally {
this._submitting = false;
}
@ -202,36 +222,39 @@ export class EntityRegistrySettings extends LitElement {
this._disabledBy = (ev.target as HaSwitch).checked ? null : "user";
}
static get styles(): CSSResult {
return css`
:host {
display: block;
margin-bottom: 0 !important;
padding: 0 !important;
}
.form {
padding-bottom: 24px;
}
.buttons {
display: flex;
justify-content: flex-end;
padding: 8px;
}
mwc-button.warning {
margin-right: auto;
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
.row {
margin-top: 8px;
color: var(--primary-text-color);
}
.secondary {
color: var(--secondary-text-color);
}
`;
static get styles(): CSSResult[] {
return [
haStyle,
css`
:host {
display: block;
margin-bottom: 0 !important;
padding: 0 !important;
}
.form {
padding-bottom: 24px;
}
.buttons {
display: flex;
justify-content: flex-end;
padding: 8px;
}
mwc-button.warning {
margin-right: auto;
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
.row {
margin-top: 8px;
color: var(--primary-text-color);
}
.secondary {
color: var(--secondary-text-color);
}
`,
];
}
}

View File

@ -39,11 +39,11 @@ import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types";
import { DialogEntityRegistryDetail } from "./dialog-entity-registry-detail";
import { DialogEntityEditor } from "./dialog-entity-editor";
import {
loadEntityRegistryDetailDialog,
showEntityRegistryDetailDialog,
} from "./show-dialog-entity-registry-detail";
loadEntityEditorDialog,
showEntityEditorDialog,
} from "./show-dialog-entity-editor";
import { configSections } from "../ha-panel-config";
import { classMap } from "lit-html/directives/class-map";
import { computeStateName } from "../../../common/entity/compute_state_name";
@ -75,7 +75,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@property() private _selectedEntities: string[] = [];
@query("hass-tabs-subpage-data-table")
private _dataTable!: HaTabsSubpageDataTable;
private getDialog?: () => DialogEntityRegistryDetail | undefined;
private getDialog?: () => DialogEntityEditor | undefined;
private _columns = memoize(
(narrow, _language): DataTableColumnContainer => {
@ -387,33 +387,35 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
.route=${this.route}
.tabs=${configSections.integrations}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._filteredEntities(
this._entities,
this.hass.states,
this._showDisabled,
this._showUnavailable,
this._showReadOnly
)}
.filter=${this._filter}
selectable
@selection-changed=${this._handleSelectionChanged}
@row-click=${this._openEditEntry}
id="entity_id"
.data=${this._filteredEntities(
this._entities,
this.hass.states,
this._showDisabled,
this._showUnavailable,
this._showReadOnly
)}
.filter=${this._filter}
selectable
@selection-changed=${this._handleSelectionChanged}
@row-click=${this._openEditEntry}
id="entity_id"
>
<div class=${classMap({
"search-toolbar": this.narrow,
"table-header": !this.narrow,
})} slot="header">
${headerToolbar}
</div>
</ha-data-table>
<div
class=${classMap({
"search-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
${headerToolbar}
</div>
</hass-tabs-subpage-data-table>
`;
}
protected firstUpdated(changedProps): void {
super.firstUpdated(changedProps);
loadEntityRegistryDetailDialog();
loadEntityEditorDialog();
}
private _showDisabledChanged() {
@ -524,7 +526,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
const entry = this._entities!.find(
(entity) => entity.entity_id === entityId
);
this.getDialog = showEntityRegistryDetailDialog(this, {
this.getDialog = showEntityEditorDialog(this, {
entry,
entity_id: entityId,
});

View File

@ -1,32 +1,33 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { EntityRegistryEntry } from "../../../data/entity_registry";
import { DialogEntityRegistryDetail } from "./dialog-entity-registry-detail";
import { DialogEntityEditor } from "./dialog-entity-editor";
export interface EntityRegistryDetailDialogParams {
entry?: EntityRegistryEntry;
entity_id: string;
tab?: string;
}
export const loadEntityRegistryDetailDialog = () =>
export const loadEntityEditorDialog = () =>
import(
/* webpackChunkName: "entity-registry-detail-dialog" */ "./dialog-entity-registry-detail"
/* webpackChunkName: "entity-editor-dialog" */ "./dialog-entity-editor"
);
const getDialog = () => {
return document
.querySelector("home-assistant")!
.shadowRoot!.querySelector("dialog-entity-registry-detail") as
| DialogEntityRegistryDetail
.shadowRoot!.querySelector("dialog-entity-editor") as
| DialogEntityEditor
| undefined;
};
export const showEntityRegistryDetailDialog = (
export const showEntityEditorDialog = (
element: HTMLElement,
entityDetailParams: EntityRegistryDetailDialogParams
): (() => DialogEntityRegistryDetail | undefined) => {
): (() => DialogEntityEditor | undefined) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-entity-registry-detail",
dialogImport: loadEntityRegistryDetailDialog,
dialogTag: "dialog-entity-editor",
dialogImport: loadEntityEditorDialog,
dialogParams: entityDetailParams,
});
return getDialog;

View File

@ -6,10 +6,6 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { HomeAssistant, Route } from "../../types";
import { CloudStatus, fetchCloudStatus } from "../../data/cloud";
import { listenMediaQuery } from "../../common/dom/media_query";
import {
getOptimisticFrontendUserDataCollection,
CoreFrontendUserData,
} from "../../data/frontend";
import { HassRouterPage, RouterOptions } from "../../layouts/hass-router-page";
import { PolymerElement } from "@polymer/polymer";
import { PageNavigation } from "../../layouts/hass-tabs-subpage";
@ -71,6 +67,21 @@ export const configSections: { [name: string]: PageNavigation[] } = {
translationKey: "ui.panel.config.script.caption",
icon: "hass:script-text",
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
icon: "hass:tools",
core: true,
},
],
lovelace: [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.caption",
icon: "hass:view-dashboard",
},
],
persons: [
{
@ -114,7 +125,7 @@ export const configSections: { [name: string]: PageNavigation[] } = {
translationKey: "ui.panel.config.customize.caption",
icon: "hass:pencil",
core: true,
exportOnly: true,
advancedOnly: true,
},
],
other: [
@ -214,6 +225,13 @@ class HaPanelConfig extends HassRouterPage {
/* webpackChunkName: "panel-config-integrations" */ "./integrations/ha-config-integrations"
),
},
lovelace: {
tag: "ha-config-lovelace",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace" */ "./lovelace/ha-config-lovelace"
),
},
person: {
tag: "ha-config-person",
load: () =>
@ -235,6 +253,13 @@ class HaPanelConfig extends HassRouterPage {
/* webpackChunkName: "panel-config-scene" */ "./scene/ha-config-scene"
),
},
helpers: {
tag: "ha-config-helpers",
load: () =>
import(
/* webpackChunkName: "panel-config-helpers" */ "./helpers/ha-config-helpers"
),
},
users: {
tag: "ha-config-users",
load: () =>
@ -268,8 +293,6 @@ class HaPanelConfig extends HassRouterPage {
@property() private _wideSidebar: boolean = false;
@property() private _wide: boolean = false;
@property() private _coreUserData?: CoreFrontendUserData;
@property() private _showAdvanced = false;
@property() private _cloudStatus?: CloudStatus;
private _listeners: Array<() => void> = [];
@ -286,17 +309,6 @@ class HaPanelConfig extends HassRouterPage {
this._wideSidebar = matches;
})
);
this._listeners.push(
getOptimisticFrontendUserDataCollection(
this.hass.connection,
"core"
).subscribe((coreUserData) => {
this._coreUserData = coreUserData || {};
this._showAdvanced = !!(
this._coreUserData && this._coreUserData.showAdvanced
);
})
);
}
public disconnectedCallback() {
@ -337,7 +349,7 @@ class HaPanelConfig extends HassRouterPage {
(el as PolymerElement).setProperties({
route: this.routeTail,
hass: this.hass,
showAdvanced: this._showAdvanced,
showAdvanced: Boolean(this.hass.userData?.showAdvanced),
isWide,
narrow: this.narrow,
cloudStatus: this._cloudStatus,
@ -345,7 +357,7 @@ class HaPanelConfig extends HassRouterPage {
} else {
el.route = this.routeTail;
el.hass = this.hass;
el.showAdvanced = this._showAdvanced;
el.showAdvanced = Boolean(this.hass.userData?.showAdvanced);
el.isWide = isWide;
el.narrow = this.narrow;
el.cloudStatus = this._cloudStatus;

View File

@ -0,0 +1,24 @@
import { InputBoolean } from "../../../data/input_boolean";
import { InputText } from "../../../data/input_text";
import { InputNumber } from "../../../data/input_number";
import { InputSelect } from "../../../data/input_select";
import { InputDateTime } from "../../../data/input_datetime";
export const HELPER_DOMAINS = [
"input_boolean",
"input_text",
"input_number",
"input_datetime",
"input_select",
];
export type Helper =
| InputBoolean
| InputText
| InputNumber
| InputSelect
| InputDateTime;

View File

@ -0,0 +1,179 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../components/ha-dialog";
import { HomeAssistant } from "../../../types";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import { createInputBoolean } from "../../../data/input_boolean";
import { createInputText } from "../../../data/input_text";
import { createInputNumber } from "../../../data/input_number";
import { createInputDateTime } from "../../../data/input_datetime";
import { createInputSelect } from "../../../data/input_select";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { Helper } from "./const";
import "@polymer/paper-item/paper-icon-item";
import "./forms/ha-input_boolean-form";
import "./forms/ha-input_text-form";
import "./forms/ha-input_datetime-form";
import "./forms/ha-input_select-form";
import "./forms/ha-input_number-form";
import { domainIcon } from "../../../common/entity/domain_icon";
import { classMap } from "lit-html/directives/class-map";
import { haStyleDialog } from "../../../resources/styles";
const HELPERS = {
input_boolean: createInputBoolean,
input_text: createInputText,
input_number: createInputNumber,
input_datetime: createInputDateTime,
input_select: createInputSelect,
};
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _item?: Helper;
@property() private _opened = false;
@property() private _platform?: string;
@property() private _error?: string;
@property() private _submitting = false;
public async showDialog(): Promise<void> {
this._platform = undefined;
this._item = undefined;
this._opened = true;
await this.updateComplete;
}
public closeDialog(): void {
this._opened = false;
this._error = "";
}
protected render(): TemplateResult {
return html`
<ha-dialog
.open=${this._opened}
@closing=${this.closeDialog}
class=${classMap({ "button-left": !this._platform })}
.heading=${this._platform
? this.hass.localize(
"ui.panel.config.helpers.dialog.add_platform",
"platform",
this.hass.localize(
`ui.panel.config.helpers.types.${this._platform}`
) || this._platform
)
: this.hass.localize("ui.panel.config.helpers.dialog.add_helper")}
>
${this._platform
? html`
<div class="form" @value-changed=${this._valueChanged}>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
${dynamicElement(`ha-${this._platform}-form`, {
hass: this.hass,
item: this._item,
new: true,
})}
</div>
<mwc-button
slot="primaryAction"
@click="${this._createItem}"
.disabled=${this._submitting}
>
${this.hass!.localize("ui.panel.config.helpers.dialog.create")}
</mwc-button>
<mwc-button
slot="secondaryAction"
@click="${this._goBack}"
.disabled=${this._submitting}
>
Back
</mwc-button>
`
: html`
${Object.keys(HELPERS).map((platform: string) => {
return html`
<paper-icon-item
.disabled=${!isComponentLoaded(this.hass, platform)}
@click="${this._platformPicked}"
.platform="${platform}"
>
<ha-icon
slot="item-icon"
.icon=${domainIcon(platform)}
></ha-icon>
<span class="item-text">
${this.hass.localize(
`ui.panel.config.helpers.types.${platform}`
) || platform}
</span>
</paper-icon-item>
`;
})}
<mwc-button slot="primaryAction" @click="${this.closeDialog}">
${this.hass!.localize("ui.common.cancel")}
</mwc-button>
`}
</ha-dialog>
`;
}
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
}
private async _createItem(): Promise<void> {
if (!this._platform || !this._item) {
return;
}
this._submitting = true;
this._error = "";
try {
await HELPERS[this._platform](this.hass, this._item);
this.closeDialog();
} catch (err) {
this._error = err.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private _platformPicked(ev: Event): void {
this._platform = (ev.currentTarget! as any).platform;
}
private _goBack() {
this._platform = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog.button-left {
--justify-action-buttons: flex-start;
}
paper-icon-item {
cursor: pointer;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-helper-detail": DialogHelperDetail;
}
}

View File

@ -0,0 +1,137 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../../components/ha-switch";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import { InputBoolean } from "../../../../data/input_boolean";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle } from "../../../../resources/styles";
@customElement("ha-input_boolean-form")
class HaInputBooleanForm extends LitElement {
@property() public hass!: HomeAssistant;
@property() public new?: boolean;
private _item?: InputBoolean;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _initial?: boolean;
set item(item: InputBoolean) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._initial = item.initial;
} else {
this._name = "";
this._icon = "";
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage="${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}"
.invalid=${nameInvalid}
></paper-input>
<ha-icon-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-input>
<br />
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value_explain"
)}
${this.hass.userData?.showAdvanced
? html`
<div class="row layout horizontal justified">
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value"
)}:
<ha-switch
.checked=${this._initial}
@change=${this._initialChanged}
></ha-switch>
</div>
`
: ""}
</div>
`;
}
private _initialChanged(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this._item, initial: ev.target.checked },
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail.value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (!value) {
delete newValue[configValue];
} else {
newValue[configValue] = ev.detail.value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.form {
color: var(--primary-text-color);
}
.row {
padding: 16px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input_boolean-form": HaInputBooleanForm;
}
}

View File

@ -0,0 +1,165 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../../components/ha-switch";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle } from "../../../../resources/styles";
import { InputDateTime } from "../../../../data/input_datetime";
@customElement("ha-input_datetime-form")
class HaInputDateTimeForm extends LitElement {
@property() public hass!: HomeAssistant;
@property() public new?: boolean;
private _item?: InputDateTime;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _initial?: string;
@property() private _hasTime?: boolean;
@property() private _hasDate?: boolean;
set item(item: InputDateTime) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._initial = item.initial;
this._hasTime = item.has_time;
this._hasDate = item.has_date;
} else {
this._name = "";
this._icon = "";
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage="${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}"
.invalid=${nameInvalid}
></paper-input>
<ha-icon-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-input>
<div class="row layout horizontal justified">
${this.hass!.localize(
"ui.dialogs.helper_settings.input_datetime.has_time"
)}:
<ha-switch
.checked=${this._hasTime}
@change=${this._hasTimeChanged}
></ha-switch>
</div>
<div class="row layout horizontal justified">
${this.hass!.localize(
"ui.dialogs.helper_settings.input_datetime.has_date"
)}:
<ha-switch
.checked=${this._hasDate}
@change=${this._hasDateChanged}
></ha-switch>
</div>
${this.hass.userData?.showAdvanced
? html`
<br />
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value_explain"
)}
<paper-input
.value=${this._initial}
.configValue=${"initial"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value"
)}
></paper-input>
`
: ""}
</div>
`;
}
private _hasTimeChanged(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this._item, has_time: ev.target.checked },
});
}
private _hasDateChanged(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this._item, has_date: ev.target.checked },
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail.value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (!value) {
delete newValue[configValue];
} else {
newValue[configValue] = ev.detail.value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.form {
color: var(--primary-text-color);
}
.row {
padding: 16px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input_datetime-form": HaInputDateTimeForm;
}
}

View File

@ -0,0 +1,214 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../../components/ha-switch";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle } from "../../../../resources/styles";
import { InputNumber } from "../../../../data/input_number";
@customElement("ha-input_number-form")
class HaInputNumberForm extends LitElement {
@property() public hass!: HomeAssistant;
@property() public new?: boolean;
private _item?: Partial<InputNumber>;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _initial?: number;
@property() private _max?: number;
@property() private _min?: number;
@property() private _mode?: string;
@property() private _step?: number;
// tslint:disable-next-line: variable-name
@property() private _unit_of_measurement?: string;
set item(item: InputNumber) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._max = item.max ?? 100;
this._min = item.min ?? 0;
this._initial = item.initial;
this._mode = item.mode || "slider";
this._step = item.step || 1;
this._unit_of_measurement = item.unit_of_measurement;
} else {
this._item = {
min: 0,
max: 0,
};
this._name = "";
this._icon = "";
this._max = 100;
this._min = 0;
this._mode = "slider";
this._step = 1;
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage="${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}"
.invalid=${nameInvalid}
></paper-input>
<ha-icon-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-input>
<paper-input
.value=${this._min}
.configValue=${"min"}
type="number"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.min"
)}
></paper-input>
<paper-input
.value=${this._max}
.configValue=${"max"}
type="number"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.max"
)}
></paper-input>
${this.hass.userData?.showAdvanced
? html`
<div class="layout horizontal center justified">
${this.hass.localize(
"ui.dialogs.helper_settings.input_number.mode"
)}
<paper-radio-group
.selected=${this._mode}
@selected-changed=${this._modeChanged}
>
<paper-radio-button name="slider">
${this.hass.localize(
"ui.dialogs.helper_settings.input_number.slider"
)}
</paper-radio-button>
<paper-radio-button name="box">
${this.hass.localize(
"ui.dialogs.helper_settings.input_number.box"
)}
</paper-radio-button>
</paper-radio-group>
</div>
${this._mode === "slider"
? html`
<paper-input
.value=${this._step}
.configValue=${"step"}
type="number"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.step"
)}
></paper-input>
`
: ""}
<paper-input
.value=${this._unit_of_measurement}
.configValue=${"unit_of_measurement"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.unit_of_measurement"
)}
></paper-input>
<br />
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value_explain"
)}
<paper-input
.value=${this._initial}
.configValue=${"initial"}
type="number"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value"
)}
></paper-input>
`
: ""}
</div>
`;
}
private _modeChanged(ev: CustomEvent) {
fireEvent(this, "value-changed", {
value: { ...this._item, mode: ev.detail.value },
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail.value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (value === undefined || value === "") {
delete newValue[configValue];
} else {
newValue[configValue] = ev.detail.value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.form {
color: var(--primary-text-color);
}
ha-paper-dropdown-menu {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input_number-form": HaInputNumberForm;
}
}

View File

@ -0,0 +1,240 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
customElement,
query,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../../components/ha-switch";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle } from "../../../../resources/styles";
import { InputSelect } from "../../../../data/input_select";
// tslint:disable-next-line: no-duplicate-imports
import { PaperInputElement } from "@polymer/paper-input/paper-input";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
@customElement("ha-input_select-form")
class HaInputSelectForm extends LitElement {
@property() public hass!: HomeAssistant;
@property() public new?: boolean;
private _item?: InputSelect;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _options: string[] = [];
@property() private _initial?: string;
@query("#option_input") private _optionInput?: PaperInputElement;
set item(item: InputSelect) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._initial = item.initial;
this._options = item.options || [];
} else {
this._name = "";
this._icon = "";
this._options = [];
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage="${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}"
.invalid=${nameInvalid}
></paper-input>
<ha-icon-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-input>
${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.options"
)}:
${this._options.length
? this._options.map((option, index) => {
return html`
<paper-item class="option">
<paper-item-body> ${option} </paper-item-body>
<paper-icon-button
.index=${index}
.title=${this.hass.localize(
"ui.dialogs.helper_settings.input_select.remove_option"
)}
@click=${this._removeOption}
icon="hass:delete"
></paper-icon-button>
</paper-item>
`;
})
: html`
<paper-item>
${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.no_options"
)}
</paper-item>
`}
<div class="layout horizontal bottom">
<paper-input
class="flex-auto"
id="option_input"
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.add_option"
)}
@keydown=${this._handleKeyAdd}
></paper-input>
<mwc-button @click=${this._addOption}
>${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.add"
)}</mwc-button
>
</div>
${this.hass.userData?.showAdvanced
? html`
<br />
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value_explain"
)}
<ha-paper-dropdown-menu
label-float
dynamic-align
.label=${this.hass.localize(
"ui.dialogs.helper_settings.generic.initial_value"
)}
>
<paper-listbox
slot="dropdown-content"
attr-for-selected="item-initial"
.selected=${this._initial}
@selected-changed=${this._initialChanged}
>
${this._options.map(
(option) => html`
<paper-item item-initial=${option}>${option}</paper-item>
`
)}
</paper-listbox>
</ha-paper-dropdown-menu>
`
: ""}
</div>
`;
}
private _initialChanged(ev: CustomEvent) {
fireEvent(this, "value-changed", {
value: { ...this._item, initial: ev.detail.value },
});
}
private _handleKeyAdd(ev: KeyboardEvent) {
ev.stopPropagation();
if (ev.keyCode !== 13) {
return;
}
this._addOption();
}
private _addOption() {
const input = this._optionInput;
if (!input || !input.value) {
return;
}
fireEvent(this, "value-changed", {
value: { ...this._item, options: [...this._options, input.value] },
});
input.value = "";
}
private async _removeOption(ev: Event) {
if (
!(await showConfirmationDialog(this, {
title: "Delete this item?",
text: "Are you sure you want to delete this item?",
}))
) {
return;
}
const index = (ev.target as any).index;
const options = [...this._options];
options.splice(index, 1);
fireEvent(this, "value-changed", {
value: { ...this._item, options },
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail.value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (!value) {
delete newValue[configValue];
} else {
newValue[configValue] = ev.detail.value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.form {
color: var(--primary-text-color);
}
.option {
border: 1px solid var(--divider-color);
border-radius: 4px;
margin-top: 4px;
}
mwc-button {
margin-left: 8px;
}
ha-paper-dropdown-menu {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input_select-form": HaInputSelectForm;
}
}

View File

@ -0,0 +1,199 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "../../../../components/ha-switch";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import { InputText } from "../../../../data/input_text";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle } from "../../../../resources/styles";
@customElement("ha-input_text-form")
class HaInputTextForm extends LitElement {
@property() public hass!: HomeAssistant;
@property() public new?: boolean;
private _item?: InputText;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _initial?: string;
@property() private _max?: number;
@property() private _min?: number;
@property() private _mode?: string;
@property() private _pattern?: string;
set item(item: InputText) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._max = item.max || 100;
this._min = item.min || 0;
this._initial = item.initial;
this._mode = item.mode || "text";
this._pattern = item.pattern;
} else {
this._name = "";
this._icon = "";
this._max = 100;
this._min = 0;
this._mode = "text";
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage="${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}"
.invalid=${nameInvalid}
></paper-input>
<ha-icon-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-input>
${this.hass.userData?.showAdvanced
? html`
<paper-input
.value=${this._min}
.configValue=${"min"}
type="number"
min="0"
max="255"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_text.min"
)}
></paper-input>
<paper-input
.value=${this._max}
.configValue=${"max"}
min="0"
max="255"
type="number"
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_text.max"
)}
></paper-input>
<div class="layout horizontal center justified">
${this.hass.localize(
"ui.dialogs.helper_settings.input_text.mode"
)}
<paper-radio-group
.selected=${this._mode}
@selected-changed=${this._modeChanged}
>
<paper-radio-button name="text">
${this.hass.localize(
"ui.dialogs.helper_settings.input_text.text"
)}
</paper-radio-button>
<paper-radio-button name="password">
${this.hass.localize(
"ui.dialogs.helper_settings.input_text.password"
)}
</paper-radio-button>
</paper-radio-group>
</div>
<paper-input
.value=${this._pattern}
.configValue=${"pattern"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_text.pattern"
)}
></paper-input>
<br />
${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value_explain"
)}
<paper-input
.value=${this._initial}
.configValue=${"initial"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.initial_value"
)}
></paper-input>
`
: ""}
</div>
`;
}
private _modeChanged(ev: CustomEvent) {
fireEvent(this, "value-changed", {
value: { ...this._item, mode: ev.detail.value },
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail.value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (!value) {
delete newValue[configValue];
} else {
newValue[configValue] = ev.detail.value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.form {
color: var(--primary-text-color);
}
.row {
padding: 16px 0;
}
ha-paper-dropdown-menu {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input_text-form": HaInputTextForm;
}
}

View File

@ -0,0 +1,184 @@
import "@polymer/paper-checkbox/paper-checkbox";
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-tooltip/paper-tooltip";
import { HassEntity } from "home-assistant-js-websocket";
import {
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import memoize from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import "../../../common/search/search-input";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-icon";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
import { HELPER_DOMAINS } from "./const";
@customElement("ha-config-helpers")
export class HaConfigHelpers extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _stateItems: HassEntity[] = [];
private _columns = memoize(
(_language): DataTableColumnContainer => {
return {
icon: {
title: "",
type: "icon",
template: (icon) => html`
<ha-icon slot="item-icon" .icon=${icon}></ha-icon>
`,
},
name: {
title: this.hass.localize(
"ui.panel.config.helpers.picker.headers.name"
),
sortable: true,
filterable: true,
direction: "asc",
template: (name, item: any) =>
html`
${name}
<div style="color: var(--secondary-text-color)">
${item.entity_id}
</div>
`,
},
type: {
title: this.hass.localize(
"ui.panel.config.helpers.picker.headers.type"
),
sortable: true,
filterable: true,
template: (type) =>
html`
${this.hass.localize(`ui.panel.config.helpers.types.${type}`) ||
type}
`,
},
};
}
);
private _getItems = memoize((stateItems: HassEntity[]) => {
return stateItems.map((state) => {
return {
id: state.entity_id,
icon: state.attributes.icon,
name: state.attributes.friendly_name || "",
entity_id: state.entity_id,
editable: state.attributes.editable,
type: computeStateDomain(state),
};
});
});
protected render(): TemplateResult {
if (!this.hass || this._stateItems === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automation}
.columns=${this._columns(this.hass.language)}
.data=${this._getItems(this._stateItems)}
@row-click=${this._openEditDialog}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.helpers.picker.add_helper"
)}"
@click=${this._createHelpler}
></ha-fab>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getStates();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && this._stateItems) {
this._getStates(oldHass);
}
}
private _getStates(oldHass?: HomeAssistant) {
let changed = false;
const tempStates = Object.values(this.hass!.states).filter((entity) => {
if (!HELPER_DOMAINS.includes(computeStateDomain(entity))) {
return false;
}
if (oldHass?.states[entity.entity_id] !== entity) {
changed = true;
}
return true;
});
if (changed || this._stateItems.length !== tempStates.length) {
this._stateItems = tempStates;
}
}
private async _openEditDialog(ev: CustomEvent): Promise<void> {
const entityId = (ev.detail as RowClickedEvent).id;
showEntityEditorDialog(this, {
entity_id: entityId,
});
}
private _createHelpler() {
showHelperDetailDialog(this);
}
static get styles(): CSSResult {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
`;
}
}

View File

@ -0,0 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const loadHelperDetailDialog = () =>
import(
/* webpackChunkName: "helper-detail-dialog" */ "./dialog-helper-detail"
);
export const showHelperDetailDialog = (element: HTMLElement) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-helper-detail",
dialogImport: loadHelperDetailDialog,
dialogParams: {},
});
};

View File

@ -0,0 +1,262 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import {
LovelaceDashboard,
LovelaceDashboardMutableParams,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
import { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { PolymerChangedEvent } from "../../../../polymer-types";
import { HaSwitch } from "../../../../components/ha-switch";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _params?: LovelaceDashboardDetailsDialogParams;
@property() private _urlPath!: LovelaceDashboard["url_path"];
@property() private _showSidebar!: boolean;
@property() private _sidebarIcon!: string;
@property() private _sidebarTitle!: string;
@property() private _requireAdmin!: LovelaceDashboard["require_admin"];
@property() private _error?: string;
@property() private _submitting = false;
public async showDialog(
params: LovelaceDashboardDetailsDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
if (this._params.dashboard) {
this._urlPath = this._params.dashboard.url_path || "";
this._showSidebar = !!this._params.dashboard.sidebar;
this._sidebarIcon = this._params.dashboard.sidebar?.icon || "";
this._sidebarTitle = this._params.dashboard.sidebar?.title || "";
this._requireAdmin = this._params.dashboard.require_admin || false;
} else {
this._urlPath = "";
this._showSidebar = true;
this._sidebarIcon = "";
this._sidebarTitle = "";
this._requireAdmin = false;
}
await this.updateComplete;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
const urlInvalid = !/^[a-zA-Z0-9_-]+$/.test(this._urlPath);
return html`
<ha-dialog
open
@closing="${this._close}"
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.dashboard
? this._sidebarTitle ||
this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
)}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<ha-switch
.checked=${this._showSidebar}
@change=${this._showSidebarChanged}
>${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.show_sidebar"
)}</ha-switch
>
${this._showSidebar
? html`
<ha-icon-input
.value=${this._sidebarIcon}
@value-changed=${this._sidebarIconChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.icon"
)}
></ha-icon-input>
<paper-input
.value=${this._sidebarTitle}
@value-changed=${this._sidebarTitleChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.title"
)}
@blur=${this._fillUrlPath}
></paper-input>
`
: ""}
${!this._params.dashboard
? html`
<paper-input
.value=${this._urlPath}
@value-changed=${this._urlChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.url"
)}
.errorMessage=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.url_error_msg"
)}
.invalid=${urlInvalid}
></paper-input>
`
: ""}
<ha-switch
.checked=${this._requireAdmin}
@change=${this._requireAdminChanged}
>${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.require_admin"
)}</ha-switch
>
</div>
</div>
${this._params.dashboard
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteDashboard}"
.disabled=${this._submitting}
>
${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.delete"
)}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateDashboard}"
.disabled=${urlInvalid || this._submitting}
>
${this._params.dashboard
? this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
)
: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.create"
)}
</mwc-button>
</ha-dialog>
`;
}
private _urlChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._urlPath = ev.detail.value;
}
private _sidebarIconChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._sidebarIcon = ev.detail.value;
}
private _sidebarTitleChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._sidebarTitle = ev.detail.value;
}
private _fillUrlPath() {
if (this._urlPath) {
return;
}
const parts = this._sidebarTitle.split(" ");
if (parts.length) {
this._urlPath = parts[0].toLowerCase();
}
}
private _showSidebarChanged(ev: Event) {
this._showSidebar = (ev.target as HaSwitch).checked;
}
private _requireAdminChanged(ev: Event) {
this._requireAdmin = (ev.target as HaSwitch).checked;
}
private async _updateDashboard() {
this._submitting = true;
try {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._requireAdmin,
sidebar: this._showSidebar
? { icon: this._sidebarIcon, title: this._sidebarTitle }
: null,
};
if (this._params!.dashboard) {
await this._params!.updateDashboard(values);
} else {
(values as LovelaceDashboardCreateParams).url_path = this._urlPath.trim();
(values as LovelaceDashboardCreateParams).mode = "storage";
await this._params!.createDashboard(
values as LovelaceDashboardCreateParams
);
}
this._params = undefined;
} catch (err) {
this._error = err?.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteDashboard() {
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
this._close();
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.form {
padding-bottom: 24px;
}
ha-switch {
padding: 16px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lovelace-dashboard-detail": DialogLovelaceDashboardDetail;
}
}

View File

@ -0,0 +1,276 @@
import {
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import memoize from "memoize-one";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-icon";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../../types";
import {
LovelaceDashboard,
fetchDashboards,
createDashboard,
updateDashboard,
deleteDashboard,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
import { compare } from "../../../../common/string/compare";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { lovelaceTabs } from "../ha-config-lovelace";
import { navigate } from "../../../../common/navigate";
@customElement("ha-config-lovelace-dashboards")
export class HaConfigLovelaceDashboards extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _dashboards: LovelaceDashboard[] = [];
private _columns = memoize(
(_language, dashboards): DataTableColumnContainer => {
const columns: DataTableColumnContainer = {
icon: {
title: "",
type: "icon",
template: (icon) =>
icon
? html`
<ha-icon slot="item-icon" .icon=${icon}></ha-icon>
`
: html``,
},
title: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.title"
),
sortable: true,
filterable: true,
direction: "asc",
},
mode: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.conf_mode"
),
sortable: true,
filterable: true,
template: (mode) =>
html`
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.conf_mode.${mode}`
) || mode}
`,
},
};
if (dashboards.some((dashboard) => dashboard.mode === "yaml")) {
columns.filename = {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.filename"
),
sortable: true,
filterable: true,
};
}
const columns2: DataTableColumnContainer = {
require_admin: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.require_admin"
),
sortable: true,
type: "icon",
template: (requireAdmin: boolean) =>
requireAdmin
? html`
<ha-icon icon="hass:check-circle-outline"></ha-icon>
`
: html`
-
`,
},
sidebar: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.sidebar"
),
type: "icon",
template: (sidebar) =>
sidebar
? html`
<ha-icon icon="hass:check-circle-outline"></ha-icon>
`
: html`
-
`,
},
url_path: {
title: "",
type: "icon",
filterable: true,
template: (urlPath) =>
html`
<mwc-button .urlPath=${urlPath} @click=${this._navigate}
>${this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.open"
)}</mwc-button
>
`,
},
};
return { ...columns, ...columns2 };
}
);
private _getItems = memoize((dashboards: LovelaceDashboard[]) => {
return dashboards.map((dashboard) => {
return {
filename: "",
...dashboard,
icon: dashboard.sidebar?.icon,
title: dashboard.sidebar?.title || dashboard.url_path,
};
});
});
protected render(): TemplateResult {
if (!this.hass || this._dashboards === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${lovelaceTabs}
.columns=${this._columns(this.hass.language, this._dashboards)}
.data=${this._getItems(this._dashboards)}
@row-click=${this._editDashboard}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.add_dashboard"
)}"
@click=${this._addDashboard}
></ha-fab>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getDashboards();
}
private async _getDashboards() {
this._dashboards = await fetchDashboards(this.hass);
}
private _navigate(ev: Event) {
ev.stopPropagation();
const url = `/${(ev.target as any).urlPath}`;
navigate(this, url);
}
private _editDashboard(ev: CustomEvent) {
const id = (ev.detail as RowClickedEvent).id;
const dashboard = id
? this._dashboards.find((res) => res.id === id)
: undefined;
if (!dashboard) {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
),
});
return;
}
this._openDialog(dashboard);
}
private _addDashboard() {
this._openDialog();
}
private async _openDialog(dashboard?: LovelaceDashboard): Promise<void> {
showDashboardDetailDialog(this, {
dashboard,
createDashboard: async (values: LovelaceDashboardCreateParams) => {
const created = await createDashboard(this.hass!, values);
this._dashboards = this._dashboards!.concat(
created
).sort((res1, res2) => compare(res1.url_path, res2.url_path));
},
updateDashboard: async (values) => {
const updated = await updateDashboard(
this.hass!,
dashboard!.id,
values
);
this._dashboards = this._dashboards!.map((res) =>
res === dashboard ? updated : res
);
},
removeDashboard: async () => {
if (
!(await showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.confirm_delete"
),
}))
) {
return false;
}
try {
await deleteDashboard(this.hass!, dashboard!.id);
this._dashboards = this._dashboards!.filter(
(res) => res !== dashboard
);
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
`;
}
}

View File

@ -0,0 +1,31 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import {
LovelaceDashboard,
LovelaceDashboardMutableParams,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
export interface LovelaceDashboardDetailsDialogParams {
dashboard?: LovelaceDashboard;
createDashboard: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
) => Promise<unknown>;
removeDashboard: () => Promise<boolean>;
}
export const loadDashboardDetailDialog = () =>
import(
/* webpackChunkName: "lovelace-dashboard-detail-dialog" */ "./dialog-lovelace-dashboard-detail"
);
export const showDashboardDetailDialog = (
element: HTMLElement,
dialogParams: LovelaceDashboardDetailsDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-lovelace-dashboard-detail",
dialogImport: loadDashboardDetailDialog,
dialogParams,
});
};

View File

@ -0,0 +1,63 @@
import {
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { property, customElement } from "lit-element";
import { HomeAssistant } from "../../../types";
export const lovelaceTabs = [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.dashboards.caption",
icon: "hass:view-dashboard",
},
{
component: "lovelace",
path: "/config/lovelace/resources",
translationKey: "ui.panel.config.lovelace.resources.caption",
icon: "hass:file-multiple",
advancedOnly: true,
},
];
@customElement("ha-config-lovelace")
class HaConfigLovelace extends HassRouterPage {
@property() public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property() public isWide!: boolean;
protected routerOptions: RouterOptions = {
defaultPage: "dashboards",
routes: {
dashboards: {
tag: "ha-config-lovelace-dashboards",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace-dashboards" */ "./dashboards/ha-config-lovelace-dashboards"
),
cache: true,
},
resources: {
tag: "ha-config-lovelace-resources",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace-resources" */ "./resources/ha-config-lovelace-resources"
),
},
},
};
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-lovelace": HaConfigLovelace;
}
}

View File

@ -0,0 +1,228 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { HomeAssistant } from "../../../../types";
import {
LovelaceResource,
LovelaceResourcesMutableParams,
} from "../../../../data/lovelace";
import { LovelaceResourceDetailsDialogParams } from "./show-dialog-lovelace-resource-detail";
import { PolymerChangedEvent } from "../../../../polymer-types";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-lovelace-resource-detail")
export class DialogLovelaceResourceDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _params?: LovelaceResourceDetailsDialogParams;
@property() private _url!: LovelaceResource["url"];
@property() private _type!: LovelaceResource["type"];
@property() private _error?: string;
@property() private _submitting = false;
public async showDialog(
params: LovelaceResourceDetailsDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
if (this._params.resource) {
this._url = this._params.resource.url || "";
this._type = this._params.resource.type || "module";
} else {
this._url = "";
this._type = "module";
}
await this.updateComplete;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
const urlInvalid = this._url.trim() === "";
return html`
<ha-dialog
open
@closing=${this._close}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.resource
? this._params.resource.url
: this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.new_resource"
)
)}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<h3 class="warning">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.warning_header"
)}
</h3>
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.warning_text"
)}
<paper-input
.value=${this._url}
@value-changed=${this._urlChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.url"
)}
.errorMessage=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.url_error_msg"
)}
.invalid=${urlInvalid}
></paper-input>
<br />
<ha-paper-dropdown-menu
.label=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.type"
)}
>
<paper-listbox
slot="dropdown-content"
.selected=${this._type}
@iron-select=${this._typeChanged}
attr-for-selected="type"
>
<paper-item type="module">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.module"
)}
</paper-item>
${this._type === "js"
? html`
<paper-item type="js">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.js"
)}
</paper-item>
`
: ""}
<paper-item type="css">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.css"
)}
</paper-item>
${this._type === "html"
? html`
<paper-item type="html">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.html"
)}
</paper-item>
`
: ""}
</paper-listbox>
</ha-paper-dropdown-menu>
</div>
</div>
${this._params.resource
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteResource}"
.disabled=${this._submitting}
>
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.delete"
)}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateResource}"
.disabled=${urlInvalid || this._submitting}
>
${this._params.resource
? this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.update"
)
: this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.create"
)}
</mwc-button>
</ha-dialog>
`;
}
private _urlChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._url = ev.detail.value;
}
private _typeChanged(ev: CustomEvent) {
this._type = ev.detail.item.getAttribute("type");
}
private async _updateResource() {
this._submitting = true;
try {
const values: LovelaceResourcesMutableParams = {
url: this._url.trim(),
res_type: this._type,
};
if (this._params!.resource) {
await this._params!.updateResource(values);
} else {
await this._params!.createResource(values);
}
this._params = undefined;
} catch (err) {
this._error = err?.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteResource() {
this._submitting = true;
try {
if (await this._params!.removeResource()) {
this._close();
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.form {
padding-bottom: 24px;
}
.warning {
color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lovelace-resource-detail": DialogLovelaceResourceDetail;
}
}

View File

@ -0,0 +1,209 @@
import "@polymer/paper-checkbox/paper-checkbox";
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-tooltip/paper-tooltip";
import {
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import memoize from "memoize-one";
import "../../../../common/search/search-input";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-icon";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../../types";
import {
LovelaceResource,
fetchResources,
createResource,
updateResource,
deleteResource,
} from "../../../../data/lovelace";
import { showResourceDetailDialog } from "./show-dialog-lovelace-resource-detail";
import { compare } from "../../../../common/string/compare";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { lovelaceTabs } from "../ha-config-lovelace";
import { loadLovelaceResources } from "../../../lovelace/common/load-resources";
@customElement("ha-config-lovelace-resources")
export class HaConfigLovelaceRescources extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _resources: LovelaceResource[] = [];
private _columns = memoize(
(_language): DataTableColumnContainer => {
return {
url: {
title: this.hass.localize(
"ui.panel.config.lovelace.resources.picker.headers.url"
),
sortable: true,
filterable: true,
direction: "asc",
},
type: {
title: this.hass.localize(
"ui.panel.config.lovelace.resources.picker.headers.type"
),
sortable: true,
filterable: true,
template: (type) =>
html`
${this.hass.localize(
`ui.panel.config.lovelace.resources.types.${type}`
) || type}
`,
},
};
}
);
protected render(): TemplateResult {
if (!this.hass || this._resources === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${lovelaceTabs}
.columns=${this._columns(this.hass.language)}
.data=${this._resources}
@row-click=${this._editResource}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.lovelace.resources.picker.add_resource"
)}"
@click=${this._addResource}
></ha-fab>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getResources();
}
private async _getResources() {
this._resources = await fetchResources(this.hass.connection);
}
private _editResource(ev: CustomEvent) {
if ((this.hass.panels.lovelace?.config as any)?.mode !== "storage") {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.cant_edit_yaml"
),
});
return;
}
const id = (ev.detail as RowClickedEvent).id;
const resource = this._resources.find((res) => res.id === id);
this._openDialog(resource);
}
private _addResource() {
if ((this.hass.panels.lovelace?.config as any)?.mode !== "storage") {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.cant_edit_yaml"
),
});
return;
}
this._openDialog();
}
private async _openDialog(resource?: LovelaceResource): Promise<void> {
showResourceDetailDialog(this, {
resource,
createResource: async (values) => {
const created = await createResource(this.hass!, values);
this._resources = this._resources!.concat(created).sort((res1, res2) =>
compare(res1.url, res2.url)
);
loadLovelaceResources(this._resources, this.hass!.auth.data.hassUrl);
},
updateResource: async (values) => {
const updated = await updateResource(this.hass!, resource!.id, values);
this._resources = this._resources!.map((res) =>
res === resource ? updated : res
);
loadLovelaceResources(this._resources, this.hass!.auth.data.hassUrl);
},
removeResource: async () => {
if (
!(await showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.confirm_delete"
),
}))
) {
return false;
}
try {
await deleteResource(this.hass!, resource!.id);
this._resources = this._resources!.filter((res) => res !== resource);
showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.lovelace.resources.refresh_header"
),
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.refresh_body"
),
confirm: () => location.reload(),
});
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
`;
}
}

View File

@ -0,0 +1,30 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import {
LovelaceResource,
LovelaceResourcesMutableParams,
} from "../../../../data/lovelace";
export interface LovelaceResourceDetailsDialogParams {
resource?: LovelaceResource;
createResource: (values: LovelaceResourcesMutableParams) => Promise<unknown>;
updateResource: (
updates: Partial<LovelaceResourcesMutableParams>
) => Promise<unknown>;
removeResource: () => Promise<boolean>;
}
export const loadResourceDetailDialog = () =>
import(
/* webpackChunkName: "lovelace-resource-detail-dialog" */ "./dialog-lovelace-resource-detail"
);
export const showResourceDetailDialog = (
element: HTMLElement,
dialogParams: LovelaceResourceDetailsDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-lovelace-resource-detail",
dialogImport: loadResourceDetailDialog,
dialogParams,
});
};

View File

@ -13,11 +13,12 @@ import "@material/mwc-button";
import "../../../components/entity/ha-entities-picker";
import "../../../components/user/ha-user-picker";
import "../../../components/ha-dialog";
import { PersonDetailDialogParams } from "./show-dialog-person-detail";
import { PolymerChangedEvent } from "../../../polymer-types";
import { HomeAssistant } from "../../../types";
import { PersonMutableParams } from "../../../data/person";
import { createCloseHeading } from "../../../components/ha-dialog";
import { haStyleDialog } from "../../../resources/styles";
class DialogPersonDetail extends LitElement {
@property() public hass!: HomeAssistant;
@ -55,26 +56,18 @@ class DialogPersonDetail extends LitElement {
return html``;
}
const nameInvalid = this._name.trim() === "";
const title = html`
${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")}
<paper-icon-button
aria-label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.dismiss"
)}
icon="hass:close"
dialogAction="close"
style="position: absolute; right: 16px; top: 12px;"
></paper-icon-button>
`;
return html`
<ha-dialog
open
@closing="${this._close}"
scrimClickAction=""
escapeKeyAction=""
.heading=${title}
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")
)}
>
<div>
${this._error
@ -236,34 +229,14 @@ class DialogPersonDetail extends LitElement {
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: 400px;
--mdc-dialog-max-width: 600px;
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
}
}
.form {
padding-bottom: 24px;
}
ha-user-picker {
margin-top: 16px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
a {
color: var(--primary-color);
}

View File

@ -84,7 +84,7 @@ class ZHAAddDevicesPage extends LitElement {
${this._showHelp
? html`
<ha-service-description
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="permit"
class="help-text"

View File

@ -165,7 +165,7 @@ export class ZHAClusterAttributes extends LitElement {
`
: ""}
<ha-call-service-button
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="set_zigbee_cluster_attribute"
.serviceData="${this._setAttributeServiceData}"
@ -177,7 +177,7 @@ export class ZHAClusterAttributes extends LitElement {
${this.showHelp
? html`
<ha-service-description
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="set_zigbee_cluster_attribute"
class="help-text2"

View File

@ -127,7 +127,7 @@ export class ZHAClusterCommands extends LitElement {
</div>
<div class="card-actions">
<ha-call-service-button
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="issue_zigbee_cluster_command"
.serviceData="${this._issueClusterCommandServiceData}"
@ -139,7 +139,7 @@ export class ZHAClusterCommands extends LitElement {
${this._showHelp
? html`
<ha-service-description
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="issue_zigbee_cluster_command"
class="help-text2"

View File

@ -299,7 +299,7 @@ class ZHADeviceCard extends LitElement {
: ""}
<ha-call-service-button
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="remove"
.confirmation=${this.hass!.localize(
@ -331,7 +331,7 @@ class ZHADeviceCard extends LitElement {
${this.showHelp
? html`
<ha-service-description
.hass="${this.hass}"
.hass=${this.hass}
domain="zha"
service="permit"
class="help-text2"

View File

@ -59,14 +59,14 @@ export class ZHADevicePage extends LitElement {
>
<zha-node
.isWide="${this.isWide}"
.hass="${this.hass}"
.hass=${this.hass}
.device=${this.device}
></zha-node>
${this.device && this.device.device_type !== "Coordinator"
? html`
<zha-clusters
.hass="${this.hass}"
.hass=${this.hass}
.isWide="${this.isWide}"
.selectedDevice="${this.device}"
@zha-cluster-selected="${this._onClusterSelected}"
@ -75,14 +75,14 @@ export class ZHADevicePage extends LitElement {
? html`
<zha-cluster-attributes
.isWide="${this.isWide}"
.hass="${this.hass}"
.hass=${this.hass}
.selectedNode="${this.device}"
.selectedCluster="${this._selectedCluster}"
></zha-cluster-attributes>
<zha-cluster-commands
.isWide="${this.isWide}"
.hass="${this.hass}"
.hass=${this.hass}
.selectedNode="${this.device}"
.selectedCluster="${this._selectedCluster}"
></zha-cluster-commands>
@ -92,7 +92,7 @@ export class ZHADevicePage extends LitElement {
? html`
<zha-device-binding-control
.isWide="${this.isWide}"
.hass="${this.hass}"
.hass=${this.hass}
.selectedDevice="${this.device}"
.bindableDevices="${this._bindableDevices}"
></zha-device-binding-control>
@ -103,7 +103,7 @@ export class ZHADevicePage extends LitElement {
<zha-group-binding-control
.isWide="${this.isWide}"
.narrow="${this.narrow}"
.hass="${this.hass}"
.hass=${this.hass}
.selectedDevice="${this.device}"
.groups="${this._groups}"
></zha-group-binding-control>

View File

@ -12,7 +12,6 @@ import "@material/mwc-button";
import "../../../components/map/ha-location-editor";
import "../../../components/ha-switch";
import "../../../components/ha-dialog";
import { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
import { HomeAssistant } from "../../../types";
@ -23,6 +22,8 @@ import {
getZoneEditorInitData,
} from "../../../data/zone";
import { addDistanceToCoord } from "../../../common/location/add_distance_to_coord";
import { createCloseHeading } from "../../../components/ha-dialog";
import { haStyleDialog } from "../../../resources/styles";
class DialogZoneDetail extends LitElement {
@property() public hass!: HomeAssistant;
@ -72,19 +73,6 @@ class DialogZoneDetail extends LitElement {
if (!this._params) {
return html``;
}
const title = html`
${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
<paper-icon-button
aria-label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.dismiss"
)}
icon="hass:close"
dialogAction="close"
style="position: absolute; right: 16px; top: 12px;"
></paper-icon-button>
`;
const nameValid = this._name.trim() === "";
const iconValid = !this._icon.trim().includes(":");
const latValid = String(this._latitude) === "";
@ -100,7 +88,12 @@ class DialogZoneDetail extends LitElement {
@closing="${this._close}"
scrimClickAction=""
escapeKeyAction=""
.heading=${title}
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")
)}
>
<div>
${this._error
@ -277,26 +270,8 @@ class DialogZoneDetail extends LitElement {
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
@media only screen and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 600px;
}
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
}
}
.form {
padding-bottom: 24px;
color: var(--primary-text-color);
@ -320,12 +295,6 @@ class DialogZoneDetail extends LitElement {
ha-user-picker {
margin-top: 16px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
a {
color: var(--primary-color);
}

View File

@ -41,7 +41,10 @@ class HaPanelHistory extends LocalizeMixin(PolymerElement) {
margin-right: 16px;
margin-top: 5px;
--paper-input-container-label-floating: {
padding-bottom: 10px;
padding-bottom: 11px;
}
--paper-input-suffix: {
height: 24px;
}
}

View File

@ -15,7 +15,7 @@ import {
} from "lit-element";
import { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import "lit-virtualizer";
import { scroll } from "lit-virtualizer";
import { LogbookEntry } from "../../data/logbook";
class HaLogbook extends LitElement {
@ -44,19 +44,23 @@ class HaLogbook extends LitElement {
}
return html`
<lit-virtualizer
.items=${this.entries}
.renderItem=${(item: LogbookEntry, index: number) =>
this._renderLogbookItem(item, index)}
style="height: 100%;"
></lit-virtualizer>
<div>
${scroll({
items: this.entries,
renderItem: (item: LogbookEntry, index?: number) =>
this._renderLogbookItem(item, index),
})}
</div>
`;
}
private _renderLogbookItem(
item: LogbookEntry,
index: number
index?: number
): TemplateResult {
if (!index) {
return html``;
}
const previous = this.entries[index - 1];
const state = item.entity_id ? this.hass.states[item.entity_id] : undefined;
return html`
@ -149,6 +153,19 @@ class HaLogbook extends LitElement {
a {
color: var(--primary-color);
}
.uni-virtualizer-host {
display: block;
position: relative;
contain: strict;
height: 100%;
overflow: auto;
padding: 0 16px;
}
.uni-virtualizer-host > * {
box-sizing: border-box;
}
`;
}
}

View File

@ -27,10 +27,6 @@ class HaPanelLogbook extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="ha-style">
.content {
padding: 0 16px 0 16px;
}
ha-logbook {
height: calc(100vh - 136px);
}
@ -52,7 +48,8 @@ class HaPanelLogbook extends LocalizeMixin(PolymerElement) {
.filters {
display: flex;
align-items: center;
align-items: flex-end;
padding: 0 16px;
}
:host([narrow]) .filters {
@ -73,7 +70,10 @@ class HaPanelLogbook extends LocalizeMixin(PolymerElement) {
max-width: 100px;
margin-right: 16px;
--paper-input-container-label-floating: {
padding-bottom: 10px;
padding-bottom: 11px;
}
--paper-input-suffix: {
height: 24px;
}
}
@ -92,6 +92,9 @@ class HaPanelLogbook extends LocalizeMixin(PolymerElement) {
display: inline-block;
flex-grow: 1;
max-width: 400px;
--paper-input-suffix: {
height: 24px;
}
}
:host([narrow]) ha-entity-picker {
@ -129,58 +132,53 @@ class HaPanelLogbook extends LocalizeMixin(PolymerElement) {
</app-toolbar>
</app-header>
<div class="content">
<paper-spinner
active="[[isLoading]]"
hidden$="[[!isLoading]]"
alt="[[localize('ui.common.loading')]]"
></paper-spinner>
<paper-spinner
active="[[isLoading]]"
hidden$="[[!isLoading]]"
alt="[[localize('ui.common.loading')]]"
></paper-spinner>
<div class="filters">
<vaadin-date-picker
id="picker"
value="{{_currentDate}}"
label="[[localize('ui.panel.logbook.showing_entries')]]"
disabled="[[isLoading]]"
required
></vaadin-date-picker>
<div class="filters">
<vaadin-date-picker
id="picker"
value="{{_currentDate}}"
label="[[localize('ui.panel.logbook.showing_entries')]]"
disabled="[[isLoading]]"
required
></vaadin-date-picker>
<paper-dropdown-menu
label-float
label="[[localize('ui.panel.logbook.period')]]"
disabled="[[isLoading]]"
>
<paper-listbox
slot="dropdown-content"
selected="{{_periodIndex}}"
<paper-dropdown-menu
label-float
label="[[localize('ui.panel.logbook.period')]]"
disabled="[[isLoading]]"
>
<paper-listbox slot="dropdown-content" selected="{{_periodIndex}}">
<paper-item
>[[localize('ui.duration.day', 'count', 1)]]</paper-item
>
<paper-item
>[[localize('ui.duration.day', 'count', 1)]]</paper-item
>
<paper-item
>[[localize('ui.duration.day', 'count', 3)]]</paper-item
>
<paper-item
>[[localize('ui.duration.week', 'count', 1)]]</paper-item
>
</paper-listbox>
</paper-dropdown-menu>
<paper-item
>[[localize('ui.duration.day', 'count', 3)]]</paper-item
>
<paper-item
>[[localize('ui.duration.week', 'count', 1)]]</paper-item
>
</paper-listbox>
</paper-dropdown-menu>
<ha-entity-picker
hass="[[hass]]"
value="{{_entityId}}"
label="[[localize('ui.components.entity.entity-picker.entity')]]"
disabled="[[isLoading]]"
on-change="_entityPicked"
></ha-entity-picker>
</div>
<ha-logbook
<ha-entity-picker
hass="[[hass]]"
entries="[[entries]]"
hidden$="[[isLoading]]"
></ha-logbook>
value="{{_entityId}}"
label="[[localize('ui.components.entity.entity-picker.entity')]]"
disabled="[[isLoading]]"
on-change="_entityPicked"
></ha-entity-picker>
</div>
<ha-logbook
hass="[[hass]]"
entries="[[entries]]"
hidden$="[[isLoading]]"
></ha-logbook>
</app-header-layout>
`;
}

View File

@ -149,6 +149,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
${this._config.show_icon
? html`
<ha-icon
tabindex="-1"
data-domain=${ifDefined(
this._config.state_color && stateObj
? computeStateDomain(stateObj)
@ -171,7 +172,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
: ""}
${this._config.show_name
? html`
<span>
<span tabindex="-1">
${this._config.name ||
(stateObj ? computeStateName(stateObj) : "")}
</span>
@ -225,6 +226,11 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
color: var(--paper-item-icon-color, #44739e);
}
ha-icon,
span {
outline: none;
}
${iconColorCSS}
`;
}

View File

@ -25,6 +25,8 @@ import { EntitiesCardConfig, EntitiesCardEntityConfig } from "./types";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
@ -44,6 +46,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
private _hass?: HomeAssistant;
private _configEntities?: EntitiesCardEntityConfig[];
private _showHeaderToggle?: boolean;
set hass(hass: HomeAssistant) {
this._hass = hass;
@ -78,6 +81,22 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
this._config = { theme: "default", ...config };
this._configEntities = entities;
if (config.show_header_toggle === undefined) {
// Default value is show toggle if we can at least toggle 2 entities.
let toggleable = 0;
for (const rowConf of entities) {
if (!rowConf.entity) {
continue;
}
toggleable += Number(DOMAINS_TOGGLE.has(computeDomain(rowConf.entity)));
if (toggleable === 2) {
break;
}
}
this._showHeaderToggle = toggleable === 2;
} else {
this._showHeaderToggle = config.show_header_toggle;
}
}
protected updated(changedProps: PropertyValues): void {
@ -110,9 +129,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
${this._config.header
? this.renderHeaderFooter(this._config.header, "header")
: ""}
${!this._config.title &&
!this._config.show_header_toggle &&
!this._config.icon
${!this._config.title && !this._showHeaderToggle && !this._config.icon
? ""
: html`
<div class="card-header">
@ -127,7 +144,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
: ""}
${this._config.title}
</div>
${this._config.show_header_toggle === false
${!this._showHeaderToggle
? html``
: html`
<hui-entities-toggle
@ -173,6 +190,10 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
overflow: hidden;
}
#states > div {
position: relative;
}
.icon {
padding: 0px 18px 0px 8px;
}

View File

@ -1,4 +1,4 @@
import { html, TemplateResult } from "lit-element";
import { CSSResult, css } from "lit-element";
import { computeCardSize } from "../common/compute-card-size";
import { HuiStackCard } from "./hui-stack-card";
@ -17,9 +17,10 @@ class HuiHorizontalStackCard extends HuiStackCard {
return totalSize;
}
protected renderStyle(): TemplateResult {
return html`
<style>
static get styles(): CSSResult[] {
return [
super.sharedStyles,
css`
#root {
display: flex;
}
@ -34,8 +35,8 @@ class HuiHorizontalStackCard extends HuiStackCard {
#root > *:last-child {
margin-right: 0;
}
</style>
`;
`,
];
}
}

View File

@ -29,6 +29,7 @@ import { toggleEntity } from "../common/entity/toggle-entity";
import { LightCardConfig } from "./types";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { SUPPORT_BRIGHTNESS } from "../../../data/light";
import { UNAVAILABLE } from "../../../data/entity";
@customElement("hui-light-card")
export class HuiLightCard extends LitElement implements LovelaceCard {
@ -84,10 +85,11 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
return html`
<ha-card>
${stateObj.state === "unavailable"
${stateObj.state === UNAVAILABLE
? html`
<hui-unavailable
.text="${this.hass.localize("state.default.unavailable")}"
.text=${this.hass.localize("state.default.unavailable")}
@click=${this._handleMoreInfo}
></hui-unavailable>
`
: ""}
@ -233,6 +235,10 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
display: block;
}
hui-unavailable {
cursor: pointer;
}
ha-card {
position: relative;
overflow: hidden;

View File

@ -1,18 +1,34 @@
import { html, LitElement, TemplateResult, CSSResult, css } from "lit-element";
import {
html,
LitElement,
TemplateResult,
CSSResult,
css,
property,
} from "lit-element";
import { createCardElement } from "../create-element/create-card-element";
import { LovelaceCard } from "../types";
import { LovelaceCard, LovelaceCardEditor } from "../types";
import { LovelaceCardConfig } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
import { StackCardConfig } from "./types";
export abstract class HuiStackCard extends LitElement implements LovelaceCard {
static get properties() {
return {
_config: {},
};
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import(
/* webpackChunkName: "hui-stack-card-editor" */ "../editor/config-elements/hui-stack-card-editor"
);
return document.createElement("hui-stack-card-editor");
}
public static getStubConfig(): object {
return { cards: [] };
}
@property() protected _cards?: LovelaceCard[];
@property() private _config?: StackCardConfig;
private _hass?: HomeAssistant;
set hass(hass: HomeAssistant) {
this._hass = hass;
@ -24,9 +40,6 @@ export abstract class HuiStackCard extends LitElement implements LovelaceCard {
element.hass = this._hass;
}
}
protected _cards?: LovelaceCard[];
private _config?: StackCardConfig;
private _hass?: HomeAssistant;
public abstract getCardSize(): number;
@ -42,12 +55,11 @@ export abstract class HuiStackCard extends LitElement implements LovelaceCard {
}
protected render(): TemplateResult {
if (!this._config) {
if (!this._config || !this._cards) {
return html``;
}
return html`
${this.renderStyle()}
${this._config.title
? html`
<div class="card-header">${this._config.title}</div>
@ -57,9 +69,7 @@ export abstract class HuiStackCard extends LitElement implements LovelaceCard {
`;
}
protected abstract renderStyle(): TemplateResult;
static get styles(): CSSResult {
static get sharedStyles(): CSSResult {
return css`
.card-header {
color: var(--ha-card-header-color, --primary-text-color);

View File

@ -34,6 +34,7 @@ import {
} from "../../../data/climate";
import { HassEntity } from "home-assistant-js-websocket";
import { actionHandler } from "../common/directives/action-handler-directive";
import { UNAVAILABLE } from "../../../data/entity";
const modeIcons: { [mode in HvacMode]: string } = {
auto: "hass:calendar-repeat",
@ -208,10 +209,11 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
[mode]: true,
})}
>
${stateObj.state === "unavailable"
${stateObj.state === UNAVAILABLE
? html`
<hui-unavailable
.text="${this.hass.localize("state.default.unavailable")}"
@click=${this._handleMoreInfo}
></hui-unavailable>
`
: ""}
@ -396,6 +398,10 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
display: block;
}
hui-unavailable {
cursor: pointer;
}
ha-card {
position: relative;
overflow: hidden;

View File

@ -1,4 +1,4 @@
import { html, TemplateResult } from "lit-element";
import { CSSResult, css } from "lit-element";
import { computeCardSize } from "../common/compute-card-size";
import { HuiStackCard } from "./hui-stack-card";
@ -18,9 +18,10 @@ class HuiVerticalStackCard extends HuiStackCard {
return totalSize;
}
protected renderStyle(): TemplateResult {
return html`
<style>
static get styles(): CSSResult[] {
return [
super.sharedStyles,
css`
#root {
display: flex;
flex-direction: column;
@ -34,8 +35,8 @@ class HuiVerticalStackCard extends HuiStackCard {
#root > *:last-child {
margin-bottom: 0;
}
</style>
`;
`,
];
}
}

View File

@ -243,6 +243,7 @@ export interface ShoppingListCardConfig extends LovelaceCardConfig {
export interface StackCardConfig extends LovelaceCardConfig {
cards: LovelaceCardConfig[];
title?: string;
}
export interface ThermostatCardConfig extends LovelaceCardConfig {

View File

@ -273,7 +273,6 @@ export const generateDefaultViewConfig = (
areaEntities.map((entity) => [entity.entity_id, entity]),
{
title: area.name,
show_header_toggle: true,
}
)
);

View File

@ -1,13 +1,13 @@
import { loadModule, loadCSS, loadJS } from "../../../common/dom/load_resource";
import { LovelaceConfig } from "../../../data/lovelace";
import { LovelaceResource } from "../../../data/lovelace";
// CSS and JS should only be imported once. Modules and HTML are safe.
const CSS_CACHE = {};
const JS_CACHE = {};
export const loadLovelaceResources = (
resources: NonNullable<LovelaceConfig["resources"]>,
resources: NonNullable<LovelaceResource[]>,
hassUrl: string
) =>
resources.forEach((resource) => {

View File

@ -106,7 +106,7 @@ export class HuiActionEditor extends LitElement {
${this.config && this.config.action === "call-service"
? html`
<ha-service-picker
.hass="${this.hass}"
.hass=${this.hass}
.value="${this._service}"
.configValue="${"service"}"
@value-changed="${this._valueChanged}"

View File

@ -93,6 +93,10 @@ export class HuiCardOptions extends LitElement {
static get styles(): CSSResult {
return css`
:host(:hover) {
outline: 2px solid var(--primary-color);
}
ha-card {
border-top-right-radius: 0;
border-top-left-radius: 0;

View File

@ -46,7 +46,7 @@ export class HuiEntityEditor extends LitElement {
return html`
<div class="entity">
<ha-entity-picker
.hass="${this.hass}"
.hass=${this.hass}
.value="${entityConf.entity}"
.index="${index}"
@change="${this._valueChanged}"
@ -70,7 +70,7 @@ export class HuiEntityEditor extends LitElement {
`;
})}
<ha-entity-picker
.hass="${this.hass}"
.hass=${this.hass}
@change="${this._addEntity}"
></ha-entity-picker>
</div>

View File

@ -32,7 +32,7 @@ class HuiGenericEntityRow extends LitElement {
@property() public config?: EntitiesCardEntityConfig;
@property() public showSecondary: boolean = true;
@property() public secondaryText?: string;
protected render(): TemplateResult {
if (!this.hass || !this.config) {
@ -59,6 +59,8 @@ class HuiGenericEntityRow extends LitElement {
(this.config.entity &&
!DOMAINS_HIDE_MORE_INFO.includes(computeDomain(this.config.entity)));
const hasSecondary = this.secondaryText || this.config.secondary_info;
return html`
<state-badge
class=${classMap({
@ -76,59 +78,59 @@ class HuiGenericEntityRow extends LitElement {
})}
tabindex=${ifDefined(pointer ? "0" : undefined)}
></state-badge>
<div class="flex">
<div
class=${classMap({
info: true,
pointer,
padName: this.showSecondary && !this.config.secondary_info,
padSecondary: Boolean(
!this.showSecondary || this.config.secondary_info
),
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this.config!.hold_action),
hasDoubleClick: hasAction(this.config!.double_tap_action),
})}
>
${this.config.name || computeStateName(stateObj)}
<div class="secondary">
${!this.showSecondary
? html`
<slot name="secondary"></slot>
`
: this.config.secondary_info === "entity-id"
? stateObj.entity_id
: this.config.secondary_info === "last-changed"
? html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.last_changed}
></ha-relative-time>
`
: this.config.secondary_info === "last-triggered"
? stateObj.attributes.last_triggered
? html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.attributes.last_triggered}
></ha-relative-time>
`
: this.hass.localize(
"ui.panel.lovelace.cards.entities.never_triggered"
)
: ""}
</div>
</div>
<slot></slot>
<div
class="info ${classMap({
pointer,
"text-content": !hasSecondary,
})}"
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this.config!.hold_action),
hasDoubleClick: hasAction(this.config!.double_tap_action),
})}
>
${this.config.name || computeStateName(stateObj)}
${hasSecondary
? html`
<div class="secondary">
${this.secondaryText ||
this.config.secondary_info === "entity-id"
? stateObj.entity_id
: this.config.secondary_info === "last-changed"
? html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.last_changed}
></ha-relative-time>
`
: this.config.secondary_info === "last-triggered"
? stateObj.attributes.last_triggered
? html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.attributes.last_triggered}
></ha-relative-time>
`
: this.hass.localize(
"ui.panel.lovelace.cards.entities.never_triggered"
)
: ""}
}
</div>
`
: ""}
</div>
<slot></slot>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
toggleAttribute(
this,
"no-secondary",
!this.secondaryText && !this.config?.secondary_info
);
if (changedProps.has("hass")) {
toggleAttribute(this, "rtl", computeRTL(this.hass!));
}
@ -143,16 +145,10 @@ class HuiGenericEntityRow extends LitElement {
:host {
display: flex;
align-items: center;
}
.flex {
flex: 1;
margin-left: 16px;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
flex-direction: row;
}
.info {
margin-left: 16px;
flex: 1 0 60px;
}
.info,
@ -161,6 +157,11 @@ class HuiGenericEntityRow extends LitElement {
overflow: hidden;
text-overflow: ellipsis;
}
:host([no-secondary]) .text-content,
:host([no-secondary]) ::slotted(.text-content) {
position: relative;
top: 2px;
}
.flex ::slotted(*) {
margin-left: 8px;
min-width: 0;
@ -192,12 +193,6 @@ class HuiGenericEntityRow extends LitElement {
.pointer {
cursor: pointer;
}
.padName {
padding: 12px 0px;
}
.padSecondary {
padding: 4px 0px;
}
`;
}
}

View File

@ -133,7 +133,7 @@ export class HuiImage extends LitElement {
${this.cameraImage && this.cameraView === "live"
? html`
<ha-camera-stream
.hass="${this.hass}"
.hass=${this.hass}
.stateObj="${cameraObj}"
></ha-camera-stream>
`

View File

@ -22,7 +22,7 @@ export const addEntitiesToLovelaceView = async (
}
if (!lovelaceConfig) {
try {
lovelaceConfig = await fetchConfig(hass.connection, false);
lovelaceConfig = await fetchConfig(hass.connection, null, false);
} catch {
alert(
hass.localize(
@ -41,7 +41,7 @@ export const addEntitiesToLovelaceView = async (
if (!saveConfigFunc) {
saveConfigFunc = async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, newConfig);
await saveConfig(hass!, null, newConfig);
} catch {
alert(
hass.localize("ui.panel.config.devices.add_entities.saving_failed")

View File

@ -99,7 +99,7 @@ export class HuiDialogEditCard extends LitElement {
${this._cardConfig === undefined
? html`
<hui-card-picker
.hass="${this.hass}"
.hass=${this.hass}
@config-changed="${this._handleCardPicked}"
></hui-card-picker>
`
@ -107,14 +107,14 @@ export class HuiDialogEditCard extends LitElement {
<div class="content">
<div class="element-editor">
<hui-card-editor
.hass="${this.hass}"
.hass=${this.hass}
.value="${this._cardConfig}"
@config-changed="${this._handleConfigChanged}"
></hui-card-editor>
</div>
<div class="element-preview">
<hui-card-preview
.hass="${this.hass}"
.hass=${this.hass}
.config="${this._cardConfig}"
class=${this._error ? "blur" : ""}
></hui-card-preview>

Some files were not shown because too many files have changed in this diff Show More