Compare commits

...

5 Commits

Author SHA1 Message Date
Aidan Timson 8cd986ff79 Self review 2026-07-16 10:22:43 +01:00
Aidan Timson 9eff01d1b9 Move content rendering to dedicated function 2026-07-16 10:10:13 +01:00
Aidan Timson 9f29ab9d91 Fill tabs 2026-07-16 10:10:13 +01:00
Aidan Timson c0192b8cca Use same slot layout 2026-07-16 10:10:13 +01:00
Aidan Timson 2b67dc2c71 Setup 2026-07-16 10:09:00 +01:00
7 changed files with 377 additions and 192 deletions
+5 -2
View File
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type { LovelaceViewRawConfig } from "./view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: string;
background?: LovelaceDashboardBackgroundConfig;
views: LovelaceViewRawConfig[];
}
+4 -1
View File
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;
background?: LovelaceDashboardBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
@@ -1,29 +1,45 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -38,39 +54,59 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
@state() private _submitting = false;
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: suggestions?.icon,
title: suggestions?.title ?? "",
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
}
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
}
public closeDialog(): void {
public closeDialog() {
this._open = false;
}
private _dialogClosed(): void {
private _dialogClosed() {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -82,6 +118,18 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -96,41 +144,43 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
</div>
<ha-dialog-footer slot="footer">
${
@@ -185,6 +235,41 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -251,15 +336,16 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
)
: "";
private _valueChanged(ev: CustomEvent) {
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
this._error = undefined;
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
if (ev.detail.value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -268,13 +354,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
};
}
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
} else {
this._data = value;
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -286,36 +389,55 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
: baseSlug,
};
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private async _updateDashboard() {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
}
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
this._markDirtyStateClean();
this.closeDialog();
@@ -339,10 +461,25 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
if (await this._params.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -351,7 +488,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
static get styles(): CSSResultGroup {
return [haStyleDialog, css``];
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
}
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -17,6 +18,8 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
@@ -181,7 +181,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
<hui-view-background-editor
.hass=${this.hass}
.config=${this._config}
@view-config-changed=${this._viewConfigChanged}
@background-config-changed=${this._viewConfigChanged}
></hui-view-background-editor>
`;
break;
@@ -2,135 +2,140 @@ import memoizeOne from "memoize-one";
import { LitElement, css, html, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewBackgroundConfig,
} from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
background?: LovelaceDashboardBackgroundConfig;
}
@customElement("hui-view-background-editor")
export class HuiViewBackgroundEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config!: LovelaceViewConfig;
@property({ attribute: false }) public config?: BackgroundConfigTarget;
@state({ attribute: false }) private _resolvedImage?: string;
set config(config: LovelaceViewConfig) {
this._config = config;
}
private _localizeValueCallback = (key: string) =>
this.hass.localize(key as Parameters<LocalizeFunc>[0]);
private _schema = memoizeOne(
(localize: LocalizeFunc, showSettings: boolean) =>
[
{
name: "image",
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
"ui.panel.lovelace.editor.card.picture.content_id_helper"
),
},
private _schema(showSettings: boolean) {
return [
{
name: "image",
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: this.hass.localize(
"ui.panel.lovelace.editor.card.picture.content_id_helper"
),
},
},
...(showSettings
? ([
{
name: "settings",
flatten: true,
expanded: true,
type: "expandable" as const,
schema: [
{
name: "opacity",
selector: {
number: { min: 0, max: 100, mode: "slider", step: 10 },
},
...(showSettings
? ([
{
name: "settings",
flatten: true,
expanded: true,
type: "expandable" as const,
schema: [
{
name: "opacity",
selector: {
number: { min: 0, max: 100, mode: "slider", step: 10 },
},
},
{
name: "attachment",
selector: {
button_toggle: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.attachment",
options: ["scroll", "fixed"],
},
},
{
name: "attachment",
selector: {
button_toggle: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.attachment",
options: ["scroll", "fixed"],
},
},
{
name: "size",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.size",
options: ["auto", "cover", "contain"],
mode: "dropdown",
},
},
{
name: "size",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.size",
options: ["auto", "cover", "contain"],
mode: "dropdown",
},
},
{
name: "alignment",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.alignment",
options: [
"top left",
"top center",
"top right",
"center left",
"center",
"center right",
"bottom left",
"bottom center",
"bottom right",
],
mode: "dropdown",
},
},
{
name: "alignment",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.alignment",
options: [
"top left",
"top center",
"top right",
"center left",
"center",
"center right",
"bottom left",
"bottom center",
"bottom right",
],
mode: "dropdown",
},
},
{
name: "repeat",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.repeat",
options: ["repeat", "no-repeat"],
mode: "dropdown",
},
},
{
name: "repeat",
required: true,
selector: {
select: {
translation_key:
"ui.panel.lovelace.editor.edit_view.background.repeat",
options: ["repeat", "no-repeat"],
mode: "dropdown",
},
},
},
],
},
] as const)
: []),
] as const
);
},
],
},
] as const)
: []),
] as const;
}
protected updated(changedProps: PropertyValues) {
protected updated(changedProps: PropertyValues<this>) {
if (
this._config &&
this.config &&
this.hass &&
(changedProps.has("_config") ||
(changedProps.has("config") ||
(changedProps.has("hass") && !changedProps.get("hass")))
) {
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
this.style.setProperty(
"--picture-opacity",
`${(background.opacity ?? 100) / 100}`
@@ -156,7 +161,7 @@ export class HuiViewBackgroundEditor extends LitElement {
return nothing;
}
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
return html`
${
@@ -174,7 +179,7 @@ export class HuiViewBackgroundEditor extends LitElement {
<ha-form
.hass=${this.hass}
.data=${background}
.schema=${this._schema(this.hass.localize, true)}
.schema=${this._schema(true)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
.localizeValue=${this._localizeValueCallback}
@@ -183,7 +188,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _backgroundData = memoizeOne(
(backgroundConfig?: LovelaceViewConfig) => {
(backgroundConfig?: BackgroundConfigTarget) => {
let background = backgroundConfig?.background;
if (typeof background === "string") {
const backgroundUrl = background.match(
@@ -220,12 +225,15 @@ export class HuiViewBackgroundEditor extends LitElement {
}
);
private _valueChanged(ev: CustomEvent): void {
const config = {
...this._config,
background: ev.detail.value,
};
fireEvent(this, "view-config-changed", { config });
private _valueChanged(
ev: HASSDomEvent<{ value: LovelaceViewBackgroundConfig }>
) {
fireEvent(this, "background-config-changed", {
config: {
...(this.config || {}),
background: ev.detail.value,
},
});
}
private _computeLabelCallback = (
@@ -292,4 +300,10 @@ declare global {
interface HTMLElementTagNameMap {
"hui-view-background-editor": HuiViewBackgroundEditor;
}
interface HASSDomEvents {
"background-config-changed": {
config: BackgroundConfigTarget;
};
}
}
+2
View File
@@ -1087,6 +1087,8 @@ class HUIRoot extends LitElement {
showDashboardDetailDialog(this, {
dashboard,
urlPath,
lovelaceConfig: this.lovelace?.config,
saveConfig: this.lovelace?.saveConfig,
updateDashboard: async (values) => {
await updateDashboard(this.hass!, dashboard!.id, values);
},