Compare commits

...
9 changed files with 564 additions and 36 deletions

No files matched your search

@@ -0,0 +1,153 @@
import type { ContextType } from "@lit/context";
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import { consume } from "../../../../common/decorators/consume";
import { storage } from "../../../../common/decorators/storage";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import { internationalizationContext } from "../../../../data/context";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { generateDefaultSection } from "../../views/default-section";
import { addSection } from "../config-util";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
@customElement("hui-dialog-create-section")
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@storage({
key: "dashboardSectionClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceSectionRawConfig;
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
return html`
<ha-adaptive-dialog
open
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
>
<ha-list-base>
<ha-list-item-button @click=${this._createManual}>
<ha-svg-icon
slot="start"
.path=${mdiViewGridPlusOutline}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.manual")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.manual_description"
)}
</span>
</ha-list-item-button>
<ha-list-item-button @click=${this._pickStrategy}>
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.automatic")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.automatic_description"
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${
this._clipboard
? html`
<ha-list-item-button @click=${this._paste}>
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.paste")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.paste_description"
)}
</span>
</ha-list-item-button>
`
: nothing
}
</ha-list-base>
</ha-adaptive-dialog>
`;
}
private _createManual() {
const { lovelace, containerPath } = this.params!;
this.closeDialog();
lovelace.saveConfig(
addSection(
lovelace.config,
containerPath,
generateDefaultSection(this._i18n.localize, true)
)
);
}
private _pickStrategy() {
showSelectSectionStrategyDialog(this, this.params!);
this.closeDialog();
}
private async _paste() {
if (!this._clipboard) {
return;
}
const { lovelace, containerPath } = this.params!;
const section = deepClone(this._clipboard);
this.closeDialog();
try {
await lovelace.saveConfig(
addSection(lovelace.config, containerPath, section)
);
} catch (_err: unknown) {
lovelace.showToast({
message: this._i18n.localize(
"ui.panel.lovelace.editor.section.paste_error"
),
});
}
}
static styles = css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
ha-list-base {
--ha-list-padding: 0 0 var(--ha-space-3);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-create-section": HuiDialogCreateSection;
}
}
@@ -54,6 +54,7 @@ import {
deleteAtPath,
getAtPath,
getViewPath,
insertAtPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
@@ -138,10 +139,9 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
this._config =
params.newSection ??
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
this._currTab =
this._config && isStrategySection(this._config)
? "tab-strategy"
@@ -151,7 +151,12 @@ export class HuiDialogEditSection
params.lovelaceConfig,
viewPath
);
this._initDirtyTracking({ type: "deep" }, this._config);
if (params.newSection) {
this._initDirtyTracking({ type: "deep" }, {});
this._updateDirtyState(params.newSection);
} else {
this._initDirtyTracking({ type: "deep" }, this._config);
}
this._defaultPreviewMode =
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
}
@@ -173,6 +178,7 @@ export class HuiDialogEditSection
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
this._discardDirtyStateChanges();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -264,24 +270,33 @@ export class HuiDialogEditSection
@click=${this._enlarge}
>${heading}</span
>
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
${
this._params.newSection
? nothing
: html`
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
`
}
<div class="content">
<div class="element-editor ha-scrollbar">
${
@@ -674,11 +689,13 @@ export class HuiDialogEditSection
) {
return;
}
const newConfig = setAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
);
const newConfig = this._params.newSection
? insertAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
)
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
@@ -0,0 +1,304 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-button-arrow-prev";
import "../../../../components/ha-ripple";
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../../types";
import "../../sections/hui-section";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceSectionStrategy } from "../../strategies/types";
import { getAtPath } from "../lovelace-path";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showCreateSectionDialog } from "./show-create-section-dialog";
import { showEditSectionDialog } from "./show-edit-section-dialog";
interface BuiltInSectionStrategy {
type: "common-controls";
isAvailable?: (hass: HomeAssistant) => boolean;
}
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
{
type: "common-controls",
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
},
];
interface StrategyOption {
type: string;
name: string;
description: string;
}
@customElement("hui-dialog-select-section-strategy")
export class HuiDialogSelectSectionStrategy
extends LitElement
implements HassDialog<CreateSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CreateSectionDialogParams;
@state() private _open = false;
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
{};
public showDialog(params: CreateSectionDialogParams): void {
this._params = params;
this._open = true;
this._previews = {};
this._loadPreviews(params);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._previews = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _availableBuiltInTypes() {
return BUILT_IN_SECTION_STRATEGIES.filter(
(strategy) => strategy.isAvailable?.(this.hass) ?? true
).map((strategy) => strategy.type);
}
private _builtInOptions(): StrategyOption[] {
return this._availableBuiltInTypes().map((type) => ({
type,
name: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.name`
),
description: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.description`
),
}));
}
private _customOptions(): StrategyOption[] {
return getCustomStrategiesForType("section").map((strategy) => ({
type: CUSTOM_TYPE_PREFIX + strategy.type,
name: strategy.name || strategy.type,
description: strategy.description || "",
}));
}
private async _loadPreviews(params: CreateSectionDialogParams) {
const types = [
...this._availableBuiltInTypes(),
...getCustomStrategiesForType("section").map(
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
),
];
await Promise.all(
types.map(async (type) => {
try {
const strategy = (await getLovelaceStrategy(
"section",
type
)) as LovelaceSectionStrategy;
const stubConfig = await strategy.getStubConfig?.(this.hass);
if (!stubConfig || this._params !== params) {
return;
}
this._previews = {
...this._previews,
[type]: { strategy: stubConfig },
};
} catch (_err: unknown) {
// The tile is shown without a preview
}
})
);
}
protected render() {
if (!this._params) {
return nothing;
}
const customOptions = this._customOptions();
return html`
<ha-adaptive-dialog
.open=${this._open}
width="large"
@closed=${this._dialogClosed}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.header"
)}
>
<ha-icon-button-arrow-prev
slot="headerNavigationIcon"
@click=${this._back}
></ha-icon-button-arrow-prev>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.built_in"
)}
</h3>
`
: nothing
}
<div class="options">
${this._builtInOptions().map((option) => this._renderOption(option))}
</div>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.custom"
)}
</h3>
<div class="options">
${customOptions.map((option) => this._renderOption(option))}
</div>
`
: nothing
}
</ha-adaptive-dialog>
`;
}
private _renderOption(option: StrategyOption) {
const preview = this._previews[option.type];
return html`
<div
class="option"
role="button"
tabindex="0"
aria-label=${option.name}
data-type=${option.type}
@click=${this._strategyPicked}
@keydown=${this._handleKeyDown}
>
<div class="header">
<h4>${option.name}</h4>
<p>${option.description}</p>
</div>
${
preview
? html`
<div class="preview" inert>
<hui-section
.hass=${this.hass}
.config=${preview}
preview
></hui-section>
</div>
`
: nothing
}
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
(ev.currentTarget as HTMLElement).click();
}
}
private _back() {
showCreateSectionDialog(this, this._params!);
this.closeDialog();
}
private _strategyPicked(ev: Event) {
const type = (ev.currentTarget as HTMLElement).dataset.type!;
const { lovelace, containerPath } = this._params!;
const sectionsPath = [...containerPath, "sections"];
const sectionCount =
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
showEditSectionDialog(this, {
lovelace,
lovelaceConfig: lovelace.config,
saveConfig: (newConfig) => {
lovelace.saveConfig(newConfig);
},
path: [...sectionsPath, sectionCount],
newSection: this._previews[type] ?? { strategy: { type } },
});
this.closeDialog();
}
static styles = css`
h3 {
margin: 0 0 var(--ha-space-3) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.options + h3 {
margin-top: var(--ha-space-6);
}
.options {
display: grid;
gap: var(--ha-space-4);
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.option {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
cursor: pointer;
outline: none;
}
.option:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.header {
flex: 1;
padding: var(--ha-space-3) var(--ha-space-4);
}
h4 {
margin: 0 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
p {
margin: 0;
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.preview {
height: 200px;
overflow: hidden;
padding: var(--ha-space-3);
background: var(--primary-background-color);
pointer-events: none;
mask-image: linear-gradient(to bottom, black 75%, transparent);
}
hui-section {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
}
}
@@ -0,0 +1,30 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateSectionDialogParams {
lovelace: Lovelace;
containerPath: LovelacePath;
}
export const showCreateSectionDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-create-section",
dialogImport: () => import("./hui-dialog-create-section"),
dialogParams: params,
});
};
export const showSelectSectionStrategyDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-section-strategy",
dialogImport: () => import("./hui-dialog-select-section-strategy"),
dialogParams: params,
});
};
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
@@ -8,6 +9,7 @@ export interface EditSectionDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
newSection?: LovelaceSectionRawConfig;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
+5 -1
View File
@@ -35,7 +35,11 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
getStubConfig?(
hass: HomeAssistant
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
}
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceStrategyConfig): void;
@@ -40,6 +40,10 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
);
}
public static getStubConfig(): CommonControlsSectionStrategyConfig {
return { type: "common-controls" };
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -30,6 +30,7 @@ import "../components/hui-section-edit-mode";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
@@ -474,12 +475,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _createSection(): void {
const newConfig = addSection(
this.lovelace!.config,
this.path!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
showCreateSectionDialog(this, {
lovelace: this.lovelace!,
containerPath: this.path!,
});
}
private _sectionMoved(ev: CustomEvent) {
+16 -1
View File
@@ -10169,9 +10169,24 @@
},
"section_strategy": {
"common-controls": {
"name": "Common controls"
"name": "Common controls",
"description": "Shows the entities you use most, based on your activity."
}
},
"create_section": {
"header": "Create section",
"manual": "Manual section",
"manual_description": "Start empty and add your own cards.",
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
"automatic_description": "Fills itself with cards and stays up to date.",
"paste": "Paste section",
"paste_description": "Add the section you copied."
},
"select_section_strategy": {
"header": "Choose an automatic section",
"built_in": "Built-in sections",
"custom": "Community sections"
},
"edit_section": {
"header": "Section configuration",
"typed_header": "{type} section configuration",