Files
2026-07-14 23:57:03 -04:00

248 lines
7.0 KiB
TypeScript

/** Dialog to save the current panel filters as a named view + manage views.
*
* "Views" are shared, named combinations of the task-list filters (status /
* user / archived) plus sort + group-by. This dialog saves the panel's *current*
* filter state under a name and lists existing views for deletion. Applying a
* view is done from the toolbar dropdown, not here.
*
* Uses a native <input> for the name — <ha-textfield> is not registered in the
* custom-panel context, so it renders without its field (the documented trap).
*/
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
interface ViewsResponse {
views: SavedView[];
}
export class MaintenanceSavedViewsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _name = "";
@state() private _views: SavedView[] = [];
private _filters: SavedViewFilters | null = null;
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
public async open(currentFilters: SavedViewFilters, views: SavedView[]): Promise<void> {
this._open = true;
this._error = "";
this._name = "";
this._filters = currentFilters;
this._views = views;
}
private _close(): void {
this._open = false;
}
private _emitChanged(views: SavedView[]): void {
this._views = views;
this.dispatchEvent(
new CustomEvent("saved-views-changed", { bubbles: true, composed: true, detail: { views } }),
);
}
private _save = async (): Promise<void> => {
const name = this._name.trim();
if (!name || this._busy || !this._filters) return;
this._busy = true;
this._error = "";
try {
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
type: "maintenance_supporter/views/save",
name,
filters: this._filters,
});
this._name = "";
this._emitChanged(res.views || []);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
};
private _delete = async (viewId: string): Promise<void> => {
if (this._busy) return;
this._busy = true;
this._error = "";
try {
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
type: "maintenance_supporter/views/delete",
view_id: viewId,
});
this._emitChanged(res.views || []);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
};
render() {
if (!this._open) return html``;
const L = this._lang;
return html`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
<div class="title">${t("views_dialog_title", L)}</div>
<div class="hint">${t("views_dialog_hint", L)}</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="save-row">
<input
class="name-input"
type="text"
.value=${this._name}
placeholder=${t("views_name_placeholder", L)}
maxlength="60"
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this._save();
}}
/>
<ha-button @click=${this._save} .disabled=${!this._name.trim() || this._busy}>
${t("views_save_current", L)}
</ha-button>
</div>
${this._views.length === 0
? html`<div class="empty">${t("views_none_yet", L)}</div>`
: html`
<div class="list">
${this._views.map(
(v) => html`
<div class="row">
<span class="row-name">${v.name}</span>
<ha-icon-button
.path=${"M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"}
.label=${t("delete", L)}
@click=${() => this._delete(v.id)}
></ha-icon-button>
</div>
`,
)}
</div>
`}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>${t("close", L)}</ha-button>
</div>
</div>
</div>
`;
}
static styles = css`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 340px;
max-width: 480px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.save-row {
display: flex;
gap: 8px;
align-items: center;
}
.name-input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
}
.empty {
color: var(--secondary-text-color);
font-size: 14px;
padding: 8px 0;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
}
.row-name {
font-size: 14px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`;
}
if (!customElements.get("maintenance-saved-views-dialog")) {
customElements.define("maintenance-saved-views-dialog", MaintenanceSavedViewsDialog);
}