Add description and due support to todo lists (#19107)

This commit is contained in:
Bram Kragten 2023-12-21 21:30:24 +01:00 committed by GitHub
parent 8f07e6f141
commit 09dcc29175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 854 additions and 202 deletions

View File

@ -3,9 +3,15 @@ import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css"; import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css"; import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-check-list-item") @customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase { export class HaCheckListItem extends CheckListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [ static override styles = [
styles, styles,
controlStyles, controlStyles,
@ -22,6 +28,12 @@ export class HaCheckListItem extends CheckListItemBase {
margin-inline-start: 0px; margin-inline-start: 0px;
direction: var(--direction); direction: var(--direction);
} }
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-list-item-graphic-margin-top);
}
`, `,
]; ];
} }

View File

@ -18,6 +18,8 @@ export interface TodoItem {
uid: string; uid: string;
summary: string; summary: string;
status: TodoItemStatus; status: TodoItemStatus;
description?: string;
due?: string;
} }
export const enum TodoListEntityFeature { export const enum TodoListEntityFeature {
@ -25,6 +27,9 @@ export const enum TodoListEntityFeature {
DELETE_TODO_ITEM = 2, DELETE_TODO_ITEM = 2,
UPDATE_TODO_ITEM = 4, UPDATE_TODO_ITEM = 4,
MOVE_TODO_ITEM = 8, MOVE_TODO_ITEM = 8,
SET_DUE_DATE_ON_ITEM = 16,
SET_DUE_DATETIME_ON_ITEM = 32,
SET_DESCRIPTION_ON_ITEM = 64,
} }
export const getTodoLists = (hass: HomeAssistant): TodoList[] => export const getTodoLists = (hass: HomeAssistant): TodoList[] =>
@ -74,20 +79,30 @@ export const updateItem = (
hass.callService( hass.callService(
"todo", "todo",
"update_item", "update_item",
{ item: item.uid, rename: item.summary, status: item.status }, {
item: item.uid,
rename: item.summary,
status: item.status,
description: item.description || undefined,
due_datetime: item.due?.includes("T") ? item.due : undefined,
due_date: item.due?.includes("T") ? undefined : item.due || undefined,
},
{ entity_id } { entity_id }
); );
export const createItem = ( export const createItem = (
hass: HomeAssistant, hass: HomeAssistant,
entity_id: string, entity_id: string,
summary: string item: Omit<TodoItem, "uid" | "status">
): Promise<ServiceCallResponse> => ): Promise<ServiceCallResponse> =>
hass.callService( hass.callService(
"todo", "todo",
"add_item", "add_item",
{ {
item: summary, item: item.summary,
description: item.description || undefined,
due_datetime: item.due?.includes("T") ? item.due : undefined,
due_date: item.due?.includes("T") ? undefined : item.due,
}, },
{ entity_id } { entity_id }
); );

View File

@ -1,10 +1,14 @@
import "@material/mwc-list/mwc-list";
import { import {
mdiClock,
mdiDelete, mdiDelete,
mdiDeleteSweep,
mdiDotsVertical,
mdiDrag, mdiDrag,
mdiNotificationClearAll,
mdiPlus, mdiPlus,
mdiSort, mdiSort,
} from "@mdi/js"; } from "@mdi/js";
import { endOfDay, isSameDay } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
@ -22,11 +26,13 @@ import memoizeOne from "memoize-one";
import type { SortableEvent } from "sortablejs"; import type { SortableEvent } from "sortablejs";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { supportsFeature } from "../../../common/entity/supports-feature"; import { supportsFeature } from "../../../common/entity/supports-feature";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-checkbox"; import "../../../components/ha-checkbox";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item"; import "../../../components/ha-list-item";
import "../../../components/ha-markdown-element";
import "../../../components/ha-relative-time";
import "../../../components/ha-select"; import "../../../components/ha-select";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
@ -42,8 +48,10 @@ import {
subscribeItems, subscribeItems,
updateItem, updateItem,
} from "../../../data/todo"; } from "../../../data/todo";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { SortableInstance } from "../../../resources/sortable"; import type { SortableInstance } from "../../../resources/sortable";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { showTodoItemEditDialog } from "../../todo/show-dialog-todo-item-editor";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
@ -199,6 +207,14 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
<div class="addRow"> <div class="addRow">
${this.todoListSupportsFeature(TodoListEntityFeature.CREATE_TODO_ITEM) ${this.todoListSupportsFeature(TodoListEntityFeature.CREATE_TODO_ITEM)
? html` ? html`
<ha-textfield
class="addBox"
.placeholder=${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.add_item"
)}
@keydown=${this._addKeyPress}
.disabled=${unavailable}
></ha-textfield>
<ha-icon-button <ha-icon-button
class="addButton" class="addButton"
.path=${mdiPlus} .path=${mdiPlus}
@ -209,38 +225,53 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
@click=${this._addItem} @click=${this._addItem}
> >
</ha-icon-button> </ha-icon-button>
<ha-textfield
class="addBox"
.placeholder=${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.add_item"
)}
@keydown=${this._addKeyPress}
.disabled=${unavailable}
></ha-textfield>
`
: nothing}
${this.todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM)
? html`
<ha-icon-button
class="reorderButton"
.path=${mdiSort}
.title=${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.reorder_items"
)}
@click=${this._toggleReorder}
.disabled=${unavailable}
>
</ha-icon-button>
` `
: nothing} : nothing}
</div> </div>
<div id="unchecked"> ${uncheckedItems.length
${this._renderItems(uncheckedItems, unavailable)} ? html` <div class="header">
</div> <span>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.unchecked_items"
)}
</span>
${this.todoListSupportsFeature(
TodoListEntityFeature.MOVE_TODO_ITEM
)
? html`<ha-button-menu>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
@click=${this._toggleReorder}
graphic="icon"
>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.reorder_items"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiSort}
.disabled=${unavailable}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>`
: nothing}
</div>
<mwc-list id="unchecked">
${this._renderItems(uncheckedItems, unavailable)}
</mwc-list>`
: html`<p class="empty">
${this.hass.localize(
"ui.panel.lovelace.cards.todo-list.no_unchecked_items"
)}
</p>`}
${checkedItems.length ${checkedItems.length
? html` ? html`
<div class="divider"></div> <div class="divider"></div>
<div class="checked"> <div class="header">
<span> <span>
${this.hass!.localize( ${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.checked_items" "ui.panel.lovelace.cards.todo-list.checked_items"
@ -249,65 +280,33 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
${this.todoListSupportsFeature( ${this.todoListSupportsFeature(
TodoListEntityFeature.DELETE_TODO_ITEM TodoListEntityFeature.DELETE_TODO_ITEM
) )
? html`<ha-svg-icon ? html`<ha-button-menu>
class="clearall" <ha-icon-button
tabindex="0" slot="trigger"
.path=${mdiNotificationClearAll} .path=${mdiDotsVertical}
.title=${this.hass!.localize( ></ha-icon-button>
"ui.panel.lovelace.cards.todo-list.clear_items" <ha-list-item
)} @click=${this._clearCompletedItems}
@click=${this._clearCompletedItems} graphic="icon"
.disabled=${unavailable} class="warning"
> >
</ha-svg-icon>` ${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.clear_items"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDeleteSweep}
.disabled=${unavailable}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>`
: nothing} : nothing}
</div> </div>
${repeat( <mwc-list multi id="checked">
checkedItems, ${this._renderItems(checkedItems, unavailable)}
(item) => item.uid, </mwc-list>
(item) => html`
<div class="editRow">
${this.todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)
? html`<ha-checkbox
tabindex="0"
.checked=${item.status === TodoItemStatus.Completed}
.itemId=${item.uid}
@change=${this._completeItem}
.disabled=${unavailable}
></ha-checkbox>`
: nothing}
<ha-textfield
class="item"
.disabled=${unavailable ||
!this.todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)}
.value=${item.summary}
.itemId=${item.uid}
@change=${this._saveEdit}
></ha-textfield>
${this.todoListSupportsFeature(
TodoListEntityFeature.DELETE_TODO_ITEM
) &&
!this.todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)
? html`<ha-icon-button
.title=${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.delete_item"
)}
class="deleteItemButton"
.path=${mdiDelete}
.itemId=${item.uid}
@click=${this._deleteItem}
>
</ha-icon-button>`
: nothing}
</div>
`
)}
` `
: ""} : ""}
</ha-card> </ha-card>
@ -319,59 +318,93 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
${repeat( ${repeat(
items, items,
(item) => item.uid, (item) => item.uid,
(item) => html` (item) => {
<div class="editRow" item-id=${item.uid}> const showDelete =
${this.todoListSupportsFeature( this.todoListSupportsFeature(
TodoListEntityFeature.DELETE_TODO_ITEM
) &&
!this.todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM TodoListEntityFeature.UPDATE_TODO_ITEM
) );
? html`<ha-checkbox const showReorder =
tabindex="0" item.status !== TodoItemStatus.Completed && this._reordering;
.checked=${item.status === TodoItemStatus.Completed} const due = item.due
.itemId=${item.uid} ? item.due.includes("T")
.disabled=${unavailable} ? new Date(item.due)
@change=${this._completeItem} : endOfDay(new Date(item.due))
></ha-checkbox>` : undefined;
: nothing} const today =
<ha-textfield due && !item.due!.includes("T") && isSameDay(new Date(), due);
class="item" return html`
<ha-check-list-item
left
.hasMeta=${showReorder || showDelete}
class="editRow ${classMap({
completed: item.status === TodoItemStatus.Completed,
multiline: Boolean(item.description || item.due),
})}"
.selected=${item.status === TodoItemStatus.Completed}
.disabled=${unavailable || .disabled=${unavailable ||
!this.todoListSupportsFeature( !this.todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM TodoListEntityFeature.UPDATE_TODO_ITEM
)} )}
.value=${item.summary} item-id=${item.uid}
.itemId=${item.uid} .itemId=${item.uid}
@change=${this._saveEdit} @change=${this._completeItem}
></ha-textfield> @click=${this._openItem}
${this._reordering @request-selected=${this._requestSelected}
? html` @keydown=${this._handleKeydown}
<ha-svg-icon >
.title=${this.hass!.localize( <div class="column">
"ui.panel.lovelace.cards.todo-list.drag_and_drop" <span class="summary">${item.summary}</span>
)} ${item.description
class="reorderButton handle" ? html`<ha-markdown-element
.path=${mdiDrag} class="description"
> .content=${item.description}
</ha-svg-icon> ></ha-markdown-element>`
` : nothing}
: this.todoListSupportsFeature( ${due
TodoListEntityFeature.DELETE_TODO_ITEM ? html`<div class="due ${due < new Date() ? "overdue" : ""}">
) && <ha-svg-icon .path=${mdiClock}></ha-svg-icon>${today
!this.todoListSupportsFeature( ? this.hass!.localize(
TodoListEntityFeature.UPDATE_TODO_ITEM "ui.panel.lovelace.cards.todo-list.today"
) )
? html`<ha-icon-button : html`<ha-relative-time
.title=${this.hass!.localize( capitalize
"ui.panel.lovelace.cards.todo-list.delete_item" .hass=${this.hass}
)} .datetime=${due}
class="deleteItemButton" ></ha-relative-time>`}
.path=${mdiDelete} </div>`
.itemId=${item.uid} : nothing}
@click=${this._deleteItem} </div>
> ${showReorder
</ha-icon-button>` ? html`
: nothing} <ha-svg-icon
</div> .title=${this.hass!.localize(
` "ui.panel.lovelace.cards.todo-list.drag_and_drop"
)}
class="reorderButton handle"
.path=${mdiDrag}
slot="meta"
>
</ha-svg-icon>
`
: showDelete
? html`<ha-icon-button
.title=${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.delete_item"
)}
class="deleteItemButton"
.path=${mdiDelete}
.itemId=${item.uid}
slot="meta"
@click=${this._deleteItem}
>
</ha-icon-button>`
: nothing}
</ha-check-list-item>
`;
}
)} )}
`; `;
} }
@ -401,39 +434,52 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
return this._items?.find((item) => item.uid === itemId); return this._items?.find((item) => item.uid === itemId);
} }
private _requestSelected(ev: Event): void {
ev.stopPropagation();
}
private _handleKeydown(ev) {
if (ev.key === " ") {
this._completeItem(ev);
return;
}
if (ev.key === "Enter") {
this._openItem(ev);
}
}
private _openItem(ev): void {
ev.stopPropagation();
if (
ev
.composedPath()
.find((el) => ["input", "a", "button"].includes(el.localName))
) {
return;
}
const item = this._getItem(ev.currentTarget.itemId);
showTodoItemEditDialog(this, {
entity: this._config!.entity!,
item,
});
}
private _completeItem(ev): void { private _completeItem(ev): void {
const item = this._getItem(ev.target.itemId); const item = this._getItem(ev.currentTarget.itemId);
if (!item) { if (!item) {
return; return;
} }
updateItem(this.hass!, this._entityId!, { updateItem(this.hass!, this._entityId!, {
...item, ...item,
status: ev.target.checked status:
? TodoItemStatus.Completed item.status === TodoItemStatus.NeedsAction
: TodoItemStatus.NeedsAction, ? TodoItemStatus.Completed
: TodoItemStatus.NeedsAction,
}); });
} }
private _saveEdit(ev): void {
// If name is not empty, update the item otherwise remove it
if (ev.target.value) {
const item = this._getItem(ev.target.itemId);
if (!item) {
return;
}
updateItem(this.hass!, this._entityId!, {
...item,
summary: ev.target.value,
});
} else if (
this.todoListSupportsFeature(TodoListEntityFeature.DELETE_TODO_ITEM)
) {
deleteItems(this.hass!, this._entityId!, [ev.target.itemId]);
}
ev.target.blur();
}
private async _clearCompletedItems(): Promise<void> { private async _clearCompletedItems(): Promise<void> {
if (!this.hass) { if (!this.hass) {
return; return;
@ -464,7 +510,9 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
private _addItem(ev): void { private _addItem(ev): void {
const newItem = this._newItem; const newItem = this._newItem;
if (newItem.value!.length > 0) { if (newItem.value!.length > 0) {
createItem(this.hass!, this._entityId!, newItem.value!); createItem(this.hass!, this._entityId!, {
summary: newItem.value!,
});
} }
newItem.value = ""; newItem.value = "";
@ -559,7 +607,6 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
ha-card { ha-card {
padding: 16px;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -568,60 +615,127 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
padding-top: 0; padding-top: 0;
} }
.editRow, .addRow {
padding: 16px;
padding-bottom: 0;
position: relative;
}
.addRow ha-icon-button {
position: absolute;
right: 16px;
}
.addRow, .addRow,
.checked { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
} }
.header {
padding-left: 30px;
padding-right: 16px;
margin-top: 8px;
justify-content: space-between;
}
.header span {
color: var(--primary-text-color);
font-weight: 500;
}
.empty {
padding: 16px 32px;
}
.item { .item {
margin-top: 8px; margin-top: 8px;
} }
.addButton { ha-check-list-item {
margin-left: -12px; --mdc-list-item-meta-size: 56px;
margin-inline-start: -12px; min-height: 56px;
direction: var(--direction); height: auto;
} }
.deleteItemButton { ha-check-list-item.multiline {
margin-right: -12px; align-items: flex-start;
margin-inline-end: -12px; --check-list-item-graphic-margin-top: 8px;
direction: var(--direction);
} }
.reorderButton { .row {
margin-right: -12px; display: flex;
margin-inline-end: -12px; justify-content: space-between;
direction: var(--direction); }
.multiline .column {
display: flex;
flex-direction: column;
margin-top: 18px;
margin-bottom: 12px;
}
.completed .summary {
text-decoration: line-through;
}
.description,
.due {
font-size: 12px;
color: var(--secondary-text-color);
}
.description {
white-space: initial;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
.description p {
margin: 0;
}
.description a {
color: var(--primary-color);
}
.due {
display: flex;
align-items: center;
}
.due ha-svg-icon {
margin-right: 4px;
--mdc-icon-size: 14px;
}
.due.overdue {
color: var(--warning-color);
}
.completed .due.overdue {
color: var(--secondary-text-color);
} }
.handle { .handle {
cursor: move; cursor: move;
height: 24px;
padding: 16px 4px;
} }
ha-checkbox { .deleteItemButton {
margin-left: -12px; position: relative;
margin-inline-start: -12px; left: 8px;
direction: var(--direction);
} }
ha-textfield { ha-textfield {
flex-grow: 1; flex-grow: 1;
} }
.checked {
margin: 12px 0;
justify-content: space-between;
}
.checked span {
color: var(--primary-text-color);
font-weight: 500;
}
.divider { .divider {
height: 1px; height: 1px;
background-color: var(--divider-color); background-color: var(--divider-color);
@ -636,6 +750,10 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
display: block; display: block;
padding: 8px; padding: 8px;
} }
.warning {
color: var(--error-color);
}
`; `;
} }
} }

View File

@ -0,0 +1,442 @@
import "@material/mwc-button";
import { mdiClose } from "@mdi/js";
import { formatInTimeZone, toDate } from "date-fns-tz";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-date-input";
import "../../components/ha-textarea";
import "../../components/ha-time-input";
import {
TodoItemStatus,
TodoListEntityFeature,
createItem,
deleteItems,
updateItem,
} from "../../data/todo";
import { TimeZone } from "../../data/translation";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { TodoItemEditDialogParams } from "./show-dialog-todo-item-editor";
import { supportsFeature } from "../../common/entity/supports-feature";
@customElement("dialog-todo-item-editor")
class DialogTodoItemEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string;
@state() private _params?: TodoItemEditDialogParams;
@state() private _summary = "";
@state() private _description? = "";
@state() private _due?: Date;
@state() private _checked = false;
@state() private _hasTime = false;
@state() private _submitting = false;
// Dates are manipulated and displayed in the browser timezone
// which may be different from the Home Assistant timezone. When
// events are persisted, they are relative to the Home Assistant
// timezone, but floating without a timezone.
private _timeZone?: string;
public showDialog(params: TodoItemEditDialogParams): void {
this._error = undefined;
this._params = params;
this._timeZone =
this.hass.locale.time_zone === TimeZone.local
? Intl.DateTimeFormat().resolvedOptions().timeZone
: this.hass.config.time_zone;
if (params.item) {
const entry = params.item;
this._checked = entry.status === TodoItemStatus.Completed;
this._summary = entry.summary;
this._description = entry.description || "";
this._due = entry.due ? new Date(entry.due) : undefined;
this._hasTime = entry.due?.includes("T") || false;
} else {
this._hasTime = false;
this._checked = false;
this._due = undefined;
}
}
public closeDialog(): void {
if (!this._params) {
return;
}
this._error = undefined;
this._params = undefined;
this._due = undefined;
this._summary = "";
this._description = "";
this._hasTime = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const isCreate = this._params.item === undefined;
const { dueDate, dueTime } = this._getLocaleStrings(this._due);
const canUpdate = this._todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
);
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${html`
<div class="header_title">
${isCreate
? this.hass.localize("ui.components.todo.item.add")
: this._summary}
</div>
<ha-icon-button
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
dialogAction="close"
class="header_button"
></ha-icon-button>
`}
>
<div class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="flex">
<ha-checkbox
.checked=${this._checked}
@change=${this._checkedCanged}
.disabled=${isCreate || !canUpdate}
></ha-checkbox>
<ha-textfield
class="summary"
name="summary"
.label=${this.hass.localize("ui.components.todo.item.summary")}
.value=${this._summary}
required
@input=${this._handleSummaryChanged}
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
.disabled=${!canUpdate}
></ha-textfield>
</div>
${this._todoListSupportsFeature(
TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
? html`<ha-textarea
class="description"
name="description"
.label=${this.hass.localize(
"ui.components.todo.item.description"
)}
.value=${this._description}
@input=${this._handleDescriptionChanged}
autogrow
.disabled=${!canUpdate}
></ha-textarea>`
: nothing}
${this._todoListSupportsFeature(
TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
) ||
this._todoListSupportsFeature(
TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
)
? html`<div>
<span class="label"
>${this.hass.localize("ui.components.todo.item.due")}:</span
>
<div class="flex">
<ha-date-input
.value=${dueDate}
.locale=${this.hass.locale}
.disabled=${!canUpdate}
@value-changed=${this._endDateChanged}
></ha-date-input>
${this._todoListSupportsFeature(
TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
)
? html`<ha-time-input
.value=${dueTime}
.locale=${this.hass.locale}
.disabled=${!canUpdate}
@value-changed=${this._endTimeChanged}
></ha-time-input>`
: nothing}
</div>
</div>`
: nothing}
</div>
${isCreate
? html`
<mwc-button
slot="primaryAction"
@click=${this._createItem}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.todo.item.add")}
</mwc-button>
`
: html`
<mwc-button
slot="primaryAction"
@click=${this._saveItem}
.disabled=${!canUpdate || this._submitting}
>
${this.hass.localize("ui.components.todo.item.save")}
</mwc-button>
${this._todoListSupportsFeature(
TodoListEntityFeature.DELETE_TODO_ITEM
)
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click=${this._deleteItem}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.todo.item.delete")}
</mwc-button>
`
: ""}
`}
</ha-dialog>
`;
}
private _todoListSupportsFeature(feature: number): boolean {
if (!this._params?.entity) {
return false;
}
const entityStateObj = this.hass!.states[this._params?.entity];
return entityStateObj && supportsFeature(entityStateObj, feature);
}
private _getLocaleStrings = memoizeOne((due?: Date) => ({
dueDate: due ? this._formatDate(due) : undefined,
dueTime: due ? this._formatTime(due) : undefined,
}));
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
}
// Formats a time in specified timezone, or defaulting to browser display timezone
private _formatTime(
date: Date,
timeZone: string = this._timeZone!
): string | undefined {
return this._hasTime
? formatInTimeZone(date, timeZone, "HH:mm:ss")
: undefined; // 24 hr
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
}
private _checkedCanged(ev) {
this._checked = ev.target.checked;
}
private _handleSummaryChanged(ev) {
this._summary = ev.target.value;
}
private _handleDescriptionChanged(ev) {
this._description = ev.target.value;
}
private _endDateChanged(ev: CustomEvent) {
const time = this._due ? this._formatTime(this._due) : undefined;
this._due = this._parseDate(`${ev.detail.value}${time ? `T${time}` : ""}`);
}
private _endTimeChanged(ev: CustomEvent) {
this._hasTime = true;
this._due = this._parseDate(
`${this._formatDate(this._due || new Date())}T${ev.detail.value}`
);
}
private async _createItem() {
if (!this._summary) {
this._error = this.hass.localize(
"ui.components.todo.item.not_all_required_fields"
);
return;
}
this._submitting = true;
try {
await createItem(this.hass!, this._params!.entity, {
summary: this._summary,
description: this._description,
due: this._due
? this._hasTime
? this._due.toISOString()
: this._formatDate(this._due)
: undefined,
});
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
return;
} finally {
this._submitting = false;
}
this.closeDialog();
}
private async _saveItem() {
if (!this._summary) {
this._error = this.hass.localize(
"ui.components.todo.item.not_all_required_fields"
);
return;
}
this._submitting = true;
const entry = this._params!.item!;
try {
await updateItem(this.hass!, this._params!.entity, {
...entry,
summary: this._summary,
description:
this._description ||
(this._todoListSupportsFeature(
TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
? // backend should accept null to clear the field, but it doesn't now
" "
: undefined),
due: this._due
? this._hasTime
? this._due.toISOString()
: this._formatDate(this._due)
: undefined,
status: this._checked
? TodoItemStatus.Completed
: TodoItemStatus.NeedsAction,
});
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
return;
} finally {
this._submitting = false;
}
this.closeDialog();
}
private async _deleteItem() {
this._submitting = true;
const entry = this._params!.item!;
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.components.todo.item.confirm_delete.delete"
),
text: this.hass.localize("ui.components.todo.item.confirm_delete.prompt"),
});
if (!confirm) {
// Cancel
this._submitting = false;
return;
}
try {
await deleteItems(this.hass!, this._params!.entity, [entry.uid]);
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
return;
} finally {
this._submitting = false;
}
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: min(600px, 95vw);
--mdc-dialog-max-width: min(600px, 95vw);
}
ha-alert {
display: block;
margin-bottom: 16px;
}
ha-textfield,
ha-textarea {
display: block;
width: 100%;
}
ha-checkbox {
margin-top: 4px;
}
ha-textarea {
margin-bottom: 16px;
}
ha-date-input {
flex-grow: 1;
}
ha-time-input {
margin-left: 16px;
}
.flex {
display: flex;
justify-content: space-between;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--input-label-ink-color);
}
.date-range-details-content {
display: inline-block;
}
ha-svg-icon {
width: 40px;
margin-right: 8px;
margin-inline-end: 16px;
margin-inline-start: initial;
direction: var(--direction);
vertical-align: top;
}
.key {
display: inline-block;
vertical-align: top;
}
.value {
display: inline-block;
vertical-align: top;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-todo-item-editor": DialogTodoItemEditor;
}
}

View File

@ -23,7 +23,14 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { storage } from "../../common/decorators/storage"; import { storage } from "../../common/decorators/storage";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createSearchParam,
extractSearchParam,
} from "../../common/url/search-params";
import "../../components/ha-button"; import "../../components/ha-button";
import "../../components/ha-fab";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-list-item"; import "../../components/ha-list-item";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
@ -33,7 +40,7 @@ import "../../components/ha-two-pane-top-app-bar-fixed";
import { deleteConfigEntry } from "../../data/config_entries"; import { deleteConfigEntry } from "../../data/config_entries";
import { getExtendedEntityRegistryEntry } from "../../data/entity_registry"; import { getExtendedEntityRegistryEntry } from "../../data/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration"; import { fetchIntegrationManifest } from "../../data/integration";
import { getTodoLists } from "../../data/todo"; import { TodoListEntityFeature, getTodoLists } from "../../data/todo";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow"; import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { import {
showAlertDialog, showAlertDialog,
@ -45,12 +52,8 @@ import { HomeAssistant } from "../../types";
import { HuiErrorCard } from "../lovelace/cards/hui-error-card"; import { HuiErrorCard } from "../lovelace/cards/hui-error-card";
import { createCardElement } from "../lovelace/create-element/create-card-element"; import { createCardElement } from "../lovelace/create-element/create-card-element";
import { LovelaceCard } from "../lovelace/types"; import { LovelaceCard } from "../lovelace/types";
import { navigate } from "../../common/navigate"; import { showTodoItemEditDialog } from "./show-dialog-todo-item-editor";
import { import { supportsFeature } from "../../common/entity/supports-feature";
createSearchParam,
extractSearchParam,
} from "../../common/url/search-params";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@customElement("ha-panel-todo") @customElement("ha-panel-todo")
class PanelTodo extends LitElement { class PanelTodo extends LitElement {
@ -152,6 +155,9 @@ class PanelTodo extends LitElement {
const entityRegistryEntry = this._entityId const entityRegistryEntry = this._entityId
? this.hass.entities[this._entityId] ? this.hass.entities[this._entityId]
: undefined; : undefined;
const entityState = this._entityId
? this.hass.states[this._entityId]
: undefined;
const showPane = this._showPaneController.value ?? !this.narrow; const showPane = this._showPaneController.value ?? !this.narrow;
const listItems = getTodoLists(this.hass).map( const listItems = getTodoLists(this.hass).map(
(list) => (list) =>
@ -187,8 +193,8 @@ class PanelTodo extends LitElement {
<ha-button slot="trigger"> <ha-button slot="trigger">
<div> <div>
${this._entityId ${this._entityId
? this._entityId in this.hass.states ? entityState
? computeStateName(this.hass.states[this._entityId]) ? computeStateName(entityState)
: this._entityId : this._entityId
: ""} : ""}
</div> </div>
@ -255,6 +261,16 @@ class PanelTodo extends LitElement {
<div id="columns"> <div id="columns">
<div class="column">${this._card}</div> <div class="column">${this._card}</div>
</div> </div>
${entityState &&
supportsFeature(entityState, TodoListEntityFeature.CREATE_TODO_ITEM)
? html`<ha-fab
.label=${this.hass.localize("ui.panel.todo.add_item")}
extended
@click=${this._addItem}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>`
: nothing}
</ha-two-pane-top-app-bar-fixed> </ha-two-pane-top-app-bar-fixed>
`; `;
} }
@ -329,6 +345,10 @@ class PanelTodo extends LitElement {
showVoiceCommandDialog(this, this.hass, { pipeline_id: "last_used" }); showVoiceCommandDialog(this, this.hass, { pipeline_id: "last_used" });
} }
private _addItem() {
showTodoItemEditDialog(this, { entity: this._entityId! });
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
@ -390,6 +410,11 @@ class PanelTodo extends LitElement {
white-space: nowrap; white-space: nowrap;
display: block; display: block;
} }
ha-fab {
position: absolute;
right: 16px;
bottom: 16px;
}
`, `,
]; ];
} }

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../common/dom/fire_event";
import { TodoItem } from "../../data/todo";
export interface TodoItemEditDialogParams {
entity: string;
item?: TodoItem;
}
export const loadTodoItemEditDialog = () => import("./dialog-todo-item-editor");
export const showTodoItemEditDialog = (
element: HTMLElement,
detailParams: TodoItemEditDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-todo-item-editor",
dialogImport: loadTodoItemEditDialog,
dialogParams: detailParams,
});
};

View File

@ -756,6 +756,22 @@
"grid": "Grid", "grid": "Grid",
"list": "List" "list": "List"
}, },
"todo": {
"item": {
"summary": "Task name",
"description": "Description",
"add": "Add item",
"delete": "Delete item",
"edit": "Edit item",
"save": "Save item",
"due": "Due date",
"not_all_required_fields": "Not all required fields are filled in",
"confirm_delete": {
"delete": "Delete item",
"prompt": "Do you want to delete this item?"
}
}
},
"calendar": { "calendar": {
"label": "Calendar", "label": "Calendar",
"my_calendars": "My calendars", "my_calendars": "My calendars",
@ -4690,14 +4706,17 @@
"never_triggered": "Never triggered" "never_triggered": "Never triggered"
}, },
"todo-list": { "todo-list": {
"checked_items": "Checked items", "unchecked_items": "Active",
"clear_items": "Clear checked items", "no_unchecked_items": "You have no to-do items!",
"checked_items": "Completed",
"clear_items": "Remove completed items",
"add_item": "Add item", "add_item": "Add item",
"today": "Today",
"reorder_items": "Reorder items", "reorder_items": "Reorder items",
"drag_and_drop": "Drag and drop", "drag_and_drop": "Drag and drop",
"delete_item": "Delete item", "delete_item": "Delete item",
"delete_confirm_title": "Clear checked items?", "delete_confirm_title": "Remove completed items?",
"delete_confirm_text": "{number} {number, plural,\n one {item}\n other {items}\n} will be permanently deleted from the to-do list." "delete_confirm_text": "{number} {number, plural,\n one {item}\n other {items}\n} will be permanently removed from the to-do list."
}, },
"picture-elements": { "picture-elements": {
"hold": "Hold:", "hold": "Hold:",
@ -5758,6 +5777,7 @@
"assist": "[%key:ui::panel::lovelace::menu::assist%]", "assist": "[%key:ui::panel::lovelace::menu::assist%]",
"create_list": "Create list", "create_list": "Create list",
"delete_list": "Delete list", "delete_list": "Delete list",
"add_item": "Add item",
"information": "Information", "information": "Information",
"delete_confirm_title": "Remove {name}?", "delete_confirm_title": "Remove {name}?",
"delete_confirm_text": "Are you sure you want to remove this list and all of its items?", "delete_confirm_text": "Are you sure you want to remove this list and all of its items?",