Add support for todo component (#18289)

This commit is contained in:
Bram Kragten 2023-10-23 22:53:09 +02:00 committed by GitHub
parent 53b8d1bb0a
commit 2b9540fe03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 670 additions and 327 deletions

View File

@ -3,6 +3,15 @@ import { DemoConfig } from "../types";
export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) => export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
convertEntities({ convertEntities({
"todo.shopping_list": {
entity_id: "todo.shopping_list",
state: "2",
attributes: {
supported_features: 15,
friendly_name: "Shopping List",
icon: "mdi:cart",
},
},
"zone.home": { "zone.home": {
entity_id: "zone.home", entity_id: "zone.home",
state: "zoning", state: "zoning",

View File

@ -3,6 +3,15 @@ import { DemoConfig } from "../types";
export const demoEntitiesJimpower: DemoConfig["entities"] = () => export const demoEntitiesJimpower: DemoConfig["entities"] = () =>
convertEntities({ convertEntities({
"todo.shopping_list": {
entity_id: "todo.shopping_list",
state: "2",
attributes: {
supported_features: 15,
friendly_name: "Shopping List",
icon: "mdi:cart",
},
},
"zone.powertec": { "zone.powertec": {
entity_id: "zone.powertec", entity_id: "zone.powertec",
state: "zoning", state: "zoning",

View File

@ -3,6 +3,15 @@ import { DemoConfig } from "../types";
export const demoEntitiesKernehed: DemoConfig["entities"] = () => export const demoEntitiesKernehed: DemoConfig["entities"] = () =>
convertEntities({ convertEntities({
"todo.shopping_list": {
entity_id: "todo.shopping_list",
state: "2",
attributes: {
supported_features: 15,
friendly_name: "Shopping List",
icon: "mdi:cart",
},
},
"zone.anna": { "zone.anna": {
entity_id: "zone.anna", entity_id: "zone.anna",
state: "zoning", state: "zoning",

View File

@ -3,6 +3,15 @@ import { DemoConfig } from "../types";
export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
convertEntities({ convertEntities({
"todo.shopping_list": {
entity_id: "todo.shopping_list",
state: "2",
attributes: {
supported_features: 15,
friendly_name: "Shopping List",
icon: "mdi:cart",
},
},
"sensor.pollen_grabo": { "sensor.pollen_grabo": {
entity_id: "sensor.pollen_grabo", entity_id: "sensor.pollen_grabo",
state: "", state: "",

View File

@ -22,7 +22,7 @@ import { mockLovelace } from "./stubs/lovelace";
import { mockMediaPlayer } from "./stubs/media_player"; import { mockMediaPlayer } from "./stubs/media_player";
import { mockPersistentNotification } from "./stubs/persistent_notification"; import { mockPersistentNotification } from "./stubs/persistent_notification";
import { mockRecorder } from "./stubs/recorder"; import { mockRecorder } from "./stubs/recorder";
import { mockShoppingList } from "./stubs/shopping_list"; import { mockTodo } from "./stubs/todo";
import { mockSystemLog } from "./stubs/system_log"; import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template"; import { mockTemplate } from "./stubs/template";
import { mockTranslations } from "./stubs/translations"; import { mockTranslations } from "./stubs/translations";
@ -49,7 +49,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockTranslations(hass); mockTranslations(hass);
mockHistory(hass); mockHistory(hass);
mockRecorder(hass); mockRecorder(hass);
mockShoppingList(hass); mockTodo(hass);
mockSystemLog(hass); mockSystemLog(hass);
mockTemplate(hass); mockTemplate(hass);
mockEvents(hass); mockEvents(hass);

View File

@ -1,44 +0,0 @@
import { ShoppingListItem } from "../../../src/data/shopping-list";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let items: ShoppingListItem[] = [
{
id: 12,
name: "Milk",
complete: false,
},
{
id: 13,
name: "Eggs",
complete: false,
},
{
id: 14,
name: "Oranges",
complete: true,
},
];
export const mockShoppingList = (hass: MockHomeAssistant) => {
hass.mockWS("shopping_list/items", () => items);
hass.mockWS("shopping_list/items/add", (msg) => {
const item: ShoppingListItem = {
id: new Date().getTime(),
complete: false,
name: msg.name,
};
items.push(item);
hass.mockEvent("shopping_list_updated");
return item;
});
hass.mockWS("shopping_list/items/update", ({ type, item_id, ...updates }) => {
items = items.map((item) =>
item.id === item_id ? { ...item, ...updates } : item
);
hass.mockEvent("shopping_list_updated");
});
hass.mockWS("shopping_list/items/clear", () => {
items = items.filter((item) => !item.complete);
hass.mockEvent("shopping_list_updated");
});
};

24
demo/src/stubs/todo.ts Normal file
View File

@ -0,0 +1,24 @@
import { TodoItem, TodoItemStatus } from "../../../src/data/todo";
import { 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[],
}));
};

View File

@ -2,12 +2,25 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators"; import { customElement, query } from "lit/decorators";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards"; import "../../components/demo-cards";
import { getEntity } from "../../../../src/fake_data/entity";
import { mockTodo } from "../../../../demo/src/stubs/todo";
const ENTITIES = [
getEntity("todo", "shopping_list", "2", {
friendly_name: "Shopping List",
supported_features: 15,
}),
getEntity("todo", "read_only", "2", {
friendly_name: "Read only",
}),
];
const CONFIGS = [ const CONFIGS = [
{ {
heading: "List example", heading: "List example",
config: ` config: `
- type: shopping-list - type: shopping-list
entity: todo.shopping_list
`, `,
}, },
{ {
@ -15,6 +28,7 @@ const CONFIGS = [
config: ` config: `
- type: shopping-list - type: shopping-list
title: Shopping List title: Shopping List
entity: todo.read_only
`, `,
}, },
]; ];
@ -32,13 +46,9 @@ class DemoShoppingListEntity extends LitElement {
const hass = provideHass(this._demoRoot); const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en"); hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en"); hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
hass.mockAPI("shopping_list", () => [ mockTodo(hass);
{ name: "list", id: 1, complete: false },
{ name: "all", id: 2, complete: false },
{ name: "the", id: 3, complete: false },
{ name: "things", id: 4, complete: true },
]);
} }
} }

View File

@ -16,6 +16,7 @@ import {
mdiCarCoolantLevel, mdiCarCoolantLevel,
mdiCash, mdiCash,
mdiChatSleep, mdiChatSleep,
mdiClipboardCheck,
mdiClock, mdiClock,
mdiCloudUpload, mdiCloudUpload,
mdiCog, mdiCog,
@ -120,6 +121,7 @@ export const FIXED_DOMAIN_ICONS = {
siren: mdiBullhorn, siren: mdiBullhorn,
stt: mdiMicrophoneMessage, stt: mdiMicrophoneMessage,
text: mdiFormTextbox, text: mdiFormTextbox,
todo: mdiClipboardCheck,
time: mdiClock, time: mdiClock,
timer: mdiTimerOutline, timer: mdiTimerOutline,
tts: mdiSpeakerMessage, tts: mdiSpeakerMessage,

View File

@ -2,9 +2,9 @@ import "@material/mwc-button/mwc-button";
import { import {
mdiBell, mdiBell,
mdiCalendar, mdiCalendar,
mdiCart,
mdiCellphoneCog, mdiCellphoneCog,
mdiChartBox, mdiChartBox,
mdiClipboardList,
mdiClose, mdiClose,
mdiCog, mdiCog,
mdiFormatListBulletedType, mdiFormatListBulletedType,
@ -81,7 +81,7 @@ const PANEL_ICONS = {
lovelace: mdiViewDashboard, lovelace: mdiViewDashboard,
map: mdiTooltipAccount, map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple, "media-browser": mdiPlayBoxMultiple,
"shopping-list": mdiCart, todo: mdiClipboardList,
}; };
const panelSorter = ( const panelSorter = (

View File

@ -1,58 +0,0 @@
import { HomeAssistant } from "../types";
export interface ShoppingListItem {
id: number;
name: string;
complete: boolean;
}
export const fetchItems = (hass: HomeAssistant): Promise<ShoppingListItem[]> =>
hass.callWS({
type: "shopping_list/items",
});
export const updateItem = (
hass: HomeAssistant,
itemId: number,
item: {
name?: string;
complete?: boolean;
}
): Promise<ShoppingListItem> =>
hass.callWS({
type: "shopping_list/items/update",
item_id: itemId,
...item,
});
export const clearItems = (hass: HomeAssistant): Promise<void> =>
hass.callWS({
type: "shopping_list/items/clear",
});
export const addItem = (
hass: HomeAssistant,
name: string
): Promise<ShoppingListItem> =>
hass.callWS({
type: "shopping_list/items/add",
name,
});
export const removeItem = (
hass: HomeAssistant,
item_id: string
): Promise<ShoppingListItem> =>
hass.callWS({
type: "shopping_list/items/remove",
item_id,
});
export const reorderItems = (
hass: HomeAssistant,
itemIds: string[]
): Promise<ShoppingListItem> =>
hass.callWS({
type: "shopping_list/items/reorder",
item_ids: itemIds,
});

104
src/data/todo.ts Normal file
View File

@ -0,0 +1,104 @@
import { HomeAssistant, ServiceCallResponse } from "../types";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { isUnavailableState } from "./entity";
export interface TodoList {
entity_id: string;
name: string;
}
export const enum TodoItemStatus {
NeedsAction = "needs-action",
Completed = "completed",
}
export interface TodoItem {
uid?: string;
summary: string;
status: TodoItemStatus;
}
export const enum TodoListEntityFeature {
CREATE_TODO_ITEM = 1,
DELETE_TODO_ITEM = 2,
UPDATE_TODO_ITEM = 4,
MOVE_TODO_ITEM = 8,
}
export const getTodoLists = (hass: HomeAssistant): TodoList[] =>
Object.keys(hass.states)
.filter(
(entityId) =>
computeDomain(entityId) === "todo" &&
!isUnavailableState(hass.states[entityId].state)
)
.sort()
.map((entityId) => ({
...hass.states[entityId],
entity_id: entityId,
name: computeStateName(hass.states[entityId]),
}));
export interface TodoItems {
items: TodoItem[];
}
export const fetchItems = async (
hass: HomeAssistant,
entityId: string
): Promise<TodoItem[]> => {
const result = await hass.callWS<TodoItems>({
type: "todo/item/list",
entity_id: entityId,
});
return result.items;
};
export const updateItem = (
hass: HomeAssistant,
entity_id: string,
item: TodoItem
): Promise<ServiceCallResponse> =>
hass.callService("todo", "update_item", item, { entity_id });
export const createItem = (
hass: HomeAssistant,
entity_id: string,
summary: string
): Promise<ServiceCallResponse> =>
hass.callService(
"todo",
"create_item",
{
summary,
},
{ entity_id }
);
export const deleteItem = (
hass: HomeAssistant,
entity_id: string,
uid: string
): Promise<ServiceCallResponse> =>
hass.callService(
"todo",
"delete_item",
{
uid,
},
{ entity_id }
);
export const moveItem = (
hass: HomeAssistant,
entity_id: string,
uid: string,
pos: number
): Promise<void> =>
hass.callWS({
type: "todo/item/move",
entity_id,
uid,
pos,
});

View File

@ -14,13 +14,7 @@ export const demoConfig: HassConfig = {
wind_speed: "m/s", wind_speed: "m/s",
accumulated_precipitation: "mm", accumulated_precipitation: "mm",
}, },
components: [ components: ["notify.html5", "history", "todo", "forecast_solar", "energy"],
"notify.html5",
"history",
"shopping_list",
"forecast_solar",
"energy",
],
time_zone: "America/Los_Angeles", time_zone: "America/Los_Angeles",
config_dir: "/config", config_dir: "/config",
version: "DEMO", version: "DEMO",

View File

@ -34,8 +34,7 @@ const COMPONENTS = {
map: () => import("../panels/map/ha-panel-map"), map: () => import("../panels/map/ha-panel-map"),
my: () => import("../panels/my/ha-panel-my"), my: () => import("../panels/my/ha-panel-my"),
profile: () => import("../panels/profile/ha-panel-profile"), profile: () => import("../panels/profile/ha-panel-profile"),
"shopping-list": () => todo: () => import("../panels/todo/ha-panel-todo"),
import("../panels/shopping-list/ha-panel-shopping-list"),
"media-browser": () => "media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"), import("../panels/media-browser/ha-panel-media-browser"),
}; };

View File

@ -1,11 +1,12 @@
import { mdiDrag, mdiNotificationClearAll, mdiPlus, mdiSort } from "@mdi/js"; import { mdiDrag, mdiNotificationClearAll, mdiPlus, mdiSort } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css,
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValueMap,
PropertyValues, PropertyValues,
css,
html,
nothing, nothing,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
@ -13,25 +14,31 @@ import { classMap } from "lit/directives/class-map";
import { guard } from "lit/directives/guard"; import { guard } from "lit/directives/guard";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
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 "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-checkbox"; import "../../../components/ha-checkbox";
import "../../../components/ha-list-item";
import "../../../components/ha-select";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield";
import { import {
addItem, TodoItem,
clearItems, TodoItemStatus,
TodoListEntityFeature,
createItem,
deleteItem,
fetchItems, fetchItems,
removeItem, getTodoLists,
reorderItems, moveItem,
ShoppingListItem,
updateItem, updateItem,
} from "../../../data/shopping-list"; } from "../../../data/todo";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { SortableInstance } from "../../../resources/sortable"; import type { SortableInstance } from "../../../resources/sortable";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { SensorCardConfig, ShoppingListCardConfig } from "./types"; import { ShoppingListCardConfig } from "./types";
@customElement("hui-shopping-list-card") @customElement("hui-shopping-list-card")
class HuiShoppingListCard class HuiShoppingListCard
@ -43,17 +50,35 @@ class HuiShoppingListCard
return document.createElement("hui-shopping-list-card-editor"); return document.createElement("hui-shopping-list-card-editor");
} }
public static getStubConfig(): ShoppingListCardConfig { public static getStubConfig(
return { type: "shopping-list" }; hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
): ShoppingListCardConfig {
const includeDomains = ["todo"];
const maxEntities = 1;
const foundEntities = findEntities(
hass,
maxEntities,
entities,
entitiesFallback,
includeDomains
);
return { type: "shopping-list", entity: foundEntities[0] || "" };
} }
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: ShoppingListCardConfig; @state() private _config?: ShoppingListCardConfig;
@state() private _uncheckedItems?: ShoppingListItem[]; @state() private _entityId?: string;
@state() private _checkedItems?: ShoppingListItem[]; @state() private _items: Record<string, TodoItem> = {};
@state() private _uncheckedItems?: TodoItem[];
@state() private _checkedItems?: TodoItem[];
@state() private _reordering = false; @state() private _reordering = false;
@ -71,10 +96,39 @@ class HuiShoppingListCard
this._config = config; this._config = config;
this._uncheckedItems = []; this._uncheckedItems = [];
this._checkedItems = []; this._checkedItems = [];
if (this._config!.entity) {
this._entityId = this._config!.entity;
}
}
public willUpdate(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
if (!this.hasUpdated) {
if (!this._entityId) {
const todoLists = getTodoLists(this.hass!);
if (todoLists.length) {
if (todoLists.length > 1) {
// find first entity provided by "shopping_list"
for (const list of todoLists) {
const entityReg = this.hass?.entities[list.entity_id];
if (entityReg?.platform === "shopping_list") {
this._entityId = list.entity_id;
break;
}
}
}
if (!this._entityId) {
this._entityId = todoLists[0].entity_id;
}
}
}
this._fetchData();
}
} }
public hassSubscribe(): Promise<UnsubscribeFunc>[] { public hassSubscribe(): Promise<UnsubscribeFunc>[] {
this._fetchData();
return [ return [
this.hass!.connection.subscribeEvents( this.hass!.connection.subscribeEvents(
() => this._fetchData(), () => this._fetchData(),
@ -91,7 +145,7 @@ class HuiShoppingListCard
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldConfig = changedProps.get("_config") as const oldConfig = changedProps.get("_config") as
| SensorCardConfig | ShoppingListCardConfig
| undefined; | undefined;
if ( if (
@ -103,7 +157,7 @@ class HuiShoppingListCard
} }
protected render() { protected render() {
if (!this._config || !this.hass) { if (!this._config || !this.hass || !this._entityId) {
return nothing; return nothing;
} }
@ -115,31 +169,39 @@ class HuiShoppingListCard
})} })}
> >
<div class="addRow"> <div class="addRow">
<ha-svg-icon ${this.todoListSupportsFeature(TodoListEntityFeature.CREATE_TODO_ITEM)
class="addButton" ? html`
.path=${mdiPlus} <ha-svg-icon
.title=${this.hass!.localize( class="addButton"
"ui.panel.lovelace.cards.shopping-list.add_item" .path=${mdiPlus}
)} .title=${this.hass!.localize(
@click=${this._addItem} "ui.panel.lovelace.cards.shopping-list.add_item"
> )}
</ha-svg-icon> @click=${this._addItem}
<ha-textfield >
class="addBox" </ha-svg-icon>
.placeholder=${this.hass!.localize( <ha-textfield
"ui.panel.lovelace.cards.shopping-list.add_item" class="addBox"
)} .placeholder=${this.hass!.localize(
@keydown=${this._addKeyPress} "ui.panel.lovelace.cards.shopping-list.add_item"
></ha-textfield> )}
<ha-svg-icon @keydown=${this._addKeyPress}
class="reorderButton" ></ha-textfield>
.path=${mdiSort} `
.title=${this.hass!.localize( : nothing}
"ui.panel.lovelace.cards.shopping-list.reorder_items" ${this.todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM)
)} ? html`
@click=${this._toggleReorder} <ha-svg-icon
> class="reorderButton"
</ha-svg-icon> .path=${mdiSort}
.title=${this.hass!.localize(
"ui.panel.lovelace.cards.shopping-list.reorder_items"
)}
@click=${this._toggleReorder}
>
</ha-svg-icon>
`
: nothing}
</div> </div>
${this._reordering ${this._reordering
? html` ? html`
@ -163,32 +225,43 @@ class HuiShoppingListCard
"ui.panel.lovelace.cards.shopping-list.checked_items" "ui.panel.lovelace.cards.shopping-list.checked_items"
)} )}
</span> </span>
<ha-svg-icon ${this.todoListSupportsFeature(
class="clearall" TodoListEntityFeature.DELETE_TODO_ITEM
tabindex="0" )
.path=${mdiNotificationClearAll} ? html` <ha-svg-icon
.title=${this.hass!.localize( class="clearall"
"ui.panel.lovelace.cards.shopping-list.clear_items" tabindex="0"
)} .path=${mdiNotificationClearAll}
@click=${this._clearItems} .title=${this.hass!.localize(
> "ui.panel.lovelace.cards.shopping-list.clear_items"
</ha-svg-icon> )}
@click=${this._clearCompletedItems}
>
</ha-svg-icon>`
: nothing}
</div> </div>
${repeat( ${repeat(
this._checkedItems!, this._checkedItems!,
(item) => item.id, (item) => item.uid,
(item) => html` (item) => html`
<div class="editRow"> <div class="editRow">
<ha-checkbox ${this.todoListSupportsFeature(
tabindex="0" TodoListEntityFeature.UPDATE_TODO_ITEM
.checked=${item.complete} )
.itemId=${item.id} ? html` <ha-checkbox
@change=${this._completeItem} tabindex="0"
></ha-checkbox> .checked=${item.status === TodoItemStatus.Completed}
.itemId=${item.uid}
@change=${this._completeItem}
></ha-checkbox>`
: nothing}
<ha-textfield <ha-textfield
class="item" class="item"
.value=${item.name} .disabled=${!this.todoListSupportsFeature(
.itemId=${item.id} TodoListEntityFeature.UPDATE_TODO_ITEM
)}
.value=${item.summary}
.itemId=${item.uid}
@change=${this._saveEdit} @change=${this._saveEdit}
></ha-textfield> ></ha-textfield>
</div> </div>
@ -200,23 +273,30 @@ class HuiShoppingListCard
`; `;
} }
private _renderItems(items: ShoppingListItem[]) { private _renderItems(items: TodoItem[]) {
return html` return html`
${repeat( ${repeat(
items, items,
(item) => item.id, (item) => item.uid,
(item) => html` (item) => html`
<div class="editRow" item-id=${item.id}> <div class="editRow" item-id=${item.uid}>
<ha-checkbox ${this.todoListSupportsFeature(
tabindex="0" TodoListEntityFeature.UPDATE_TODO_ITEM
.checked=${item.complete} )
.itemId=${item.id} ? html` <ha-checkbox
@change=${this._completeItem} tabindex="0"
></ha-checkbox> .checked=${item.status === TodoItemStatus.Completed}
.itemId=${item.uid}
@change=${this._completeItem}
></ha-checkbox>`
: nothing}
<ha-textfield <ha-textfield
class="item" class="item"
.value=${item.name} .disabled=${!this.todoListSupportsFeature(
.itemId=${item.id} TodoListEntityFeature.UPDATE_TODO_ITEM
)}
.value=${item.summary}
.itemId=${item.uid}
@change=${this._saveEdit} @change=${this._saveEdit}
></ha-textfield> ></ha-textfield>
${this._reordering ${this._reordering
@ -237,47 +317,70 @@ class HuiShoppingListCard
`; `;
} }
private todoListSupportsFeature(feature: number): boolean {
const entityStateObj = this.hass!.states[this._entityId!];
return entityStateObj && supportsFeature(entityStateObj, feature);
}
private async _fetchData(): Promise<void> { private async _fetchData(): Promise<void> {
if (!this.hass) { if (!this.hass || !this._entityId) {
return; return;
} }
const checkedItems: ShoppingListItem[] = []; const checkedItems: TodoItem[] = [];
const uncheckedItems: ShoppingListItem[] = []; const uncheckedItems: TodoItem[] = [];
const items = await fetchItems(this.hass); const items = await fetchItems(this.hass!, this._entityId!);
for (const key in items) { const records: Record<string, TodoItem> = {};
if (items[key].complete) { items.forEach((item) => {
checkedItems.push(items[key]); records[item.uid!] = item;
if (item.status === TodoItemStatus.Completed) {
checkedItems.push(item);
} else { } else {
uncheckedItems.push(items[key]); uncheckedItems.push(item);
} }
} });
this._items = records;
this._checkedItems = checkedItems; this._checkedItems = checkedItems;
this._uncheckedItems = uncheckedItems; this._uncheckedItems = uncheckedItems;
} }
private _completeItem(ev): void { private _completeItem(ev): void {
updateItem(this.hass!, ev.target.itemId, { const item = this._items[ev.target.itemId];
complete: ev.target.checked, updateItem(this.hass!, this._entityId!, {
}).catch(() => this._fetchData()); ...item,
status: ev.target.checked
? TodoItemStatus.Completed
: TodoItemStatus.NeedsAction,
}).finally(() => this._fetchData());
} }
private _saveEdit(ev): void { private _saveEdit(ev): void {
// If name is not empty, update the item otherwise remove it // If name is not empty, update the item otherwise remove it
if (ev.target.value) { if (ev.target.value) {
updateItem(this.hass!, ev.target.itemId, { const item = this._items[ev.target.itemId];
name: ev.target.value, updateItem(this.hass!, this._entityId!, {
}).catch(() => this._fetchData()); ...item,
} else { summary: ev.target.value,
removeItem(this.hass!, ev.target.itemId).catch(() => this._fetchData()); }).finally(() => this._fetchData());
} else if (
this.todoListSupportsFeature(TodoListEntityFeature.DELETE_TODO_ITEM)
) {
deleteItem(this.hass!, this._entityId!, ev.target.itemId).finally(() =>
this._fetchData()
);
} }
ev.target.blur(); ev.target.blur();
} }
private _clearItems(): void { private async _clearCompletedItems(): Promise<void> {
if (this.hass) { if (!this.hass) {
clearItems(this.hass).catch(() => this._fetchData()); return;
} }
const deleteActions: Array<Promise<any>> = [];
this._checkedItems!.forEach((item: TodoItem) => {
deleteActions.push(deleteItem(this.hass!, this._entityId!, item.uid!));
});
await Promise.all(deleteActions).finally(() => this._fetchData());
} }
private get _newItem(): HaTextField { private get _newItem(): HaTextField {
@ -286,9 +389,10 @@ class HuiShoppingListCard
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) {
addItem(this.hass!, newItem.value!).catch(() => this._fetchData()); createItem(this.hass!, this._entityId!, newItem.value!).finally(() =>
this._fetchData()
);
} }
newItem.value = ""; newItem.value = "";
@ -329,9 +433,13 @@ class HuiShoppingListCard
// Since this is `onEnd` event, it's possible that // Since this is `onEnd` event, it's possible that
// an item wa dragged away and was put back to its original position. // an item wa dragged away and was put back to its original position.
if (evt.oldIndex !== evt.newIndex) { if (evt.oldIndex !== evt.newIndex) {
reorderItems(this.hass!, this._sortable!.toArray()).catch(() => const item = this._uncheckedItems![evt.oldIndex];
this._fetchData() moveItem(
); this.hass!,
this._entityId!,
item.uid!,
evt.newIndex
).finally(() => this._fetchData());
// Move the shopping list item in memory. // Move the shopping list item in memory.
this._uncheckedItems!.splice( this._uncheckedItems!.splice(
evt.newIndex, evt.newIndex,
@ -416,6 +524,11 @@ class HuiShoppingListCard
.clearall { .clearall {
cursor: pointer; cursor: pointer;
} }
.todoList {
display: block;
padding: 8px;
}
`; `;
} }
} }

View File

@ -430,6 +430,7 @@ export interface SensorCardConfig extends LovelaceCardConfig {
export interface ShoppingListCardConfig extends LovelaceCardConfig { export interface ShoppingListCardConfig extends LovelaceCardConfig {
title?: string; title?: string;
theme?: string; theme?: string;
entity?: string;
} }
export interface StackCardConfig extends LovelaceCardConfig { export interface StackCardConfig extends LovelaceCardConfig {

View File

@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct"; import { assert, assign, object, optional, string } from "superstruct";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-textfield"; import "../../../../components/ha-textfield";
import "../../../../components/ha-theme-picker"; import "../../../../components/ha-theme-picker";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
@ -16,6 +17,7 @@ const cardConfigStruct = assign(
object({ object({
title: optional(string()), title: optional(string()),
theme: optional(string()), theme: optional(string()),
entity: optional(string()),
}) })
); );
@ -48,7 +50,7 @@ export class HuiShoppingListEditor
return html` return html`
<div class="card-config"> <div class="card-config">
${!isComponentLoaded(this.hass, "shopping_list") ${!isComponentLoaded(this.hass, "todo")
? html` ? html`
<div class="error"> <div class="error">
${this.hass.localize( ${this.hass.localize(
@ -67,6 +69,14 @@ export class HuiShoppingListEditor
.configValue=${"title"} .configValue=${"title"}
@input=${this._valueChanged} @input=${this._valueChanged}
></ha-textfield> ></ha-textfield>
<ha-entity-picker
.hass=${this.hass!}
.configValue=${"entity"}
.value=${this._config.entity}
.includeDomains=${["todo"]}
@value-changed=${this._valueChanged}
>
</ha-entity-picker>
<ha-theme-picker <ha-theme-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this._theme} .value=${this._theme}

View File

@ -1,106 +0,0 @@
import { mdiMicrophone } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import "../../components/ha-icon-button";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { HuiErrorCard } from "../lovelace/cards/hui-error-card";
import { createCardElement } from "../lovelace/create-element/create-card-element";
import { LovelaceCard } from "../lovelace/types";
@customElement("ha-panel-shopping-list")
class PanelShoppingList extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@state() private _card!: LovelaceCard | HuiErrorCard;
private _conversation = memoizeOne((_components) =>
isComponentLoaded(this.hass, "conversation")
);
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this._card = createCardElement({ type: "shopping-list" }) as LovelaceCard;
this._card.hass = this.hass;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("hass")) {
this._card.hass = this.hass;
}
}
protected render(): TemplateResult {
return html`
<ha-top-app-bar-fixed>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div slot="title">${this.hass.localize("panel.shopping_list")}</div>
${this._conversation(this.hass.config.components)
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass!.localize(
"ui.panel.shopping_list.start_conversation"
)}
.path=${mdiMicrophone}
@click=${this._showVoiceCommandDialog}
></ha-icon-button>
`
: ""}
<div id="columns">
<div class="column">${this._card}</div>
</div>
</ha-top-app-bar-fixed>
`;
}
private _showVoiceCommandDialog(): void {
showVoiceCommandDialog(this, this.hass, { pipeline_id: "last_used" });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
#columns {
display: flex;
flex-direction: row;
justify-content: center;
margin: 8px;
}
.column {
flex: 1 0 0;
max-width: 500px;
min-width: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-shopping-list": PanelShoppingList;
}
}

View File

@ -0,0 +1,257 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import "@material/mwc-list";
import { mdiChevronDown, mdiDotsVertical, mdiMicrophone } from "@mdi/js";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { storage } from "../../common/decorators/storage";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
import { getTodoLists } from "../../data/todo";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { HuiErrorCard } from "../lovelace/cards/hui-error-card";
import { createCardElement } from "../lovelace/create-element/create-card-element";
import { LovelaceCard } from "../lovelace/types";
@customElement("ha-panel-todo")
class PanelTodo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property({ type: Boolean, reflect: true }) public mobile = false;
@state() private _card?: LovelaceCard | HuiErrorCard;
@storage({
key: "selectedTodoEntity",
state: true,
})
private _entityId?: string;
private _headerHeight = 56;
private _showPaneController = new ResizeController(this, {
callback: (entries: ResizeObserverEntry[]) =>
entries[0]?.contentRect.width > 750,
});
private _mql?: MediaQueryList;
private _conversation = memoizeOne((_components) =>
isComponentLoaded(this.hass, "conversation")
);
public connectedCallback() {
super.connectedCallback();
this._mql = window.matchMedia(
"(max-width: 450px), all and (max-height: 500px)"
);
this._mql.addListener(this._setIsMobile);
this.mobile = this._mql.matches;
const computedStyles = getComputedStyle(this);
this._headerHeight = Number(
computedStyles.getPropertyValue("--header-height").replace("px", "")
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
}
private _setIsMobile = (ev: MediaQueryListEvent) => {
this.mobile = ev.matches;
};
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (!this.hasUpdated && !this._entityId) {
this._entityId = Object.keys(this.hass.states).find(
(entityId) => computeDomain(entityId) === "todo"
);
} else if (!this.hasUpdated) {
this._createCard();
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("_entityId")) {
this._createCard();
}
if (changedProperties.has("hass") && this._card) {
this._card.hass = this.hass;
}
}
private _createCard(): void {
if (!this._entityId) {
this._card = undefined;
return;
}
this._card = createCardElement({
type: "shopping-list",
entity: this._entityId,
}) as LovelaceCard;
this._card.hass = this.hass;
}
protected render(): TemplateResult {
const showPane = this._showPaneController.value ?? !this.narrow;
const listItems = getTodoLists(this.hass).map(
(list) =>
html`<ha-list-item
graphic="icon"
@click=${this._handleEntityPicked}
.entityId=${list.entity_id}
.activated=${list.entity_id === this._entityId}
>
<ha-state-icon .state=${list} slot="graphic"></ha-state-icon
>${list.name}
</ha-list-item> `
);
return html`
<ha-two-pane-top-app-bar-fixed .pane=${showPane}>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div slot="title">
${!showPane
? html`<ha-button-menu
class="lists"
activatable
fixed
.noAnchor=${this.mobile}
.y=${this.mobile
? this._headerHeight / 2
: this._headerHeight / 4}
.x=${this.mobile ? 0 : undefined}
>
<ha-button slot="trigger">
${this._entityId
? computeStateName(this.hass.states[this._entityId])
: ""}
<ha-svg-icon
slot="trailingIcon"
.path=${mdiChevronDown}
></ha-svg-icon>
</ha-button>
${listItems}
<li divider role="separator"></li>
</ha-button-menu>`
: "Lists"}
</div>
<mwc-list slot="pane" activatable>${listItems}</mwc-list>
<ha-button-menu slot="actionItems">
<ha-icon-button
slot="trigger"
.label=${""}
.path=${mdiDotsVertical}
></ha-icon-button>
${this._conversation(this.hass.config.components)
? html`<ha-list-item
graphic="icon"
@click=${this._showVoiceCommandDialog}
>
<ha-svg-icon .path=${mdiMicrophone} slot="graphic">
</ha-svg-icon>
${this.hass.localize("ui.panel.todo.start_conversation")}
</ha-list-item>`
: nothing}
</ha-button-menu>
<div id="columns">
<div class="column">${this._card}</div>
</div>
</ha-two-pane-top-app-bar-fixed>
`;
}
private _handleEntityPicked(ev) {
this._entityId = ev.currentTarget.entityId;
}
private _showVoiceCommandDialog(): void {
showVoiceCommandDialog(this, this.hass, { pipeline_id: "last_used" });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
}
#columns {
display: flex;
flex-direction: row;
justify-content: center;
margin: 8px;
}
.column {
flex: 1 0 0;
max-width: 500px;
min-width: 0;
}
:host([mobile]) .lists {
--mdc-menu-min-width: 100vw;
}
:host([mobile]) ha-button-menu {
--mdc-shape-medium: 0 0 var(--mdc-shape-medium)
var(--mdc-shape-medium);
}
ha-button-menu ha-button {
--mdc-theme-primary: currentColor;
--mdc-typography-button-text-transform: none;
--mdc-typography-button-font-size: var(
--mdc-typography-headline6-font-size,
1.25rem
);
--mdc-typography-button-font-weight: var(
--mdc-typography-headline6-font-weight,
500
);
--mdc-typography-button-letter-spacing: var(
--mdc-typography-headline6-letter-spacing,
0.0125em
);
--mdc-typography-button-line-height: var(
--mdc-typography-headline6-line-height,
2rem
);
--button-height: 40px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-todo": PanelTodo;
}
}

View File

@ -8,7 +8,7 @@
"logbook": "Logbook", "logbook": "Logbook",
"history": "History", "history": "History",
"mailbox": "Mailbox", "mailbox": "Mailbox",
"shopping_list": "Shopping list", "todo": "To-do Lists",
"developer_tools": "Developer tools", "developer_tools": "Developer tools",
"media_browser": "Media", "media_browser": "Media",
"profile": "Profile" "profile": "Profile"
@ -4471,6 +4471,7 @@
"never_triggered": "Never triggered" "never_triggered": "Never triggered"
}, },
"shopping-list": { "shopping-list": {
"lists": "To-do Lists",
"checked_items": "Checked items", "checked_items": "Checked items",
"clear_items": "Clear checked items", "clear_items": "Clear checked items",
"add_item": "Add item", "add_item": "Add item",
@ -5046,7 +5047,7 @@
"shopping-list": { "shopping-list": {
"name": "Shopping list", "name": "Shopping list",
"description": "The Shopping list card allows you to add, edit, check-off, and clear items from your shopping list.", "description": "The Shopping list card allows you to add, edit, check-off, and clear items from your shopping list.",
"integration_not_loaded": "This card requires the `shopping_list` integration to be set up." "integration_not_loaded": "This card requires the `todo` integration to be set up."
}, },
"thermostat": { "thermostat": {
"name": "Thermostat", "name": "Thermostat",
@ -5458,7 +5459,7 @@
"qr_code_image": "QR code for token {name}" "qr_code_image": "QR code for token {name}"
} }
}, },
"shopping_list": { "todo": {
"start_conversation": "Start conversation" "start_conversation": "Start conversation"
}, },
"page-authorize": { "page-authorize": {