From 38607a641019bf8ddef25c8ea4e816a7affb6413 Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Fri, 12 Aug 2022 08:58:08 -0500 Subject: [PATCH] Add UI for Schedule Helper (#13375) --- package.json | 1 + src/common/datetime/format_time.ts | 15 +- src/data/helpers_crud.ts | 22 +- src/data/schedule.ts | 61 +++ src/panels/config/entities/const.ts | 1 + .../settings/entity-settings-helper-tab.ts | 3 +- src/panels/config/helpers/const.ts | 5 +- .../config/helpers/dialog-helper-detail.ts | 3 + .../config/helpers/forms/ha-schedule-form.ts | 379 ++++++++++++++++++ src/translations/en.json | 3 +- yarn.lock | 14 +- 11 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 src/data/schedule.ts create mode 100644 src/panels/config/helpers/forms/ha-schedule-form.ts diff --git a/package.json b/package.json index 3ee9e6a068..c2a91ffd2e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@fullcalendar/daygrid": "5.9.0", "@fullcalendar/interaction": "5.9.0", "@fullcalendar/list": "5.9.0", + "@fullcalendar/timegrid": "5.9.0", "@lit-labs/motion": "^1.0.2", "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch", "@material/chips": "14.0.0-canary.261f2db59.0", diff --git a/src/common/datetime/format_time.ts b/src/common/datetime/format_time.ts index c49afc7f56..082e7cb4da 100644 --- a/src/common/datetime/format_time.ts +++ b/src/common/datetime/format_time.ts @@ -1,7 +1,7 @@ import memoizeOne from "memoize-one"; import { FrontendLocaleData } from "../../data/translation"; -import { useAmPm } from "./use_am_pm"; import { polyfillsLoaded } from "../translations/localize"; +import { useAmPm } from "./use_am_pm"; if (__BUILD__ === "latest" && polyfillsLoaded) { await polyfillsLoaded; @@ -64,3 +64,16 @@ const formatTimeWeekdayMem = memoizeOne( } ) ); + +// 21:15 +export const formatTime24h = (dateObj: Date) => + formatTime24hMem().format(dateObj); + +const formatTime24hMem = memoizeOne( + () => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: false, + }) +); diff --git a/src/data/helpers_crud.ts b/src/data/helpers_crud.ts index c03ce664ac..47010e5ef4 100644 --- a/src/data/helpers_crud.ts +++ b/src/data/helpers_crud.ts @@ -1,31 +1,32 @@ -import { fetchCounter, updateCounter, deleteCounter } from "./counter"; +import { deleteCounter, fetchCounter, updateCounter } from "./counter"; import { + deleteInputBoolean, fetchInputBoolean, updateInputBoolean, - deleteInputBoolean, } from "./input_boolean"; import { + deleteInputButton, fetchInputButton, updateInputButton, - deleteInputButton, } from "./input_button"; import { + deleteInputDateTime, fetchInputDateTime, updateInputDateTime, - deleteInputDateTime, } from "./input_datetime"; import { + deleteInputNumber, fetchInputNumber, updateInputNumber, - deleteInputNumber, } from "./input_number"; import { + deleteInputSelect, fetchInputSelect, updateInputSelect, - deleteInputSelect, } from "./input_select"; -import { fetchInputText, updateInputText, deleteInputText } from "./input_text"; -import { fetchTimer, updateTimer, deleteTimer } from "./timer"; +import { deleteInputText, fetchInputText, updateInputText } from "./input_text"; +import { deleteSchedule, fetchSchedule, updateSchedule } from "./schedule"; +import { deleteTimer, fetchTimer, updateTimer } from "./timer"; export const HELPERS_CRUD = { input_boolean: { @@ -68,4 +69,9 @@ export const HELPERS_CRUD = { update: updateTimer, delete: deleteTimer, }, + schedule: { + fetch: fetchSchedule, + update: updateSchedule, + delete: deleteSchedule, + }, }; diff --git a/src/data/schedule.ts b/src/data/schedule.ts new file mode 100644 index 0000000000..c073795650 --- /dev/null +++ b/src/data/schedule.ts @@ -0,0 +1,61 @@ +import { HomeAssistant } from "../types"; + +export const weekdays = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +export interface ScheduleDay { + from: string; + to: string; +} + +type ScheduleDays = { [K in typeof weekdays[number]]?: ScheduleDay[] }; + +export interface Schedule extends ScheduleDays { + id: string; + name: string; + icon?: string; +} + +export interface ScheduleMutableParams { + name: string; + icon: string; +} + +export const fetchSchedule = (hass: HomeAssistant) => + hass.callWS({ type: "schedule/list" }); + +export const createSchedule = ( + hass: HomeAssistant, + values: ScheduleMutableParams +) => + hass.callWS({ + type: "schedule/create", + ...values, + }); + +export const updateSchedule = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "schedule/update", + schedule_id: id, + ...updates, + }); + +export const deleteSchedule = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "schedule/delete", + schedule_id: id, + }); + +export const getScheduleTime = (date: Date): string => + `${("0" + date.getHours()).slice(-2)}:${("0" + date.getMinutes()).slice(-2)}`; diff --git a/src/panels/config/entities/const.ts b/src/panels/config/entities/const.ts index 11d2b2622b..f1979111d3 100644 --- a/src/panels/config/entities/const.ts +++ b/src/panels/config/entities/const.ts @@ -8,4 +8,5 @@ export const PLATFORMS_WITH_SETTINGS_TAB = { counter: "entity-settings-helper-tab", timer: "entity-settings-helper-tab", input_button: "entity-settings-helper-tab", + schedule: "entity-settings-helper-tab", }; diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index 210d931a6c..da462f9ca9 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -6,7 +6,7 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { customElement, property, state, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../../common/dom/fire_event"; @@ -26,6 +26,7 @@ import "../../../helpers/forms/ha-input_datetime-form"; import "../../../helpers/forms/ha-input_number-form"; import "../../../helpers/forms/ha-input_select-form"; import "../../../helpers/forms/ha-input_text-form"; +import "../../../helpers/forms/ha-schedule-form"; import "../../../helpers/forms/ha-timer-form"; import "../../entity-registry-basic-editor"; import type { HaEntityRegistryBasicEditor } from "../../entity-registry-basic-editor"; diff --git a/src/panels/config/helpers/const.ts b/src/panels/config/helpers/const.ts index c103332573..87b03eff10 100644 --- a/src/panels/config/helpers/const.ts +++ b/src/panels/config/helpers/const.ts @@ -5,6 +5,7 @@ import type { InputDateTime } from "../../../data/input_datetime"; import type { InputNumber } from "../../../data/input_number"; import type { InputSelect } from "../../../data/input_select"; import type { InputText } from "../../../data/input_text"; +import type { Schedule } from "../../../data/schedule"; import type { Timer } from "../../../data/timer"; export const HELPER_DOMAINS = [ @@ -16,6 +17,7 @@ export const HELPER_DOMAINS = [ "input_select", "counter", "timer", + "schedule", ]; export type Helper = @@ -26,4 +28,5 @@ export type Helper = | InputSelect | InputDateTime | Counter - | Timer; + | Timer + | Schedule; diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index 65daced845..7f4b0a488a 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -19,6 +19,7 @@ import { createInputNumber } from "../../../data/input_number"; import { createInputSelect } from "../../../data/input_select"; import { createInputText } from "../../../data/input_text"; import { domainToName } from "../../../data/integration"; +import { createSchedule } from "../../../data/schedule"; import { createTimer } from "../../../data/timer"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { haStyleDialog } from "../../../resources/styles"; @@ -32,6 +33,7 @@ import "./forms/ha-input_datetime-form"; import "./forms/ha-input_number-form"; import "./forms/ha-input_select-form"; import "./forms/ha-input_text-form"; +import "./forms/ha-schedule-form"; import "./forms/ha-timer-form"; import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; @@ -44,6 +46,7 @@ const HELPERS = { input_select: createInputSelect, counter: createCounter, timer: createTimer, + schedule: createSchedule, }; @customElement("dialog-helper-detail") diff --git a/src/panels/config/helpers/forms/ha-schedule-form.ts b/src/panels/config/helpers/forms/ha-schedule-form.ts new file mode 100644 index 0000000000..ea0dc38c05 --- /dev/null +++ b/src/panels/config/helpers/forms/ha-schedule-form.ts @@ -0,0 +1,379 @@ +// @ts-ignore +import fullcalendarStyle from "@fullcalendar/common/main.css"; +import { Calendar, CalendarOptions } from "@fullcalendar/core"; +import allLocales from "@fullcalendar/core/locales-all"; +import interactionPlugin from "@fullcalendar/interaction"; +import timeGridPlugin from "@fullcalendar/timegrid"; +// @ts-ignore +import timegridStyle from "@fullcalendar/timegrid/main.css"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { formatTime24h } from "../../../../common/datetime/format_time"; +import { useAmPm } from "../../../../common/datetime/use_am_pm"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-icon-picker"; +import "../../../../components/ha-textfield"; +import { Schedule, ScheduleDay, weekdays } from "../../../../data/schedule"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; + +const defaultFullCalendarConfig: CalendarOptions = { + plugins: [timeGridPlugin, interactionPlugin], + headerToolbar: false, + initialView: "timeGridWeek", + editable: true, + selectable: true, + selectMirror: true, + selectOverlap: false, + eventOverlap: false, + allDaySlot: false, + height: "parent", + locales: allLocales, + firstDay: 1, + dayHeaderFormat: { weekday: "short", month: undefined, day: undefined }, + slotLabelFormat: { hour: "numeric", minute: undefined, meridiem: "narrow" }, +}; + +@customElement("ha-schedule-form") +class HaScheduleForm extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public new?: boolean; + + @state() private _name!: string; + + @state() private _icon!: string; + + @state() private _monday!: ScheduleDay[]; + + @state() private _tuesday!: ScheduleDay[]; + + @state() private _wednesday!: ScheduleDay[]; + + @state() private _thursday!: ScheduleDay[]; + + @state() private _friday!: ScheduleDay[]; + + @state() private _saturday!: ScheduleDay[]; + + @state() private _sunday!: ScheduleDay[]; + + @state() private calendar?: Calendar; + + private _item?: Schedule; + + set item(item: Schedule) { + this._item = item; + if (item) { + this._name = item.name || ""; + this._icon = item.icon || ""; + this._monday = item.monday || []; + this._tuesday = item.tuesday || []; + this._wednesday = item.wednesday || []; + this._thursday = item.thursday || []; + this._friday = item.friday || []; + this._saturday = item.saturday || []; + this._sunday = item.sunday || []; + } else { + this._name = ""; + this._icon = ""; + this._monday = []; + this._tuesday = []; + this._wednesday = []; + this._thursday = []; + this._friday = []; + this._saturday = []; + this._sunday = []; + } + } + + public focus() { + this.updateComplete.then(() => + ( + this.shadowRoot?.querySelector("[dialogInitialFocus]") as HTMLElement + )?.focus() + ); + } + + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + const nameInvalid = !this._name || this._name.trim() === ""; + + return html` +
+ + +
+
+ `; + } + + public willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + if (!this.calendar) { + return; + } + + if ( + changedProps.has("_sunday") || + changedProps.has("_monday") || + changedProps.has("_tuesday") || + changedProps.has("_wednesday") || + changedProps.has("_thursday") || + changedProps.has("_friday") || + changedProps.has("_saturday") || + changedProps.has("calendar") + ) { + this.calendar.removeAllEventSources(); + this.calendar.addEventSource(this._events); + } + + const oldHass = changedProps.get("hass") as HomeAssistant; + + if (oldHass && oldHass.language !== this.hass.language) { + this.calendar.setOption("locale", this.hass.language); + } + } + + protected firstUpdated(): void { + const config: CalendarOptions = { + ...defaultFullCalendarConfig, + locale: this.hass.language, + eventTimeFormat: { + hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit", + minute: useAmPm(this.hass.locale) ? "numeric" : "2-digit", + hour12: useAmPm(this.hass.locale), + }, + }; + + config.eventClick = (info) => this._handleEventClick(info); + config.select = (info) => this._handleSelect(info); + config.eventResize = (info) => this._handleEventResize(info); + config.eventDrop = (info) => this._handleEventDrop(info); + + this.calendar = new Calendar( + this.shadowRoot!.getElementById("calendar")!, + config + ); + + this.calendar!.render(); + + // Update size after fully rendered to avoid a bad render in the more info + this.updateComplete.then(() => + window.setTimeout(() => { + this.calendar!.updateSize(); + }, 500) + ); + } + + private get _events() { + const events: any[] = []; + const currentDay = new Date().getDay(); + + for (const [i, day] of weekdays.entries()) { + if (!this[`_${day}`].length) { + continue; + } + + this[`_${day}`].forEach((item: ScheduleDay, index: number) => { + const distance = i - currentDay; + + const start = new Date(); + start.setDate(start.getDate() + distance); + start.setHours( + parseInt(item.from.slice(0, 2)), + parseInt(item.from.slice(-2)) + ); + + const end = new Date(); + end.setDate(end.getDate() + distance); + end.setHours( + parseInt(item.to.slice(0, 2)), + parseInt(item.to.slice(-2)) + ); + + events.push({ + id: `${day}-${index}`, + start: start.toISOString(), + end: end.toISOString(), + }); + }); + } + + return events; + } + + private _handleSelect(info: { start: Date; end: Date }) { + const { start, end } = info; + + if (start.getDay() !== end.getDay()) { + this.calendar!.unselect(); + return; + } + + const day = weekdays[start.getDay()]; + const value = [...this[`_${day}`]]; + const newValue = { ...this._item }; + + value.push({ + from: formatTime24h(start), + to: formatTime24h(end), + }); + + newValue[day] = value; + + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _handleEventResize(info: any) { + const { id, start, end } = info.event; + + if (start.getDay() !== end.getDay()) { + info.revert(); + return; + } + + const [day, index] = id.split("-"); + const value = this[`_${day}`][parseInt(index)]; + const newValue = { ...this._item }; + + newValue[day][index] = { + from: value.from, + to: formatTime24h(end), + }; + + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _handleEventDrop(info: any) { + const { id, start, end } = info.event; + + if (start.getDay() !== end.getDay()) { + info.revert(); + return; + } + + const [day, index] = id.split("-"); + const newDay = weekdays[start.getDay()]; + const newValue = { ...this._item }; + + const event = { + from: formatTime24h(start), + to: formatTime24h(end), + }; + + if (newDay === day) { + newValue[day][index] = event; + } else { + newValue[day].splice(index, 1); + const value = [...this[`_${newDay}`]]; + value.push(event); + newValue[newDay] = value; + } + + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _handleEventClick(info: any) { + const [day, index] = info.event.id.split("-"); + const value = [...this[`_${day}`]]; + + const newValue = { ...this._item }; + value.splice(parseInt(index), 1); + newValue[day] = value; + + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _valueChanged(ev: CustomEvent) { + if (!this.new && !this._item) { + return; + } + + ev.stopPropagation(); + const configValue = (ev.target as any).configValue; + const value = ev.detail?.value || (ev.target as any).value; + if (this[`_${configValue}`] === value) { + return; + } + const newValue = { ...this._item }; + if (!value) { + delete newValue[configValue]; + } else { + newValue[configValue] = value; + } + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ${unsafeCSS(fullcalendarStyle)} + ${unsafeCSS(timegridStyle)} + .form { + color: var(--primary-text-color); + } + + ha-textfield { + display: block; + margin: 8px 0; + } + + #calendar { + margin: 8px 0; + height: 450px; + width: 100%; + } + .fc-scroller { + overflow-x: visible !important; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-schedule-form": HaScheduleForm; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index c7afc5ae3d..9e5b4d5a4e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1538,7 +1538,8 @@ "input_button": "Button", "input_datetime": "Date and/or time", "counter": "Counter", - "timer": "Timer" + "timer": "Timer", + "schedule": "Schedule" }, "picker": { "headers": { diff --git a/yarn.lock b/yarn.lock index 69f98fe8bf..9aeaa205b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1708,7 +1708,7 @@ __metadata: languageName: node linkType: hard -"@fullcalendar/daygrid@npm:5.9.0": +"@fullcalendar/daygrid@npm:5.9.0, @fullcalendar/daygrid@npm:~5.9.0": version: 5.9.0 resolution: "@fullcalendar/daygrid@npm:5.9.0" dependencies: @@ -1738,6 +1738,17 @@ __metadata: languageName: node linkType: hard +"@fullcalendar/timegrid@npm:5.9.0": + version: 5.9.0 + resolution: "@fullcalendar/timegrid@npm:5.9.0" + dependencies: + "@fullcalendar/common": ~5.9.0 + "@fullcalendar/daygrid": ~5.9.0 + tslib: ^2.1.0 + checksum: dedef1e1147cd17aa277b159c806e0f927715d67c513d940bec61cb97bfdf97c71b43c03166d8442e9683e2d7d6f03d81619a694de84e04e5995b9e8ef3585b9 + languageName: node + linkType: hard + "@gfx/zopfli@npm:^1.0.9": version: 1.0.11 resolution: "@gfx/zopfli@npm:1.0.11" @@ -9030,6 +9041,7 @@ fsevents@^1.2.7: "@fullcalendar/daygrid": 5.9.0 "@fullcalendar/interaction": 5.9.0 "@fullcalendar/list": 5.9.0 + "@fullcalendar/timegrid": 5.9.0 "@koa/cors": ^3.1.0 "@lit-labs/motion": ^1.0.2 "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch"