Render todo items with no state, change look of read only items (#24529)

* Render todo items with no state, change look of read only items

Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
This commit is contained in:
Bram Kragten 2025-04-28 16:06:02 +02:00 committed by GitHub
parent 7c46d2d2f4
commit 66dbafb5f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 229 additions and 90 deletions

View File

@ -2,26 +2,36 @@ import type { TodoItem } from "../../../src/data/todo";
import { TodoItemStatus } from "../../../src/data/todo";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTodo = (hass: MockHomeAssistant) => {
hass.mockWS("todo/item/list", () => ({
items: [
{
uid: "12",
summary: "Milk",
status: TodoItemStatus.NeedsAction,
},
{
uid: "13",
summary: "Eggs",
status: TodoItemStatus.NeedsAction,
},
{
uid: "14",
summary: "Oranges",
status: TodoItemStatus.Completed,
},
] as TodoItem[],
}));
// eslint-disable-next-line @typescript-eslint/no-empty-function
hass.mockWS("todo/item/subscribe", (_msg, _hass) => () => {});
const items = {
items: [
{
uid: "12",
summary: "Milk",
status: TodoItemStatus.NeedsAction,
},
{
uid: "13",
summary: "Eggs",
status: TodoItemStatus.NeedsAction,
},
{
uid: "14",
summary: "Oranges",
status: TodoItemStatus.Completed,
},
{
uid: "15",
summary: "Beer",
},
] as TodoItem[],
};
export const mockTodo = (hass: MockHomeAssistant) => {
hass.mockWS("todo/item/list", () => items);
hass.mockWS("todo/item/move", () => undefined);
hass.mockWS("todo/item/subscribe", (_msg, _hass, onChange) => {
onChange!(items);
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
};

View File

@ -1,11 +1,11 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { getEntity } from "../../../../src/fake_data/entity";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockIcons } from "../../../../demo/src/stubs/icons";
const ENTITIES = [
getEntity("todo", "shopping_list", "2", {

View File

@ -1,17 +1,54 @@
import { css } from "lit";
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 } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const checkboxClasses = {
"mdc-deprecated-list-item__graphic": this.left,
"mdc-deprecated-list-item__meta": !this.left,
};
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<span class=${classMap(checkboxClasses)}>
<ha-checkbox
reducedTouchTarget
tabindex=${this.tabindex}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
>
</ha-checkbox>
</span>
${this.left ? text : ""} ${meta}`;
}
static override styles = [
styles,
controlStyles,

View File

@ -25,7 +25,7 @@ export enum TodoSortMode {
export interface TodoItem {
uid: string;
summary: string;
status: TodoItemStatus;
status: TodoItemStatus | null;
description?: string | null;
due?: string | null;
}
@ -72,7 +72,7 @@ export const fetchItems = async (
export const subscribeItems = (
hass: HomeAssistant,
entity_id: string,
callback: (item) => void
callback: (update: TodoItems) => void
) =>
hass.connection.subscribeMessage<any>(callback, {
type: "todo/item/subscribe",

View File

@ -156,6 +156,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
return items;
}
private _getUncheckedAndItemsWithoutStatus = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items
? this._sortItems(
items.filter(
(item) =>
item.status === TodoItemStatus.NeedsAction || !item.status
),
sort
)
: []
);
private _getCheckedItems = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items
@ -176,6 +189,16 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
: []
);
private _getItemsWithoutStatus = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items
? this._sortItems(
items.filter((item) => !item.status),
sort
)
: []
);
public willUpdate(
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
@ -235,6 +258,18 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
this._config.display_order
);
const itemsWithoutStatus = this._getItemsWithoutStatus(
this._items,
this._config.display_order
);
const reorderableItems = this._reordering
? this._getUncheckedAndItemsWithoutStatus(
this._items,
this._config.display_order
)
: undefined;
return html`
<ha-card
.header=${this._config.title}
@ -274,7 +309,24 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
@item-moved=${this._itemMoved}
>
<ha-list wrapFocus multi>
${uncheckedItems.length
${!uncheckedItems.length && !itemsWithoutStatus.length
? html`<p class="empty">
${this.hass.localize(
"ui.panel.lovelace.cards.todo-list.no_unchecked_items"
)}
</p>`
: this._reordering
? html`<div class="header" role="seperator">
<h2>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.reorder_items"
)}
</h2>
${this._renderMenu(this._config, unavailable)}
</div>
${this._renderItems(reorderableItems ?? [], unavailable)}`
: nothing}
${!this._reordering && uncheckedItems.length
? html`
<div class="header" role="seperator">
<h2>
@ -282,47 +334,35 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
"ui.panel.lovelace.cards.todo-list.unchecked_items"
)}
</h2>
${(!this._config.display_order ||
this._config.display_order === TodoSortMode.NONE) &&
this._todoListSupportsFeature(
TodoListEntityFeature.MOVE_TODO_ITEM
)
? html`<ha-button-menu
@closed=${stopPropagation}
fixed
@action=${this._handlePrimaryMenuAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass!.localize(
this._reordering
? "ui.panel.lovelace.cards.todo-list.exit_reorder_items"
: "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}
${this._renderMenu(this._config, unavailable)}
</div>
${this._renderItems(uncheckedItems, unavailable)}
`
: html`<p class="empty">
${this.hass.localize(
"ui.panel.lovelace.cards.todo-list.no_unchecked_items"
)}
</p>`}
: nothing}
${!this._reordering && itemsWithoutStatus.length
? html`
<div>
${uncheckedItems.length
? html`<div class="divider" role="seperator"></div>`
: nothing}
<div class="header" role="seperator">
<h2>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.no_status_items"
)}
</h2>
${!uncheckedItems.length
? this._renderMenu(this._config, unavailable)
: nothing}
</div>
</div>
${this._renderItems(itemsWithoutStatus, unavailable)}
`
: nothing}
${!this._config.hide_completed && checkedItems.length
? html`
<div role="separator">
<div class="divider"></div>
<div>
<div class="divider" role="separator"></div>
<div class="header">
<h2>
${this.hass!.localize(
@ -359,13 +399,43 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
</div>
${this._renderItems(checkedItems, unavailable)}
`
: ""}
: nothing}
</ha-list>
</ha-sortable>
</ha-card>
`;
}
private _renderMenu(config: TodoListCardConfig, unavailable: boolean) {
return (!config.display_order ||
config.display_order === TodoSortMode.NONE) &&
this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM)
? html`<ha-button-menu
@closed=${stopPropagation}
fixed
@action=${this._handlePrimaryMenuAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass!.localize(
this._reordering
? "ui.panel.lovelace.cards.todo-list.exit_reorder_items"
: "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;
}
private _getDueDate(item: TodoItem): Date | undefined {
return item.due
? item.due.includes("T")
@ -397,16 +467,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
left
.hasMeta=${showReorder || showDelete}
class="editRow ${classMap({
draggable: item.status === TodoItemStatus.NeedsAction,
draggable: item.status !== TodoItemStatus.Completed,
completed: item.status === TodoItemStatus.Completed,
multiline: Boolean(item.description || item.due),
})}"
.selected=${item.status === TodoItemStatus.Completed}
.disabled=${unavailable ||
!this._todoListSupportsFeature(
.disabled=${unavailable}
.checkboxDisabled=${!this._todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)}
.indeterminate=${!item.status}
.noninteractive=${!this._todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)}
item-id=${item.uid}
.itemId=${item.uid}
@change=${this._completeItem}
@click=${this._openItem}
@ -631,35 +704,53 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
this._moveItem(oldIndex, newIndex);
}
private async _moveItem(oldIndex: number, newIndex: number) {
// correct index for header
oldIndex -= 1;
newIndex -= 1;
const uncheckedItems = this._getUncheckedItems(this._items);
const item = uncheckedItems[oldIndex];
let prevItem: TodoItem | undefined;
if (newIndex > 0) {
if (newIndex < oldIndex) {
prevItem = uncheckedItems[newIndex - 1];
} else {
prevItem = uncheckedItems[newIndex];
private _findFirstItem(
items: HTMLCollection,
start: number,
direction: "up" | "down"
) {
let item: Element | undefined;
let index = direction === "up" ? start - 1 : start;
while (item?.localName !== "ha-check-list-item") {
item = items[index];
index = direction === "up" ? index - 1 : index + 1;
if (!item) {
break;
}
}
return item;
}
private async _moveItem(oldIndex: number, newIndex: number) {
await this.updateComplete;
const list = this.renderRoot.querySelector("ha-list")!;
const items = list.children;
const itemId = (items[oldIndex] as any).itemId as string;
const prevItemId = (
this._findFirstItem(
items,
newIndex,
newIndex < oldIndex ? "up" : "down"
) as any
)?.itemId;
// Optimistic change
const itemIndex = this._items!.findIndex((itm) => itm.uid === item.uid);
this._items!.splice(itemIndex, 1);
if (newIndex === 0) {
const itemIndex = this._items!.findIndex((itm) => itm.uid === itemId);
const item = this._items!.splice(itemIndex, 1)[0];
if (!prevItemId) {
this._items!.unshift(item);
} else {
const prevIndex = this._items!.findIndex(
(itm) => itm.uid === prevItem!.uid
);
const prevIndex = this._items!.findIndex((itm) => itm.uid === prevItemId);
this._items!.splice(prevIndex + 1, 0, item);
}
this._items = [...this._items!];
await moveItem(this.hass!, this._entityId!, item.uid, prevItem?.uid);
await moveItem(this.hass!, this._entityId!, itemId, prevItemId);
}
static styles = css`

View File

@ -6529,6 +6529,7 @@
"unchecked_items": "Active",
"no_unchecked_items": "You have no to-do items!",
"checked_items": "Completed",
"no_status_items": "No status",
"clear_items": "Remove completed items",
"add_item": "Add item",
"today": "Today",