diff --git a/package.json b/package.json index 5f3c81026d..5e91415849 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "regenerator-runtime": "^0.13.8", "resize-observer-polyfill": "^1.5.1", "roboto-fontface": "^0.10.0", + "rrule": "^2.7.1", "sortablejs": "^1.14.0", "superstruct": "^0.15.2", "tinykeys": "^1.1.3", diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts index 8becaf367d..b6f93a9584 100644 --- a/src/components/date-range-picker.ts +++ b/src/components/date-range-picker.ts @@ -9,6 +9,10 @@ import { Constructor } from "../types"; const Component = Vue.extend({ props: { + timePicker: { + type: Boolean, + default: true, + }, twentyfourHours: { type: Boolean, default: true, @@ -37,13 +41,19 @@ const Component = Vue.extend({ type: Number, default: 1, }, + autoApply: { + type: Boolean, + default: false, + }, }, render(createElement) { // @ts-ignore return createElement(DateRangePicker, { props: { - "time-picker": true, - "auto-apply": false, + // @ts-ignore + "time-picker": this.timePicker, + // @ts-ignore + "auto-apply": this.autoApply, opens: "right", "show-dropdowns": false, // @ts-ignore diff --git a/src/components/ha-date-input.ts b/src/components/ha-date-input.ts index bd542fb797..f5386b81e0 100644 --- a/src/components/ha-date-input.ts +++ b/src/components/ha-date-input.ts @@ -35,6 +35,10 @@ export class HaDateInput extends LitElement { @property() public value?: string; + @property() public min?: string; + + @property() public max?: string; + @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public required = false; @@ -65,7 +69,8 @@ export class HaDateInput extends LitElement { return; } showDatePickerDialog(this, { - min: "1970-01-01", + min: this.min || "1970-01-01", + max: this.max, value: this.value, onChange: (value) => this._valueChanged(value), locale: this.locale.language, @@ -86,6 +91,9 @@ export class HaDateInput extends LitElement { ha-svg-icon { color: var(--secondary-text-color); } + ha-textfield { + display: block; + } `; } } diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index f5747d398e..5c870215a7 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -13,6 +13,7 @@ import { } from "lit"; import { customElement, property } from "lit/decorators"; import { formatDateTime } from "../common/datetime/format_date_time"; +import { formatDate } from "../common/datetime/format_date"; import { useAmPm } from "../common/datetime/use_am_pm"; import { firstWeekdayIndex } from "../common/datetime/first_weekday"; import { computeRTLDirection } from "../common/util/compute_rtl"; @@ -35,6 +36,10 @@ export class HaDateRangePicker extends LitElement { @property() public ranges?: DateRangePickerRanges; + @property() public autoApply = false; + + @property() public timePicker = true; + @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) private _hour24format = false; @@ -55,6 +60,8 @@ export class HaDateRangePicker extends LitElement { return html` { const eventStart = getCalendarDate(ev.start); - if (!eventStart) { + const eventEnd = getCalendarDate(ev.end); + if (!eventStart || !eventEnd) { return; } - const eventEnd = getCalendarDate(ev.end); + const eventData: CalendarEventData = { + uid: ev.uid, + summary: ev.summary, + dtstart: eventStart, + dtend: eventEnd, + recurrence_id: ev.recurrence_id, + rrule: ev.rrule, + }; const event: CalendarEvent = { start: eventStart, end: eventEnd, title: ev.summary, - summary: ev.summary, backgroundColor: cal.backgroundColor, borderColor: cal.backgroundColor, calendar: cal.entity_id, + eventData: eventData, }; calEvents.push(event); @@ -83,3 +131,40 @@ export const getCalendars = (hass: HomeAssistant): Calendar[] => name: computeStateName(hass.states[eid]), backgroundColor: getColorByIndex(idx), })); + +export const createCalendarEvent = ( + hass: HomeAssistant, + entityId: string, + event: CalendarEventMutableParams +) => + hass.callWS({ + type: "calendar/event/create", + entity_id: entityId, + event: event, + }); + +export const updateCalendarEvent = ( + hass: HomeAssistant, + entityId: string, + event: CalendarEventMutableParams +) => + hass.callWS({ + type: "calendar/event/update", + entity_id: entityId, + event: event, + }); + +export const deleteCalendarEvent = ( + hass: HomeAssistant, + entityId: string, + uid: string, + recurrence_id?: string, + recurrence_range?: RecurrenceRange +) => + hass.callWS({ + type: "calendar/event/delete", + entity_id: entityId, + uid, + recurrence_id, + recurrence_range, + }); diff --git a/src/panels/calendar/confirm-event-dialog-box.ts b/src/panels/calendar/confirm-event-dialog-box.ts new file mode 100644 index 0000000000..d8a6b3fdbc --- /dev/null +++ b/src/panels/calendar/confirm-event-dialog-box.ts @@ -0,0 +1,149 @@ +import "@material/mwc-button/mwc-button"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-dialog"; +import "../../components/ha-svg-icon"; +import "../../components/ha-switch"; +import { HomeAssistant } from "../../types"; +import { ConfirmEventDialogBoxParams } from "./show-confirm-event-dialog-box"; +import { RecurrenceRange } from "../../data/calendar"; + +@customElement("confirm-event-dialog-box") +class ConfirmEventDialogBox extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: ConfirmEventDialogBoxParams; + + public async showDialog(params: ConfirmEventDialogBoxParams): Promise { + this._params = params; + } + + public closeDialog(): boolean { + return true; + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + + return html` + +
+

${this._params.text}

+
+ + ${this.hass.localize("ui.dialogs.generic.cancel")} + + + ${this._params.confirmText} + + ${this._params.confirmFutureText + ? html` + + ${this._params.confirmFutureText} + + ` + : ""} +
+ `; + } + + private _dismiss(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + this._close(); + } + + private _confirm(): void { + if (this._params!.confirm) { + this._params!.confirm(RecurrenceRange.THISEVENT); + } + this._close(); + } + + private _confirmFuture(): void { + if (this._params!.confirm) { + this._params!.confirm(RecurrenceRange.THISANDFUTURE); + } + this._close(); + } + + private _dialogClosed(ev) { + if (ev.detail.action === "ignore") { + return; + } + this._dismiss(); + } + + private _close(): void { + if (!this._params) { + return; + } + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + static get styles(): CSSResultGroup { + return css` + :host([inert]) { + pointer-events: initial !important; + cursor: initial !important; + } + a { + color: var(--primary-color); + } + p { + margin: 0; + color: var(--primary-text-color); + } + .no-bottom-padding { + padding-bottom: 0; + } + .secondary { + color: var(--secondary-text-color); + } + .destructive { + --mdc-theme-primary: var(--error-color); + } + ha-dialog { + --mdc-dialog-heading-ink-color: var(--primary-text-color); + --mdc-dialog-content-ink-color: var(--primary-text-color); + /* Place above other dialogs */ + --dialog-z-index: 104; + } + @media all and (min-width: 600px) { + ha-dialog { + --mdc-dialog-min-width: 400px; + } + } + ha-textfield { + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "confirm-event-dialog-box": ConfirmEventDialogBox; + } +} diff --git a/src/panels/calendar/dialog-calendar-event-detail.ts b/src/panels/calendar/dialog-calendar-event-detail.ts new file mode 100644 index 0000000000..96bdac6df8 --- /dev/null +++ b/src/panels/calendar/dialog-calendar-event-detail.ts @@ -0,0 +1,244 @@ +import "@material/mwc-button"; +import { mdiCalendarClock, mdiClose } from "@mdi/js"; +import { isSameDay } from "date-fns/esm"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { property, state } from "lit/decorators"; +import { RRule } from "rrule"; +import { formatDate } from "../../common/datetime/format_date"; +import { formatDateTime } from "../../common/datetime/format_date_time"; +import { formatTime } from "../../common/datetime/format_time"; +import { fireEvent } from "../../common/dom/fire_event"; +import { isDate } from "../../common/string/is_date"; +import "../../components/entity/state-info"; +import "../../components/ha-date-input"; +import "../../components/ha-time-input"; +import { + CalendarEventMutableParams, + deleteCalendarEvent, +} from "../../data/calendar"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; +import "../lovelace/components/hui-generic-entity-row"; +import "./ha-recurrence-rule-editor"; +import { showConfirmEventDialog } from "./show-confirm-event-dialog-box"; +import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail"; +import { showCalendarEventEditDialog } from "./show-dialog-calendar-event-editor"; + +class DialogCalendarEventDetail extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: CalendarEventDetailDialogParams; + + @state() private _calendarId?: string; + + @state() private _submitting = false; + + @state() private _error?: string; + + @state() private _data!: CalendarEventMutableParams; + + public async showDialog( + params: CalendarEventDetailDialogParams + ): Promise { + this._params = params; + if (params.entry) { + const entry = params.entry!; + this._data = entry; + this._calendarId = params.calendarId || params.calendars[0].entity_id; + } + } + + private closeDialog(): void { + this._calendarId = undefined; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + const stateObj = this.hass.states[this._calendarId!]; + return html` + ${this._data!.summary} + + `} + > +
+ ${this._error + ? html`${this._error}` + : ""} +
+ +
+ ${this._formatDateRange()}
+ ${this._data!.rrule + ? this._renderRruleAsText(this._data.rrule) + : ""} +
+
+ +
+ +
+
+ ${this._params.canDelete + ? html` + + ${this.hass.localize("ui.components.calendar.event.delete")} + + ` + : ""}${this._params.canEdit + ? html` + ${this.hass.localize("ui.components.calendar.event.edit")} + ` + : ""} +
+ `; + } + + private _renderRruleAsText(value: string) { + // TODO: Make sure this handles translations + try { + const readableText = + value === "" ? "" : RRule.fromString(`RRULE:${value}`).toText(); + return html`
${readableText}
`; + } catch (e) { + return ""; + } + } + + private _formatDateRange() { + const start = new Date(this._data!.dtstart); + const end = new Date(this._data!.dtend); + // The range can be shortened when the start and end are on the same day. + if (isSameDay(start, end)) { + if (isDate(this._data.dtstart)) { + // Single date string only + return formatDate(start, this.hass.locale); + } + // Single day with a start/end time range + return `${formatDate(start, this.hass.locale)} ${formatTime( + start, + this.hass.locale + )} - ${formatTime(end, this.hass.locale)}`; + } + // An event across multiple dates, optionally with a time range + return `${formatDateTime(start, this.hass.locale)} - ${formatDateTime( + end, + this.hass.locale + )}`; + } + + private async _editEvent() { + showCalendarEventEditDialog(this, this._params!); + this.closeDialog(); + } + + private async _deleteEvent() { + this._submitting = true; + const entry = this._params!.entry!; + const range = await showConfirmEventDialog(this, { + title: this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete" + ), + text: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.recurring_prompt" + ) + : this.hass.localize( + "ui.components.calendar.event.confirm_delete.prompt" + ), + confirmText: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete_this" + ) + : this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete" + ), + confirmFutureText: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete_future" + ) + : undefined, + }); + if (range === undefined) { + // Cancel + this._submitting = false; + return; + } + try { + await deleteCalendarEvent( + this.hass!, + this._calendarId!, + entry.uid!, + entry.recurrence_id || "", + range! + ); + } catch (err: any) { + this._error = err ? err.message : "Unknown error"; + return; + } finally { + this._submitting = false; + } + await this._params!.updated(); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + state-info { + line-height: 40px; + } + ha-svg-icon { + width: 40px; + margin-right: 8px; + margin-inline-end: 8px; + margin-inline-start: initial; + direction: var(--direction); + vertical-align: top; + } + .field { + display: flex; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-calendar-event-detail": DialogCalendarEventDetail; + } +} + +customElements.define( + "dialog-calendar-event-detail", + DialogCalendarEventDetail +); diff --git a/src/panels/calendar/dialog-calendar-event-editor.ts b/src/panels/calendar/dialog-calendar-event-editor.ts new file mode 100644 index 0000000000..03ddcdd3d9 --- /dev/null +++ b/src/panels/calendar/dialog-calendar-event-editor.ts @@ -0,0 +1,438 @@ +import "@material/mwc-button"; +import { mdiClose } from "@mdi/js"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { addDays, addHours, startOfHour } from "date-fns/esm"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isDate } from "../../common/string/is_date"; +import "../../components/ha-date-input"; +import "../../components/ha-time-input"; +import { + Calendar, + CalendarEventMutableParams, + createCalendarEvent, + deleteCalendarEvent, +} from "../../data/calendar"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; +import "../lovelace/components/hui-generic-entity-row"; +import "./ha-recurrence-rule-editor"; +import { showConfirmEventDialog } from "./show-confirm-event-dialog-box"; +import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail"; +import { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor"; + +const rowRenderer: ComboBoxLitRenderer = ( + item +) => html` + ${item.name} +`; + +@customElement("dialog-calendar-event-editor") +class DialogCalendarEventEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _error?: string; + + @state() private _params?: CalendarEventDetailDialogParams; + + @state() private _calendars: Calendar[] = []; + + @state() private _calendarId?: string; + + @state() private _data?: CalendarEventMutableParams; + + @state() private _allDay = false; + + @state() private _dtstart?: Date; // In sync with _data.dtstart + + @state() private _dtend?: Date; // Inclusive for display, in sync with _data.dtend (exclusive) + + @state() private _submitting = false; + + public async showDialog( + params: CalendarEventEditDialogParams + ): Promise { + this._error = undefined; + this._params = params; + this._calendars = params.calendars; + this._calendarId = params.calendarId || this._calendars[0].entity_id; + if (params.entry) { + const entry = params.entry!; + this._data = entry; + this._allDay = isDate(entry.dtstart); + if (this._allDay) { + this._dtstart = new Date(entry.dtstart); + // Calendar event end dates are exclusive, but not shown that way in the UI. The + // reverse happens when persisting the event. + this._dtend = new Date(entry.dtend); + this._dtend.setDate(this._dtend.getDate() - 1); + } else { + this._dtstart = new Date(entry.dtstart); + this._dtend = new Date(entry.dtend); + } + } else { + this._data = { + summary: "", + // Dates are set in _dateChanged() + dtstart: "", + dtend: "", + }; + this._allDay = false; + this._dtstart = startOfHour(new Date()); + this._dtend = addHours(this._dtstart, 1); + this._dateChanged(); + } + await this.updateComplete; + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + const isCreate = this._params.entry === undefined; + return html` + + ${isCreate + ? this.hass.localize("ui.components.calendar.event.add") + : this._data!.summary} + + + `} + > +
+ ${this._error + ? html`${this._error}` + : ""} + + + + + + + +
+ ${this.hass.localize( + "ui.components.calendar.event.start" + )}: +
+ + ${!this._allDay + ? html`` + : ""} +
+
+
+ ${this.hass.localize("ui.components.calendar.event.end")}: +
+ + ${!this._allDay + ? html`` + : ""} +
+
+ + +
+ ${isCreate + ? html` + + ${this.hass.localize("ui.components.calendar.event.add")} + + ` + : html` + ${this.hass.localize("ui.components.calendar.event.save")} + + ${this._params.canDelete + ? html` + + ${this.hass.localize( + "ui.components.calendar.event.delete" + )} + + ` + : ""}`} +
+ `; + } + + private _handleSummaryChanged(ev) { + this._data!.summary = ev.target.value; + } + + private _handleRRuleChanged(ev) { + this._data!.rrule = ev.detail.value; + this.requestUpdate(); + } + + private _allDayToggleChanged(ev) { + this._allDay = ev.target.checked; + this._dateChanged(); + } + + private _startDateChanged(ev: CustomEvent) { + this._dtstart = new Date( + ev.detail.value + "T" + this._dtstart!.toISOString().split("T")[1] + ); + this._dateChanged(); + } + + private _endDateChanged(ev: CustomEvent) { + this._dtend = new Date( + ev.detail.value + "T" + this._dtend!.toISOString().split("T")[1] + ); + this._dateChanged(); + } + + private _startTimeChanged(ev: CustomEvent) { + this._dtstart = new Date( + this._dtstart!.toISOString().split("T")[0] + "T" + ev.detail.value + ); + this._dateChanged(); + } + + private _endTimeChanged(ev: CustomEvent) { + this._dtend = new Date( + this._dtend!.toISOString().split("T")[0] + "T" + ev.detail.value + ); + this._dateChanged(); + } + + private _dateChanged() { + if (this._allDay) { + this._data!.dtstart = this._dtstart!.toISOString(); + // End date/time is exclusive when persisted + this._data!.dtend = addDays(new Date(this._dtend!), 1).toISOString(); + } else { + this._data!.dtstart = this._dtstart!.toISOString(); + this._data!.dtend = this._dtend!.toISOString(); + } + } + + private _handleCalendarChanged(ev: CustomEvent) { + this._calendarId = ev.detail.value; + } + + private async _createEvent() { + this._submitting = true; + try { + await createCalendarEvent(this.hass!, this._calendarId!, this._data!); + } catch (err: any) { + this._error = err ? err.message : "Unknown error"; + } finally { + this._submitting = false; + } + await this._params!.updated(); + this._params = undefined; + } + + private async _saveEvent() { + // to be implemented + } + + private async _deleteEvent() { + this._submitting = true; + const entry = this._params!.entry!; + const range = await showConfirmEventDialog(this, { + title: this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete" + ), + text: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.recurring_prompt" + ) + : this.hass.localize( + "ui.components.calendar.event.confirm_delete.prompt" + ), + confirmText: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete_this" + ) + : this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete" + ), + confirmFutureText: entry.recurrence_id + ? this.hass.localize( + "ui.components.calendar.event.confirm_delete.delete_future" + ) + : undefined, + }); + if (range === undefined) { + // Cancel + this._submitting = false; + return; + } + try { + await deleteCalendarEvent( + this.hass!, + this._calendarId!, + entry.uid!, + entry.recurrence_id || "", + range! + ); + } catch (err: any) { + this._error = err ? err.message : "Unknown error"; + return; + } finally { + this._submitting = false; + } + await this._params!.updated(); + this._close(); + } + + private _close(): void { + this._calendars = []; + this._calendarId = undefined; + this._params = undefined; + this._data = undefined; + this._dtstart = undefined; + this._dtend = undefined; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + state-info { + line-height: 40px; + } + ha-textfield { + display: block; + margin-bottom: 24px; + } + ha-formfield { + display: block; + padding: 16px 0; + } + ha-date-input { + flex-grow: 1; + } + ha-time-input { + margin-left: 16px; + } + ha-recurrence-rule-editor { + display: block; + margin-top: 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-rrule { + display: block; + } + ha-combo-box { + display: block; + margin-bottom: 24px; + } + ha-svg-icon { + width: 40px; + margin-right: 8px; + margin-inline-end: 8px; + margin-inline-start: initial; + direction: var(--direction); + vertical-align: top; + } + ha-rrule { + display: inline-block; + } + .key { + display: inline-block; + vertical-align: top; + } + .value { + display: inline-block; + vertical-align: top; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-calendar-event-editor": DialogCalendarEventEditor; + } +} diff --git a/src/panels/calendar/ha-full-calendar.ts b/src/panels/calendar/ha-full-calendar.ts index 50c393bce5..5fcde29675 100644 --- a/src/panels/calendar/ha-full-calendar.ts +++ b/src/panels/calendar/ha-full-calendar.ts @@ -11,7 +11,13 @@ import listPlugin from "@fullcalendar/list"; // @ts-ignore import listStyle from "@fullcalendar/list/main.css"; import "@material/mwc-button"; -import { mdiViewAgenda, mdiViewDay, mdiViewModule, mdiViewWeek } from "@mdi/js"; +import { + mdiPlus, + mdiViewAgenda, + mdiViewDay, + mdiViewModule, + mdiViewWeek, +} from "@mdi/js"; import { css, CSSResultGroup, @@ -26,18 +32,26 @@ import memoize from "memoize-one"; import { useAmPm } from "../../common/datetime/use_am_pm"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-button-toggle-group"; +import "../../components/ha-fab"; import "../../components/ha-icon-button-prev"; import "../../components/ha-icon-button-next"; import { haStyle } from "../../resources/styles"; import { computeRTLDirection } from "../../common/util/compute_rtl"; import type { - CalendarEvent, CalendarViewChanged, FullCalendarView, HomeAssistant, ToggleButton, } from "../../types"; import { firstWeekdayIndex } from "../../common/datetime/first_weekday"; +import { supportsFeature } from "../../common/entity/supports-feature"; +import { showCalendarEventDetailDialog } from "./show-dialog-calendar-event-detail"; +import type { + Calendar as CalendarData, + CalendarEvent, +} from "../../data/calendar"; +import { CalendarEntityFeature } from "../../data/calendar"; +import { showCalendarEventEditDialog } from "./show-dialog-calendar-event-editor"; declare global { interface HTMLElementTagNameMap { @@ -86,6 +100,8 @@ export class HAFullCalendar extends LitElement { @property({ attribute: false }) public events: CalendarEvent[] = []; + @property({ attribute: false }) public calendars: CalendarData[] = []; + @property({ attribute: false }) public views: FullCalendarView[] = [ "dayGridMonth", "dayGridWeek", @@ -179,7 +195,18 @@ export class HAFullCalendar extends LitElement { ` : ""} +
+ ${this._mutableCalendars.length > 0 + ? html` + + ` + : html``} `; } @@ -231,19 +258,46 @@ export class HAFullCalendar extends LitElement { this.shadowRoot!.getElementById("calendar")!, config ); - this.calendar!.render(); this._fireViewChanged(); } - private _handleEventClick(info): void { - if (info.view.type !== "dayGridMonth") { - return; - } + // Return calendars that support creating events + private get _mutableCalendars(): CalendarData[] { + return this.calendars + .filter((selCal) => { + const entityStateObj = this.hass.states[selCal.entity_id]; + return ( + entityStateObj && + supportsFeature(entityStateObj, CalendarEntityFeature.CREATE_EVENT) + ); + }) + .map((cal) => cal); + } - this._activeView = "dayGridDay"; - this.calendar!.changeView("dayGridDay"); - this.calendar!.gotoDate(info.event.startStr); + private _createEvent(_info) { + showCalendarEventEditDialog(this, { + calendars: this._mutableCalendars, + updated: () => { + this._fireViewChanged(); + }, + }); + } + + private _handleEventClick(info): void { + const entityStateObj = this.hass.states[info.event.extendedProps.calendar]; + const canDelete = + entityStateObj && + supportsFeature(entityStateObj, CalendarEntityFeature.DELETE_EVENT); + showCalendarEventDetailDialog(this, { + calendars: this.calendars, + calendarId: info.event.extendedProps.calendar, + entry: info.event.extendedProps.eventData, + updated: () => { + this._fireViewChanged(); + }, + canDelete: canDelete, + }); } private _handleDateClick(info): void { @@ -352,6 +406,13 @@ export class HAFullCalendar extends LitElement { color: var(--primary-color); } + ha-fab { + position: absolute; + bottom: 32px; + right: 32px; + z-index: 1; + } + #calendar { flex-grow: 1; background-color: var( diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index e757705acd..5dc7a28381 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -20,16 +20,13 @@ import "../../components/ha-icon-button"; import "../../components/ha-menu-button"; import { Calendar, + CalendarEvent, fetchCalendarEvents, getCalendars, } from "../../data/calendar"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; -import type { - CalendarEvent, - CalendarViewChanged, - HomeAssistant, -} from "../../types"; +import type { CalendarViewChanged, HomeAssistant } from "../../types"; import "./ha-full-calendar"; @customElement("ha-panel-calendar") @@ -101,6 +98,7 @@ class PanelCalendar extends LitElement { = new Set(); + + @state() private _end: RepeatEnd = "never"; + + @state() private _count?: number; + + @state() private _until?: Date; + + private _allWeekdays?: WeekdayStr[]; + + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!changedProps.has("value") && !changedProps.has("locale")) { + return; + } + this._interval = 1; + this._weekday.clear(); + this._end = "never"; + this._count = undefined; + this._until = undefined; + + this._allWeekdays = getWeekdays(firstWeekdayIndex(this.locale)).map( + (day: Weekday) => day.toString() as WeekdayStr + ); + + this._computedRRule = this.value; + if (this.value === "") { + this._freq = "none"; + return; + } + let rrule: Partial | undefined; + try { + rrule = RRule.parseString(this.value); + } catch (ex) { + // unsupported rrule string + this._freq = undefined; + return; + } + this._freq = convertFrequency(rrule!.freq!); + if (rrule.interval) { + this._interval = rrule.interval; + } + if ( + this._freq === "weekly" && + rrule.byweekday && + Array.isArray(rrule.byweekday) + ) { + this._weekday = new Set( + rrule.byweekday.map( + (value: ByWeekday) => value.toString() as WeekdayStr + ) + ); + } + if (rrule.until) { + this._end = "on"; + this._until = rrule.until; + } else if (rrule.count) { + this._end = "after"; + this._count = rrule.count; + } + } + + renderRepeat() { + return html` + + None + Yearly + Monthly + Weekly + Daily + + `; + } + + renderMonthly() { + return this.renderInterval(); + } + + renderWeekly() { + return html` + ${this.renderInterval()} +
+ ${this._allWeekdays!.map( + (item) => html` + ${WEEKDAY_NAME[item]} + ` + )} +
+ `; + } + + renderDaily() { + return this.renderInterval(); + } + + renderInterval() { + return html` + + `; + } + + renderEnd() { + return html` + + Never + After + On + + ${this._end === "after" + ? html` + + ` + : html``} + ${this._end === "on" + ? html` + + ` + : html``} + `; + } + + render() { + return html` + ${this.renderRepeat()} + ${this._freq === "monthly" ? this.renderMonthly() : html``} + ${this._freq === "weekly" ? this.renderWeekly() : html``} + ${this._freq === "daily" ? this.renderDaily() : html``} + ${this._freq !== "none" ? this.renderEnd() : html``} + `; + } + + private _onIntervalChange(e: Event) { + this._interval = (e.target! as any).value; + this._updateRule(); + } + + private _onRepeatSelected(e: CustomEvent>) { + this._freq = (e.target as HaSelect).value as RepeatFrequency; + + if (this._freq === "yearly") { + this._interval = 1; + } + if (this._freq !== "weekly") { + this._weekday.clear(); + } + e.stopPropagation(); + this._updateRule(); + } + + private _onWeekdayToggle(e: MouseEvent) { + const target = e.currentTarget as any; + const value = target.value as WeekdayStr; + if (!target.classList.contains("active")) { + this._weekday.add(value); + } else { + this._weekday.delete(value); + } + this._updateRule(); + } + + private _onEndSelected(e: CustomEvent>) { + const end = (e.target as HaSelect).value as RepeatEnd; + if (end === this._end) { + return; + } + this._end = end; + + switch (this._end) { + case "after": + this._count = DEFAULT_COUNT[this._freq!]; + this._until = undefined; + break; + case "on": + this._count = undefined; + this._until = untilValue(this._freq!); + break; + default: + this._count = undefined; + this._until = undefined; + } + e.stopPropagation(); + this._updateRule(); + } + + private _onCountChange(e: Event) { + this._count = (e.target! as any).value; + this._updateRule(); + } + + private _onUntilChange(e: CustomEvent) { + this._until = new Date(e.detail.value); + this._updateRule(); + } + + private _computeRRule() { + if (this._freq === undefined || this._freq === "none") { + return ""; + } + const options = { + freq: convertRepeatFrequency(this._freq!)!, + interval: this._interval > 1 ? this._interval : undefined, + byweekday: ruleByWeekDay(this._weekday), + count: this._count, + until: this._until, + }; + const contentline = RRule.optionsToString(options); + return contentline.slice(6); // Strip "RRULE:" prefix + } + + // Fire event with an rfc5546 recurrence rule string value + private _updateRule() { + const rule = this._computeRRule(); + if (rule === this._computedRRule) { + return; + } + this._computedRRule = rule; + + this.dispatchEvent( + new CustomEvent("value-changed", { + detail: { value: rule }, + }) + ); + } + + static styles = css` + ha-textfield, + ha-select { + display: block; + margin-bottom: 16px; + } + .weekdays { + display: flex; + justify-content: space-between; + margin-bottom: 16px; + } + ha-textfield:last-child, + ha-select:last-child, + .weekdays:last-child { + margin-bottom: 0; + } + + .active { + --ha-chip-background-color: var(--primary-color); + --ha-chip-text-color: var(--text-primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-recurrence-rule-editor": RecurrenceRuleEditor; + } +} diff --git a/src/panels/calendar/recurrence.ts b/src/panels/calendar/recurrence.ts new file mode 100644 index 0000000000..204efafa31 --- /dev/null +++ b/src/panels/calendar/recurrence.ts @@ -0,0 +1,139 @@ +// Library for converting back and forth from values use by this webcomponent +// and the values defined by rrule.js. +import { RRule, Frequency, Weekday } from "rrule"; +import type { WeekdayStr } from "rrule"; + +export type RepeatFrequency = + | "none" + | "yearly" + | "monthly" + | "weekly" + | "daily"; + +export type RepeatEnd = "never" | "on" | "after"; + +export const DEFAULT_COUNT = { + none: 1, + yearly: 5, + monthly: 12, + weekly: 13, + daily: 30, +}; + +export function intervalSuffix(freq: RepeatFrequency) { + if (freq === "monthly") { + return "months"; + } + if (freq === "weekly") { + return "weeks"; + } + return "days"; +} + +export function untilValue(freq: RepeatFrequency): Date { + const today = new Date(); + const increment = DEFAULT_COUNT[freq]; + switch (freq) { + case "yearly": + return new Date(new Date().setFullYear(today.getFullYear() + increment)); + case "monthly": + return new Date(new Date().setMonth(today.getMonth() + increment)); + case "weekly": + return new Date(new Date().setDate(today.getDate() + 7 * increment)); + case "daily": + default: + return new Date(new Date().setDate(today.getDate() + increment)); + } +} + +export const convertFrequency = ( + freq: Frequency +): RepeatFrequency | undefined => { + switch (freq) { + case Frequency.YEARLY: + return "yearly"; + case Frequency.MONTHLY: + return "monthly"; + case Frequency.WEEKLY: + return "weekly"; + case Frequency.DAILY: + return "daily"; + default: + return undefined; + } +}; + +export const convertRepeatFrequency = ( + freq: RepeatFrequency +): Frequency | undefined => { + switch (freq) { + case "yearly": + return Frequency.YEARLY; + case "monthly": + return Frequency.MONTHLY; + case "weekly": + return Frequency.WEEKLY; + case "daily": + return Frequency.DAILY; + default: + return undefined; + } +}; + +export const WEEKDAY_NAME = { + SU: "Sun", + MO: "Mon", + TU: "Tue", + WE: "Wed", + TH: "Thu", + FR: "Fri", + SA: "Sat", +}; + +export const WEEKDAYS = [ + RRule.SU, + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, +]; + +export function getWeekdays(firstDay?: number) { + if (firstDay === undefined || firstDay === 0) { + return WEEKDAYS; + } + let iterator = firstDay; + const weekDays: Weekday[] = [...WEEKDAYS]; + while (iterator > 0) { + weekDays.push(weekDays.shift() as Weekday); + iterator -= 1; + } + return weekDays; +} + +export function ruleByWeekDay( + weekdays: Set +): Weekday[] | undefined { + return Array.from(weekdays).map((value: string) => { + switch (value) { + case "MO": + return RRule.MO; + case "TU": + return RRule.TU; + case "WE": + return RRule.WE; + case "TH": + return RRule.TH; + case "FR": + return RRule.FR; + case "SA": + return RRule.SA; + case "SU": + return RRule.SU; + default: + return RRule.MO; + } + }); +} diff --git a/src/panels/calendar/show-confirm-event-dialog-box.ts b/src/panels/calendar/show-confirm-event-dialog-box.ts new file mode 100644 index 0000000000..f3b377805a --- /dev/null +++ b/src/panels/calendar/show-confirm-event-dialog-box.ts @@ -0,0 +1,44 @@ +import { TemplateResult } from "lit"; +import { fireEvent } from "../../common/dom/fire_event"; +import { RecurrenceRange } from "../../data/calendar"; + +export interface ConfirmEventDialogBoxParams { + confirmText?: string; + confirmFutureText?: string; // Prompt for future recurring events + confirm?: (recurrenceRange: RecurrenceRange) => void; + cancel?: () => void; + text?: string | TemplateResult; + title: string; +} + +export const loadGenericDialog = () => import("./confirm-event-dialog-box"); + +export const showConfirmEventDialog = ( + element: HTMLElement, + dialogParams: ConfirmEventDialogBoxParams +) => + new Promise((resolve) => { + const origConfirm = dialogParams.confirm; + const origCancel = dialogParams.cancel; + + fireEvent(element, "show-dialog", { + dialogTag: "confirm-event-dialog-box", + dialogImport: loadGenericDialog, + dialogParams: { + ...dialogParams, + confirm: (thisAndFuture: RecurrenceRange) => { + resolve(thisAndFuture); + if (origConfirm) { + origConfirm(thisAndFuture); + } + }, + cancel: () => { + resolve(undefined); + if (origCancel) { + origCancel(); + } + }, + }, + addHistory: false, + }); + }); diff --git a/src/panels/calendar/show-dialog-calendar-event-detail.ts b/src/panels/calendar/show-dialog-calendar-event-detail.ts new file mode 100644 index 0000000000..902e8746b6 --- /dev/null +++ b/src/panels/calendar/show-dialog-calendar-event-detail.ts @@ -0,0 +1,25 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import { Calendar, CalendarEventData } from "../../data/calendar"; + +export interface CalendarEventDetailDialogParams { + calendars: Calendar[]; // When creating new events, is the list of events that support creation + calendarId?: string; + entry?: CalendarEventData; + canDelete?: boolean; + canEdit?: boolean; + updated: () => void; +} + +export const loadCalendarEventDetailDialog = () => + import("./dialog-calendar-event-detail"); + +export const showCalendarEventDetailDialog = ( + element: HTMLElement, + detailParams: CalendarEventDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-calendar-event-detail", + dialogImport: loadCalendarEventDetailDialog, + dialogParams: detailParams, + }); +}; diff --git a/src/panels/calendar/show-dialog-calendar-event-editor.ts b/src/panels/calendar/show-dialog-calendar-event-editor.ts new file mode 100644 index 0000000000..695832d164 --- /dev/null +++ b/src/panels/calendar/show-dialog-calendar-event-editor.ts @@ -0,0 +1,24 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import { Calendar, CalendarEventData } from "../../data/calendar"; + +export interface CalendarEventEditDialogParams { + calendars: Calendar[]; // When creating new events, is the list of events that support creation + calendarId?: string; + entry?: CalendarEventData; + canDelete?: boolean; + updated: () => void; +} + +export const loadCalendarEventEditDialog = () => + import("./dialog-calendar-event-editor"); + +export const showCalendarEventEditDialog = ( + element: HTMLElement, + detailParams: CalendarEventEditDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-calendar-event-editor", + dialogImport: loadCalendarEventEditDialog, + dialogParams: detailParams, + }); +}; diff --git a/src/panels/lovelace/cards/hui-calendar-card.ts b/src/panels/lovelace/cards/hui-calendar-card.ts index f159cc4c49..313762e918 100644 --- a/src/panels/lovelace/cards/hui-calendar-card.ts +++ b/src/panels/lovelace/cards/hui-calendar-card.ts @@ -12,9 +12,12 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen import { HASSDomEvent } from "../../../common/dom/fire_event"; import { debounce } from "../../../common/util/debounce"; import "../../../components/ha-card"; -import { Calendar, fetchCalendarEvents } from "../../../data/calendar"; -import type { +import { + Calendar, CalendarEvent, + fetchCalendarEvents, +} from "../../../data/calendar"; +import type { CalendarViewChanged, FullCalendarView, HomeAssistant, diff --git a/src/translations/en.json b/src/translations/en.json index f53ec0b939..d557cfbafb 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -631,8 +631,43 @@ "media_player_unavailable": "The selected media player is unavailable." }, "calendar": { + "label": "Calendar", "my_calendars": "My Calendars", - "today": "Today" + "today": "Today", + "event": { + "add": "Add Event", + "delete": "Delete Event", + "edit": "Edit Event", + "save": "Save Event", + "all_day": "All Day", + "start": "Start", + "end": "End", + "confirm_delete": { + "delete": "Delete Event", + "delete_this": "Delete Only This Event", + "delete_future": "Delete All Future Events", + "prompt": "Do you want to delete this event?", + "recurring_prompt": "Do you want to delete only this event, or this and all future occurrences of the event?" + }, + "repeat": { + "label": "Repeat", + "freq": { + "none": "No repeat", + "label": "Repeat", + "yearly": "Yearly", + "monthly": "Monthly", + "weekly": "Weekly", + "daily": "Daily" + }, + "interval": { + "label": "Repeat interval", + "monthly": "months", + "weekly": "weeks", + "daily": "days" + } + }, + "summary": "Summary" + } }, "attributes": { "expansion_header": "Attributes" diff --git a/src/types.ts b/src/types.ts index 44479f9168..e5939b7a4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,17 +112,6 @@ export interface Panels { [name: string]: PanelInfo; } -export interface CalendarEvent { - summary: string; - title: string; - start: string; - end?: string; - backgroundColor?: string; - borderColor?: string; - calendar: string; - [key: string]: any; -} - export interface CalendarViewChanged { end: Date; start: Date; diff --git a/yarn.lock b/yarn.lock index 955af85d37..fae93eb469 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9456,6 +9456,7 @@ fsevents@^1.2.7: rollup-plugin-string: ^3.0.0 rollup-plugin-terser: ^5.3.0 rollup-plugin-visualizer: ^4.0.4 + rrule: ^2.7.1 serve: ^11.3.2 sinon: ^11.0.0 sortablejs: ^1.14.0 @@ -13898,6 +13899,15 @@ fsevents@^1.2.7: languageName: node linkType: hard +"rrule@npm:^2.7.1": + version: 2.7.1 + resolution: "rrule@npm:2.7.1" + dependencies: + tslib: ^2.4.0 + checksum: 80b81c5b1c1d12539a2af6de6fc8ebff8d763d8006cd9a418a1de7adbd634ae84ea853f1590d73ea72737422b8775cd53ca64c59e7c4625eda09a2746596f1d9 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.1.9 resolution: "run-parallel@npm:1.1.9" @@ -15297,10 +15307,10 @@ fsevents@^1.2.7: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0": - version: 2.3.1 - resolution: "tslib@npm:2.3.1" - checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9 +"tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0": + version: 2.4.0 + resolution: "tslib@npm:2.4.0" + checksum: 8c4aa6a3c5a754bf76aefc38026134180c053b7bd2f81338cb5e5ebf96fefa0f417bff221592bf801077f5bf990562f6264fecbc42cd3309b33872cb6fc3b113 languageName: node linkType: hard