Add Calendar Event creation and deletion dialogs (#14036)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Allen Porter 2022-11-30 12:20:41 -08:00 committed by GitHub
parent e43f3b193e
commit 9b6e33cfec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1655 additions and 42 deletions

View File

@ -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",

View File

@ -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

View File

@ -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;
}
`;
}
}

View File

@ -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`
<date-range-picker
?disabled=${this.disabled}
?auto-apply=${this.autoApply}
?time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this.startDate}
end-date=${this.endDate}
@ -64,7 +71,9 @@ export class HaDateRangePicker extends LitElement {
<div slot="input" class="date-range-inputs">
<ha-svg-icon .path=${mdiCalendar}></ha-svg-icon>
<ha-textfield
.value=${formatDateTime(this.startDate, this.hass.locale)}
.value=${this.timePicker
? formatDateTime(this.startDate, this.hass.locale)
: formatDate(this.startDate, this.hass.locale)}
.label=${this.hass.localize(
"ui.components.date-range-picker.start_date"
)}
@ -73,7 +82,9 @@ export class HaDateRangePicker extends LitElement {
readonly
></ha-textfield>
<ha-textfield
.value=${formatDateTime(this.endDate, this.hass.locale)}
.value=${this.timePicker
? formatDateTime(this.endDate, this.hass.locale)
: formatDate(this.endDate, this.hass.locale)}
.label=${this.hass.localize(
"ui.components.date-range-picker.end_date"
)}

View File

@ -1,7 +1,7 @@
import { getColorByIndex } from "../common/color/colors";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import type { CalendarEvent, HomeAssistant } from "../types";
import type { HomeAssistant } from "../types";
export interface Calendar {
entity_id: string;
@ -9,6 +9,46 @@ export interface Calendar {
backgroundColor?: string;
}
/** Object used to render a calendar event in fullcalendar. */
export interface CalendarEvent {
title: string;
start: string;
end?: string;
backgroundColor?: string;
borderColor?: string;
calendar: string;
eventData: CalendarEventData;
[key: string]: any;
}
/** Data returned from the core APIs. */
export interface CalendarEventData {
uid?: string;
recurrence_id?: string;
summary: string;
dtstart: string;
dtend: string;
rrule?: string;
}
export interface CalendarEventMutableParams {
summary: string;
dtstart: string;
dtend: string;
rrule?: string;
}
// The scope of a delete/update for a recurring event
export enum RecurrenceRange {
THISEVENT = "",
THISANDFUTURE = "THISANDFUTURE",
}
export const enum CalendarEntityFeature {
CREATE_EVENT = 1,
DELETE_EVENT = 2,
}
export const fetchCalendarEvents = async (
hass: HomeAssistant,
start: Date,
@ -37,18 +77,26 @@ export const fetchCalendarEvents = async (
const cal = calendars[idx];
result.forEach((ev) => {
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<void>({
type: "calendar/event/create",
entity_id: entityId,
event: event,
});
export const updateCalendarEvent = (
hass: HomeAssistant,
entityId: string,
event: CalendarEventMutableParams
) =>
hass.callWS<void>({
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<void>({
type: "calendar/event/delete",
entity_id: entityId,
uid,
recurrence_id,
recurrence_range,
});

View File

@ -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<void> {
this._params = params;
}
public closeDialog(): boolean {
return true;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
?scrimClickAction="true"
?escapeKeyAction="true"
@closed=${this._dialogClosed}
defaultAction="ignore"
.heading=${this._params.title}
>
<div>
<p>${this._params.text}</p>
</div>
<mwc-button @click=${this._dismiss} slot="secondaryAction">
${this.hass.localize("ui.dialogs.generic.cancel")}
</mwc-button>
<mwc-button
slot="primaryAction"
@click=${this._confirm}
dialogInitialFocus
class="destructive"
>
${this._params.confirmText}
</mwc-button>
${this._params.confirmFutureText
? html`
<mwc-button
@click=${this._confirmFuture}
class="destructive"
slot="primaryAction"
>
${this._params.confirmFutureText}
</mwc-button>
`
: ""}
</ha-dialog>
`;
}
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;
}
}

View File

@ -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<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${html`
<div class="header_title">${this._data!.summary}</div>
<ha-icon-button
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
dialogAction="close"
class="header_button"
></ha-icon-button>
`}
>
<div class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="field">
<ha-svg-icon .path=${mdiCalendarClock}></ha-svg-icon>
<div class="value">
${this._formatDateRange()}<br />
${this._data!.rrule
? this._renderRruleAsText(this._data.rrule)
: ""}
</div>
</div>
<div class="attribute">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
inDialog
></state-info>
</div>
</div>
${this._params.canDelete
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click=${this._deleteEvent}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.calendar.event.delete")}
</mwc-button>
`
: ""}${this._params.canEdit
? html`<mwc-button
slot="primaryAction"
@click=${this._editEvent}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.calendar.event.edit")}
</mwc-button>`
: ""}
</ha-dialog>
`;
}
private _renderRruleAsText(value: string) {
// TODO: Make sure this handles translations
try {
const readableText =
value === "" ? "" : RRule.fromString(`RRULE:${value}`).toText();
return html`<div id="text">${readableText}</div>`;
} 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
);

View File

@ -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<Calendar> = (
item
) => html`<mwc-list-item>
<span>${item.name}</span>
</mwc-list-item>`;
@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<void> {
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`
<ha-dialog
open
@closed=${this._close}
scrimClickAction
escapeKeyAction
.heading=${html`
<div class="header_title">
${isCreate
? this.hass.localize("ui.components.calendar.event.add")
: this._data!.summary}
</div>
<ha-icon-button
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
dialogAction="close"
class="header_button"
></ha-icon-button>
`}
>
<div class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<ha-textfield
class="summary"
name="summary"
.label=${this.hass.localize("ui.components.calendar.event.summary")}
required
@change=${this._handleSummaryChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-combo-box
name="calendar"
.hass=${this.hass}
.label=${this.hass.localize("ui.components.calendar.label")}
.value=${this._calendarId!}
.renderer=${rowRenderer}
.items=${this._calendars}
item-id-path="entity_id"
item-value-path="entity_id"
item-label-path="name"
required
@value-changed=${this._handleCalendarChanged}
></ha-combo-box>
<ha-formfield
.label=${this.hass.localize("ui.components.calendar.event.all_day")}
>
<ha-switch
id="all_day"
.checked=${this._allDay}
@change=${this._allDayToggleChanged}
></ha-switch>
</ha-formfield>
<div>
<span class="label"
>${this.hass.localize(
"ui.components.calendar.event.start"
)}:</span
>
<div class="flex">
<ha-date-input
.value=${this._data!.dtstart}
.locale=${this.hass.locale}
@value-changed=${this._startDateChanged}
></ha-date-input>
${!this._allDay
? html`<ha-time-input
.value=${this._data!.dtstart.split("T")[1]}
.locale=${this.hass.locale}
@value-changed=${this._startTimeChanged}
></ha-time-input>`
: ""}
</div>
</div>
<div>
<span class="label"
>${this.hass.localize("ui.components.calendar.event.end")}:</span
>
<div class="flex">
<ha-date-input
.value=${this._data!.dtend}
.min=${this._data!.dtstart}
.locale=${this.hass.locale}
@value-changed=${this._endDateChanged}
></ha-date-input>
${!this._allDay
? html`<ha-time-input
.value=${this._data!.dtend.split("T")[1]}
.locale=${this.hass.locale}
@value-changed=${this._endTimeChanged}
></ha-time-input>`
: ""}
</div>
</div>
<ha-recurrence-rule-editor
.locale=${this.hass.locale}
.value=${this._data!.rrule || ""}
@value-changed=${this._handleRRuleChanged}
>
</ha-recurrence-rule-editor>
</div>
${isCreate
? html`
<mwc-button
slot="primaryAction"
@click=${this._createEvent}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.calendar.event.add")}
</mwc-button>
`
: html` <mwc-button
slot="primaryAction"
@click=${this._saveEvent}
.disabled=${this._submitting}
>
${this.hass.localize("ui.components.calendar.event.save")}
</mwc-button>
${this._params.canDelete
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click=${this._deleteEvent}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.components.calendar.event.delete"
)}
</mwc-button>
`
: ""}`}
</ha-dialog>
`;
}
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;
}
}

View File

@ -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 {
</div>
`
: ""}
<div id="calendar"></div>
${this._mutableCalendars.length > 0
? html`<ha-fab
slot="fab"
.label=${this.hass.localize("ui.components.calendar.event.add")}
extended
@click=${this._createEvent}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>`
: 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(

View File

@ -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 {
</div>
<ha-full-calendar
.events=${this._events}
.calendars=${this._calendars}
.narrow=${this.narrow}
.hass=${this.hass}
@view-changed=${this._handleViewChanged}

View File

@ -0,0 +1,339 @@
import type { SelectedDetail } from "@material/mwc-list";
import { css, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { Options, WeekdayStr } from "rrule";
import { ByWeekday, RRule, Weekday } from "rrule";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-chip";
import "../../components/ha-list-item";
import "../../components/ha-select";
import type { HaSelect } from "../../components/ha-select";
import "../../components/ha-textfield";
import { HomeAssistant } from "../../types";
import {
convertFrequency,
convertRepeatFrequency,
DEFAULT_COUNT,
getWeekdays,
intervalSuffix,
RepeatEnd,
RepeatFrequency,
ruleByWeekDay,
untilValue,
WEEKDAY_NAME,
} from "./recurrence";
import "../../components/ha-date-input";
@customElement("ha-recurrence-rule-editor")
export class RecurrenceRuleEditor extends LitElement {
@property() public disabled = false;
@property() public value = "";
@property({ attribute: false }) public locale!: HomeAssistant["locale"];
@state() private _computedRRule = "";
@state() private _freq?: RepeatFrequency = "none";
@state() private _interval = 1;
@state() private _weekday: Set<WeekdayStr> = new Set<WeekdayStr>();
@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<Options> | 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<WeekdayStr>(
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`
<ha-select
id="freq"
label="Repeat"
@selected=${this._onRepeatSelected}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
.value=${this._freq}
>
<ha-list-item value="none">None</ha-list-item>
<ha-list-item value="yearly">Yearly</ha-list-item>
<ha-list-item value="monthly">Monthly</ha-list-item>
<ha-list-item value="weekly">Weekly</ha-list-item>
<ha-list-item value="daily">Daily</ha-list-item>
</ha-select>
`;
}
renderMonthly() {
return this.renderInterval();
}
renderWeekly() {
return html`
${this.renderInterval()}
<div class="weekdays">
${this._allWeekdays!.map(
(item) => html`
<ha-chip
.value=${item}
class=${classMap({ active: this._weekday.has(item) })}
@click=${this._onWeekdayToggle}
>${WEEKDAY_NAME[item]}</ha-chip
>
`
)}
</div>
`;
}
renderDaily() {
return this.renderInterval();
}
renderInterval() {
return html`
<ha-textfield
id="interval"
label="Repeat interval"
type="number"
min="1"
.value=${this._interval}
.suffix=${intervalSuffix(this._freq!)}
@change=${this._onIntervalChange}
></ha-textfield>
`;
}
renderEnd() {
return html`
<ha-select
id="end"
label="Ends"
.value=${this._end}
@selected=${this._onEndSelected}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
<ha-list-item value="never">Never</ha-list-item>
<ha-list-item value="after">After</ha-list-item>
<ha-list-item value="on">On</ha-list-item>
</ha-select>
${this._end === "after"
? html`
<ha-textfield
id="after"
label="End after"
type="number"
min="1"
.value=${this._count!}
suffix="ocurrences"
@change=${this._onCountChange}
></ha-textfield>
`
: html``}
${this._end === "on"
? html`
<ha-date-input
id="on"
label="End on"
.locale=${this.locale}
.value=${this._until!.toISOString()}
@value-changed=${this._onUntilChange}
></ha-date-input>
`
: 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<SelectedDetail<number>>) {
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<SelectedDetail<number>>) {
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;
}
}

View File

@ -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<WeekdayStr>
): 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;
}
});
}

View File

@ -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<RecurrenceRange | undefined>((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,
});
});

View File

@ -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,
});
};

View File

@ -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,
});
};

View File

@ -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,

View File

@ -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"

View File

@ -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;

View File

@ -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