mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-08 18:39:40 +00:00
591 lines
18 KiB
TypeScript
591 lines
18 KiB
TypeScript
import { mdiClose, mdiPlus } from "@mdi/js";
|
|
import { dump } from "js-yaml";
|
|
import type { CSSResultGroup } from "lit";
|
|
import { css, html, LitElement, nothing } from "lit";
|
|
import { customElement, property, state } from "lit/decorators";
|
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
|
import "../../../../components/chips/ha-assist-chip";
|
|
import "../../../../components/chips/ha-chip-set";
|
|
import "../../../../components/ha-alert";
|
|
import "../../../../components/ha-area-picker";
|
|
import "../../../../components/ha-domain-icon";
|
|
import "../../../../components/ha-expansion-panel";
|
|
import "../../../../components/ha-icon-picker";
|
|
import "../../../../components/ha-labels-picker";
|
|
import "../../../../components/ha-suggest-with-ai-button";
|
|
import type { SuggestWithAIGenerateTask } from "../../../../components/ha-suggest-with-ai-button";
|
|
import "../../../../components/ha-svg-icon";
|
|
import "../../../../components/ha-textarea";
|
|
import "../../../../components/ha-textfield";
|
|
import "../../category/ha-category-picker";
|
|
|
|
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
|
|
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
|
|
import { subscribeOne } from "../../../../common/util/subscribe-one";
|
|
import type { GenDataTaskResult } from "../../../../data/ai_task";
|
|
import { fetchCategoryRegistry } from "../../../../data/category_registry";
|
|
import { subscribeEntityRegistry } from "../../../../data/entity_registry";
|
|
import { subscribeLabelRegistry } from "../../../../data/label_registry";
|
|
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
|
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
|
import type { HomeAssistant } from "../../../../types";
|
|
import type {
|
|
EntityRegistryUpdate,
|
|
SaveDialogParams,
|
|
} from "./show-dialog-automation-save";
|
|
|
|
@customElement("ha-dialog-automation-save")
|
|
class DialogAutomationSave extends LitElement implements HassDialog {
|
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
|
|
|
@state() private _opened = false;
|
|
|
|
@state() private _error?: string;
|
|
|
|
@state() private _visibleOptionals: string[] = [];
|
|
|
|
@state() private _entryUpdates!: EntityRegistryUpdate;
|
|
|
|
private _params!: SaveDialogParams;
|
|
|
|
@state() private _newName?: string;
|
|
|
|
private _newIcon?: string;
|
|
|
|
private _newDescription?: string;
|
|
|
|
public showDialog(params: SaveDialogParams): void {
|
|
this._opened = true;
|
|
this._params = params;
|
|
this._newIcon = "icon" in params.config ? params.config.icon : undefined;
|
|
this._newName =
|
|
params.config.alias ||
|
|
this.hass.localize(
|
|
`ui.panel.config.${this._params.domain}.editor.default_name`
|
|
);
|
|
this._newDescription = params.config.description || "";
|
|
this._entryUpdates = params.entityRegistryUpdate || {
|
|
area: params.entityRegistryEntry?.area_id || "",
|
|
labels: params.entityRegistryEntry?.labels || [],
|
|
category: params.entityRegistryEntry?.categories[params.domain] || "",
|
|
};
|
|
|
|
this._visibleOptionals = [
|
|
this._newDescription ? "description" : "",
|
|
this._newIcon ? "icon" : "",
|
|
this._entryUpdates.category ? "category" : "",
|
|
this._entryUpdates.labels.length > 0 ? "labels" : "",
|
|
this._entryUpdates.area ? "area" : "",
|
|
].filter(Boolean);
|
|
}
|
|
|
|
public closeDialog() {
|
|
this._params.onClose();
|
|
|
|
if (this._opened) {
|
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
|
}
|
|
this._opened = false;
|
|
this._visibleOptionals = [];
|
|
return true;
|
|
}
|
|
|
|
protected _renderOptionalChip(id: string, label: string) {
|
|
if (this._visibleOptionals.includes(id)) {
|
|
return nothing;
|
|
}
|
|
|
|
return html`
|
|
<ha-assist-chip id=${id} @click=${this._addOptional} label=${label}>
|
|
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
|
</ha-assist-chip>
|
|
`;
|
|
}
|
|
|
|
protected _renderDiscard() {
|
|
if (!this._params.onDiscard) {
|
|
return nothing;
|
|
}
|
|
return html`
|
|
<ha-button
|
|
@click=${this._handleDiscard}
|
|
slot="secondaryAction"
|
|
variant="danger"
|
|
appearance="plain"
|
|
>
|
|
${this.hass.localize("ui.common.dont_save")}
|
|
</ha-button>
|
|
`;
|
|
}
|
|
|
|
protected _renderInputs() {
|
|
if (this._params.hideInputs) {
|
|
return nothing;
|
|
}
|
|
|
|
return html`
|
|
<ha-textfield
|
|
dialogInitialFocus
|
|
.value=${this._newName}
|
|
.placeholder=${this.hass.localize(
|
|
`ui.panel.config.${this._params.domain}.editor.default_name`
|
|
)}
|
|
.label=${this.hass.localize("ui.panel.config.automation.editor.alias")}
|
|
required
|
|
type="string"
|
|
@input=${this._valueChanged}
|
|
></ha-textfield>
|
|
|
|
${this._params.domain === "script" &&
|
|
this._visibleOptionals.includes("icon")
|
|
? html`
|
|
<ha-icon-picker
|
|
.hass=${this.hass}
|
|
.label=${this.hass.localize(
|
|
"ui.panel.config.automation.editor.icon"
|
|
)}
|
|
.value=${this._newIcon}
|
|
@value-changed=${this._iconChanged}
|
|
>
|
|
<ha-domain-icon
|
|
slot="fallback"
|
|
domain=${this._params.domain}
|
|
.hass=${this.hass}
|
|
>
|
|
</ha-domain-icon>
|
|
</ha-icon-picker>
|
|
`
|
|
: nothing}
|
|
${this._visibleOptionals.includes("description")
|
|
? html` <ha-textarea
|
|
.label=${this.hass.localize(
|
|
"ui.panel.config.automation.editor.description.label"
|
|
)}
|
|
.placeholder=${this.hass.localize(
|
|
"ui.panel.config.automation.editor.description.placeholder"
|
|
)}
|
|
name="description"
|
|
autogrow
|
|
.value=${this._newDescription}
|
|
.helper=${supportsMarkdownHelper(this.hass.localize)}
|
|
@input=${this._valueChanged}
|
|
></ha-textarea>`
|
|
: nothing}
|
|
${this._visibleOptionals.includes("category")
|
|
? html` <ha-category-picker
|
|
id="category"
|
|
.hass=${this.hass}
|
|
.scope=${this._params.domain}
|
|
.value=${this._entryUpdates.category}
|
|
@value-changed=${this._registryEntryChanged}
|
|
></ha-category-picker>`
|
|
: nothing}
|
|
${this._visibleOptionals.includes("labels")
|
|
? html` <ha-labels-picker
|
|
id="labels"
|
|
.hass=${this.hass}
|
|
.value=${this._entryUpdates.labels}
|
|
@value-changed=${this._registryEntryChanged}
|
|
></ha-labels-picker>`
|
|
: nothing}
|
|
${this._visibleOptionals.includes("area")
|
|
? html` <ha-area-picker
|
|
id="area"
|
|
.hass=${this.hass}
|
|
.value=${this._entryUpdates.area}
|
|
@value-changed=${this._registryEntryChanged}
|
|
></ha-area-picker>`
|
|
: nothing}
|
|
|
|
<ha-chip-set>
|
|
${this._renderOptionalChip(
|
|
"description",
|
|
this.hass.localize(
|
|
"ui.panel.config.automation.editor.dialog.add_description"
|
|
)
|
|
)}
|
|
${this._params.domain === "script"
|
|
? this._renderOptionalChip(
|
|
"icon",
|
|
this.hass.localize(
|
|
"ui.panel.config.automation.editor.dialog.add_icon"
|
|
)
|
|
)
|
|
: nothing}
|
|
${this._renderOptionalChip(
|
|
"area",
|
|
this.hass.localize(
|
|
"ui.panel.config.automation.editor.dialog.add_area"
|
|
)
|
|
)}
|
|
${this._renderOptionalChip(
|
|
"category",
|
|
this.hass.localize(
|
|
"ui.panel.config.automation.editor.dialog.add_category"
|
|
)
|
|
)}
|
|
${this._renderOptionalChip(
|
|
"labels",
|
|
this.hass.localize(
|
|
"ui.panel.config.automation.editor.dialog.add_labels"
|
|
)
|
|
)}
|
|
</ha-chip-set>
|
|
`;
|
|
}
|
|
|
|
protected render() {
|
|
if (!this._opened) {
|
|
return nothing;
|
|
}
|
|
|
|
const title = this.hass.localize(
|
|
this._params.config.alias
|
|
? "ui.panel.config.automation.editor.rename"
|
|
: "ui.common.save"
|
|
);
|
|
|
|
return html`
|
|
<ha-dialog
|
|
open
|
|
scrimClickAction
|
|
@closed=${this.closeDialog}
|
|
.heading=${title}
|
|
>
|
|
<ha-dialog-header slot="heading">
|
|
<ha-icon-button
|
|
slot="navigationIcon"
|
|
dialogAction="cancel"
|
|
.label=${this.hass.localize("ui.common.close")}
|
|
.path=${mdiClose}
|
|
></ha-icon-button>
|
|
<span slot="title">${this._params.title || title}</span>
|
|
${this._params.hideInputs
|
|
? nothing
|
|
: html` <ha-suggest-with-ai-button
|
|
slot="actionItems"
|
|
.hass=${this.hass}
|
|
.generateTask=${this._generateTask}
|
|
@suggestion=${this._handleSuggestion}
|
|
></ha-suggest-with-ai-button>`}
|
|
</ha-dialog-header>
|
|
${this._error
|
|
? html`<ha-alert alert-type="error"
|
|
>${this.hass.localize(
|
|
"ui.panel.config.automation.editor.missing_name"
|
|
)}</ha-alert
|
|
>`
|
|
: ""}
|
|
${this._params.description
|
|
? html`<p>${this._params.description}</p>`
|
|
: nothing}
|
|
${this._renderInputs()} ${this._renderDiscard()}
|
|
|
|
<div slot="primaryAction">
|
|
<ha-button appearance="plain" @click=${this.closeDialog}>
|
|
${this.hass.localize("ui.common.cancel")}
|
|
</ha-button>
|
|
<ha-button @click=${this._save}>
|
|
${this.hass.localize(
|
|
this._params.config.alias && !this._params.onDiscard
|
|
? "ui.panel.config.automation.editor.rename"
|
|
: "ui.common.save"
|
|
)}
|
|
</ha-button>
|
|
</div>
|
|
</ha-dialog>
|
|
`;
|
|
}
|
|
|
|
private _addOptional(ev) {
|
|
ev.stopPropagation();
|
|
const option: string = ev.target.id;
|
|
this._visibleOptionals = [...this._visibleOptionals, option];
|
|
}
|
|
|
|
private _registryEntryChanged(ev) {
|
|
ev.stopPropagation();
|
|
const id: string = ev.target.id;
|
|
const value = ev.detail.value;
|
|
|
|
this._entryUpdates = { ...this._entryUpdates, [id]: value };
|
|
}
|
|
|
|
private _iconChanged(ev: CustomEvent) {
|
|
ev.stopPropagation();
|
|
this._newIcon = ev.detail.value || undefined;
|
|
}
|
|
|
|
private _valueChanged(ev: CustomEvent) {
|
|
ev.stopPropagation();
|
|
const target = ev.target as any;
|
|
if (target.name === "description") {
|
|
this._newDescription = target.value;
|
|
} else {
|
|
this._newName = target.value;
|
|
}
|
|
}
|
|
|
|
private _handleDiscard() {
|
|
this._params.onDiscard?.();
|
|
this.closeDialog();
|
|
}
|
|
|
|
private _getSuggestData() {
|
|
return Promise.all([
|
|
subscribeOne(this.hass.connection, subscribeLabelRegistry).then((labs) =>
|
|
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
|
|
),
|
|
subscribeOne(this.hass.connection, subscribeEntityRegistry).then((ents) =>
|
|
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
|
|
),
|
|
fetchCategoryRegistry(this.hass.connection, "automation").then((cats) =>
|
|
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
|
|
),
|
|
]);
|
|
}
|
|
|
|
private _generateTask = async (): Promise<SuggestWithAIGenerateTask> => {
|
|
const [labels, entities, categories] = await this._getSuggestData();
|
|
const inspirations: string[] = [];
|
|
|
|
const domain = this._params.domain;
|
|
|
|
for (const entity of Object.values(this.hass.states)) {
|
|
const entityEntry = entities[entity.entity_id];
|
|
if (
|
|
computeStateDomain(entity) !== domain ||
|
|
entity.attributes.restored ||
|
|
!entity.attributes.friendly_name ||
|
|
!entityEntry
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
let inspiration = `- ${entity.attributes.friendly_name}`;
|
|
|
|
const category = categories[entityEntry.categories.automation];
|
|
if (category) {
|
|
inspiration += ` (category: ${category})`;
|
|
}
|
|
|
|
if (entityEntry.labels.length) {
|
|
inspiration += ` (labels: ${entityEntry.labels
|
|
.map((label) => labels[label])
|
|
.join(", ")})`;
|
|
}
|
|
|
|
inspirations.push(inspiration);
|
|
}
|
|
|
|
const term = this._params.domain === "script" ? "script" : "automation";
|
|
|
|
return {
|
|
type: "data",
|
|
task: {
|
|
task_name: `frontend__${term}__save`,
|
|
instructions: `Suggest in language "${this.hass.language}" a name, description, category and labels for the following Home Assistant ${term}.
|
|
|
|
The name should be relevant to the ${term}'s purpose.
|
|
${
|
|
inspirations.length
|
|
? `The name should be in same style and sentence capitalization as existing ${term}s.
|
|
Suggest a category and labels if relevant to the ${term}'s purpose.
|
|
Only suggest category and labels that are already used by existing ${term}s.`
|
|
: `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.`
|
|
}
|
|
If the ${term} contains 5+ steps, include a short description.
|
|
|
|
For inspiration, here are existing ${term}s:
|
|
${inspirations.join("\n")}
|
|
|
|
The ${term} configuration is as follows:
|
|
|
|
${dump(this._params.config)}
|
|
`,
|
|
structure: {
|
|
name: {
|
|
description: "The name of the automation",
|
|
required: true,
|
|
selector: {
|
|
text: {},
|
|
},
|
|
},
|
|
description: {
|
|
description: "A short description of the automation",
|
|
required: false,
|
|
selector: {
|
|
text: {},
|
|
},
|
|
},
|
|
labels: {
|
|
description: "Labels for the automation",
|
|
required: false,
|
|
selector: {
|
|
text: {
|
|
multiple: true,
|
|
},
|
|
},
|
|
},
|
|
category: {
|
|
description: "The category of the automation",
|
|
required: false,
|
|
selector: {
|
|
select: {
|
|
options: Object.entries(categories).map(([id, name]) => ({
|
|
value: id,
|
|
label: name,
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
private async _handleSuggestion(
|
|
event: CustomEvent<
|
|
GenDataTaskResult<{
|
|
name: string;
|
|
description?: string;
|
|
category?: string;
|
|
labels?: string[];
|
|
}>
|
|
>
|
|
) {
|
|
const result = event.detail;
|
|
const [labels, _entities, categories] = await this._getSuggestData();
|
|
|
|
this._newName = result.data.name;
|
|
if (result.data.description) {
|
|
this._newDescription = result.data.description;
|
|
if (!this._visibleOptionals.includes("description")) {
|
|
this._visibleOptionals = [...this._visibleOptionals, "description"];
|
|
}
|
|
}
|
|
if (result.data.category) {
|
|
// We get back category name, convert it to ID
|
|
const categoryId = Object.entries(categories).find(
|
|
([, name]) => name === result.data.category
|
|
)?.[0];
|
|
if (categoryId) {
|
|
this._entryUpdates = {
|
|
...this._entryUpdates,
|
|
category: categoryId,
|
|
};
|
|
if (!this._visibleOptionals.includes("category")) {
|
|
this._visibleOptionals = [...this._visibleOptionals, "category"];
|
|
}
|
|
}
|
|
}
|
|
if (result.data.labels?.length) {
|
|
// We get back label names, convert them to IDs
|
|
const newLabels: Record<string, undefined | string> = Object.fromEntries(
|
|
result.data.labels.map((name) => [name, undefined])
|
|
);
|
|
let toFind = result.data.labels.length;
|
|
for (const [labelId, labelName] of Object.entries(labels)) {
|
|
if (labelName in newLabels && newLabels[labelName] === undefined) {
|
|
newLabels[labelName] = labelId;
|
|
toFind--;
|
|
if (toFind === 0) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const foundLabels = Object.values(newLabels).filter(
|
|
(labelId) => labelId !== undefined
|
|
);
|
|
if (foundLabels.length) {
|
|
this._entryUpdates = {
|
|
...this._entryUpdates,
|
|
labels: foundLabels,
|
|
};
|
|
if (!this._visibleOptionals.includes("labels")) {
|
|
this._visibleOptionals = [...this._visibleOptionals, "labels"];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async _save(): Promise<void> {
|
|
if (!this._newName) {
|
|
this._error = "Name is required";
|
|
return;
|
|
}
|
|
|
|
if (this._params.domain === "script") {
|
|
await this._params.updateConfig(
|
|
{
|
|
...this._params.config,
|
|
alias: this._newName,
|
|
description: this._newDescription,
|
|
icon: this._newIcon,
|
|
},
|
|
this._entryUpdates
|
|
);
|
|
} else {
|
|
await this._params.updateConfig(
|
|
{
|
|
...this._params.config,
|
|
alias: this._newName,
|
|
description: this._newDescription,
|
|
},
|
|
this._entryUpdates
|
|
);
|
|
}
|
|
|
|
this.closeDialog();
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return [
|
|
haStyle,
|
|
haStyleDialog,
|
|
css`
|
|
ha-dialog {
|
|
--dialog-content-padding: 0 24px 24px 24px;
|
|
}
|
|
|
|
@media all and (min-width: 500px) {
|
|
ha-dialog {
|
|
--mdc-dialog-min-width: min(500px, 95vw);
|
|
--mdc-dialog-max-width: min(500px, 95vw);
|
|
}
|
|
}
|
|
|
|
ha-textfield,
|
|
ha-textarea,
|
|
ha-icon-picker,
|
|
ha-category-picker,
|
|
ha-labels-picker,
|
|
ha-area-picker {
|
|
display: block;
|
|
}
|
|
ha-icon-picker,
|
|
ha-category-picker,
|
|
ha-labels-picker,
|
|
ha-area-picker,
|
|
ha-chip-set {
|
|
margin-top: 16px;
|
|
}
|
|
ha-alert {
|
|
display: block;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
ha-suggest-with-ai-button {
|
|
margin: 8px 16px;
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"ha-dialog-automation-save": DialogAutomationSave;
|
|
}
|
|
}
|