mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
Add Calendar Event creation and deletion dialogs (#14036)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
e43f3b193e
commit
9b6e33cfec
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)}
|
||||
|
@ -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,
|
||||
});
|
||||
|
149
src/panels/calendar/confirm-event-dialog-box.ts
Normal file
149
src/panels/calendar/confirm-event-dialog-box.ts
Normal 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;
|
||||
}
|
||||
}
|
244
src/panels/calendar/dialog-calendar-event-detail.ts
Normal file
244
src/panels/calendar/dialog-calendar-event-detail.ts
Normal 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
|
||||
);
|
438
src/panels/calendar/dialog-calendar-event-editor.ts
Normal file
438
src/panels/calendar/dialog-calendar-event-editor.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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}
|
||||
|
339
src/panels/calendar/ha-recurrence-rule-editor.ts
Normal file
339
src/panels/calendar/ha-recurrence-rule-editor.ts
Normal 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;
|
||||
}
|
||||
}
|
139
src/panels/calendar/recurrence.ts
Normal file
139
src/panels/calendar/recurrence.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
44
src/panels/calendar/show-confirm-event-dialog-box.ts
Normal file
44
src/panels/calendar/show-confirm-event-dialog-box.ts
Normal 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,
|
||||
});
|
||||
});
|
25
src/panels/calendar/show-dialog-calendar-event-detail.ts
Normal file
25
src/panels/calendar/show-dialog-calendar-event-detail.ts
Normal 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,
|
||||
});
|
||||
};
|
24
src/panels/calendar/show-dialog-calendar-event-editor.ts
Normal file
24
src/panels/calendar/show-dialog-calendar-event-editor.ts
Normal 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,
|
||||
});
|
||||
};
|
@ -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,
|
||||
|
@ -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"
|
||||
|
11
src/types.ts
11
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;
|
||||
|
18
yarn.lock
18
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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user