import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, TemplateRef, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { TemplatePortal } from '@angular/cdk/portal'; import { MatDialog } from '@angular/material/dialog'; import { MatDrawer } from '@angular/material/sidenav'; import { FullCalendarComponent } from '@fullcalendar/angular'; import { Calendar as FullCalendar } from '@fullcalendar/core'; import dayGridPlugin from '@fullcalendar/daygrid'; import listPlugin from '@fullcalendar/list'; import interactionPlugin from '@fullcalendar/interaction'; import momentPlugin from '@fullcalendar/moment'; import rrulePlugin from '@fullcalendar/rrule'; import timeGridPlugin from '@fullcalendar/timegrid'; import { clone, cloneDeep, isEqual, omit } from 'lodash-es'; import * as moment from 'moment'; import { RRule } from 'rrule'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { FuseMediaWatcherService } from '@fuse/services/media-watcher'; import { CalendarRecurrenceComponent } from 'app/modules/admin/apps/calendar/recurrence/recurrence.component'; import { CalendarService } from 'app/modules/admin/apps/calendar/calendar.service'; import { Calendar, CalendarDrawerMode, CalendarEvent, CalendarEventEditMode, CalendarEventPanelMode, CalendarSettings } from 'app/modules/admin/apps/calendar/calendar.types'; @Component({ selector : 'calendar', templateUrl : './calendar.component.html', styleUrls : ['./calendar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation : ViewEncapsulation.None }) export class CalendarComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('eventPanel') private _eventPanel: TemplateRef; @ViewChild('fullCalendar') private _fullCalendar: FullCalendarComponent; @ViewChild('drawer') private _drawer: MatDrawer; calendars: Calendar[]; calendarPlugins: any[] = [dayGridPlugin, interactionPlugin, listPlugin, momentPlugin, rrulePlugin, timeGridPlugin]; drawerMode: CalendarDrawerMode = 'side'; drawerOpened: boolean = true; event: CalendarEvent; eventEditMode: CalendarEventEditMode = 'single'; eventForm: FormGroup; eventTimeFormat: any; events: CalendarEvent[] = []; panelMode: CalendarEventPanelMode = 'view'; settings: CalendarSettings; view: 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listYear' = 'dayGridMonth'; views: any; viewTitle: string; private _eventPanelOverlayRef: OverlayRef; private _fullCalendarApi: FullCalendar; private _unsubscribeAll: Subject = new Subject(); /** * Constructor */ constructor( private _calendarService: CalendarService, private _changeDetectorRef: ChangeDetectorRef, @Inject(DOCUMENT) private _document: Document, private _formBuilder: FormBuilder, private _matDialog: MatDialog, private _overlay: Overlay, private _fuseMediaWatcherService: FuseMediaWatcherService, private _viewContainerRef: ViewContainerRef ) { } // ----------------------------------------------------------------------------------------------------- // @ Accessors // ----------------------------------------------------------------------------------------------------- /** * Getter for event's recurrence status */ get recurrenceStatus(): string { // Get the recurrence from event form const recurrence = this.eventForm.get('recurrence').value; // Return null, if there is no recurrence on the event if ( !recurrence ) { return null; } // Convert the recurrence rule to text let ruleText = RRule.fromString(recurrence).toText(); ruleText = ruleText.charAt(0).toUpperCase() + ruleText.slice(1); // Return the rule text return ruleText; } // ----------------------------------------------------------------------------------------------------- // @ Lifecycle hooks // ----------------------------------------------------------------------------------------------------- /** * On init */ ngOnInit(): void { // Create the event form this.eventForm = this._formBuilder.group({ id : [''], calendarId : [''], recurringEventId: [null], title : [''], description : [''], start : [null], end : [null], duration : [null], allDay : [true], recurrence : [null], range : [{}] }); // Subscribe to 'range' field value changes this.eventForm.get('range').valueChanges.subscribe((value) => { if ( !value ) { return; } // Set the 'start' field value from the range this.eventForm.get('start').setValue(value.start, {emitEvent: false}); // If this is a recurring event... if ( this.eventForm.get('recurrence').value ) { // Update the recurrence rules if needed this._updateRecurrenceRule(); // Set the duration field const duration = moment(value.end).diff(moment(value.start), 'minutes'); this.eventForm.get('duration').setValue(duration, {emitEvent: false}); // Update the end value this._updateEndValue(); } // Otherwise... else { // Set the end field this.eventForm.get('end').setValue(value.end, {emitEvent: false}); } }); // Subscribe to 'recurrence' field changes this.eventForm.get('recurrence').valueChanges.subscribe((value) => { // If this is a recurring event... if ( value ) { // Update the end value this._updateEndValue(); } }); // Get calendars this._calendarService.calendars$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((calendars) => { // Store the calendars this.calendars = calendars; // Mark for check this._changeDetectorRef.markForCheck(); }); // Get events this._calendarService.events$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((events) => { // Clone the events to change the object reference so // that the FullCalendar can trigger a re-render. this.events = cloneDeep(events); // Mark for check this._changeDetectorRef.markForCheck(); }); // Get settings this._calendarService.settings$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((settings) => { // Store the settings this.settings = settings; // Set the FullCalendar event time format based on the time format setting this.eventTimeFormat = { hour : settings.timeFormat === '12' ? 'numeric' : '2-digit', hour12 : settings.timeFormat === '12', minute : '2-digit', meridiem: settings.timeFormat === '12' ? 'short' : false }; // Mark for check this._changeDetectorRef.markForCheck(); }); // Subscribe to media changes this._fuseMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({matchingAliases}) => { // Set the drawerMode and drawerOpened if the given breakpoint is active if ( matchingAliases.includes('md') ) { this.drawerMode = 'side'; this.drawerOpened = true; } else { this.drawerMode = 'over'; this.drawerOpened = false; } // Mark for check this._changeDetectorRef.markForCheck(); }); // Build the view specific FullCalendar options this.views = { dayGridMonth: { eventLimit : 3, eventTimeFormat: this.eventTimeFormat, fixedWeekCount : false }, timeGrid : { allDayText : '', columnHeaderFormat: { weekday : 'short', day : 'numeric', omitCommas: true }, columnHeaderHtml : (date): string => `${moment(date).format('ddd')} ${moment(date).format('D')}`, slotDuration : '01:00:00', slotLabelFormat : this.eventTimeFormat }, timeGridWeek: {}, timeGridDay : {}, listYear : { allDayText : 'All day', eventTimeFormat : this.eventTimeFormat, listDayFormat : false, listDayAltFormat: false } }; } /** * After view init */ ngAfterViewInit(): void { // Get the full calendar API this._fullCalendarApi = this._fullCalendar.getApi(); // Get the current view's title this.viewTitle = this._fullCalendarApi.view.title; // Get the view's current start and end dates, add/subtract // 60 days to create a ~150 days period to fetch the data for const viewStart = moment(this._fullCalendarApi.view.currentStart).subtract(60, 'days'); const viewEnd = moment(this._fullCalendarApi.view.currentEnd).add(60, 'days'); // Get events this._calendarService.getEvents(viewStart, viewEnd, true).subscribe(); } /** * On destroy */ ngOnDestroy(): void { // Unsubscribe from all subscriptions this._unsubscribeAll.next(); this._unsubscribeAll.complete(); // Dispose the overlay if ( this._eventPanelOverlayRef ) { this._eventPanelOverlayRef.dispose(); } } // ----------------------------------------------------------------------------------------------------- // @ Public methods // ----------------------------------------------------------------------------------------------------- /** * Toggle Drawer */ toggleDrawer(): void { // Toggle the drawer this._drawer.toggle(); } /** * Open recurrence panel */ openRecurrenceDialog(): void { // Open the dialog const dialogRef = this._matDialog.open(CalendarRecurrenceComponent, { panelClass: 'calendar-event-recurrence-dialog', data : { event: this.eventForm.value } }); // After dialog closed dialogRef.afterClosed().subscribe((result) => { // Return if canceled if ( !result || !result.recurrence ) { return; } // Only update the recurrence if it actually changed if ( this.eventForm.get('recurrence').value === result.recurrence ) { return; } // If returned value is 'cleared'... if ( result.recurrence === 'cleared' ) { // Clear the recurrence field if recurrence cleared this.eventForm.get('recurrence').setValue(null); } // Otherwise... else { // Update the recurrence field with the result this.eventForm.get('recurrence').setValue(result.recurrence); } }); } /** * Change the event panel mode between view and edit * mode while setting the event edit mode * * @param panelMode * @param eventEditMode */ changeEventPanelMode(panelMode: CalendarEventPanelMode, eventEditMode: CalendarEventEditMode = 'single'): void { // Set the panel mode this.panelMode = panelMode; // Set the event edit mode this.eventEditMode = eventEditMode; // Update the panel position setTimeout(() => { this._eventPanelOverlayRef.updatePosition(); }); } /** * Get calendar by id * * @param id */ getCalendar(id): Calendar { if ( !id ) { return; } return this.calendars.find(calendar => calendar.id === id); } /** * Change the calendar view * * @param view */ changeView(view: 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listYear'): void { // Store the view this.view = view; // If the FullCalendar API is available... if ( this._fullCalendarApi ) { // Set the view this._fullCalendarApi.changeView(view); // Update the view title this.viewTitle = this._fullCalendarApi.view.title; } } /** * Moves the calendar one stop back */ previous(): void { // Go to previous stop this._fullCalendarApi.prev(); // Update the view title this.viewTitle = this._fullCalendarApi.view.title; // Get the view's current start date const start = moment(this._fullCalendarApi.view.currentStart); // Prefetch past events this._calendarService.prefetchPastEvents(start).subscribe(); } /** * Moves the calendar to the current date */ today(): void { // Go to today this._fullCalendarApi.today(); // Update the view title this.viewTitle = this._fullCalendarApi.view.title; } /** * Moves the calendar one stop forward */ next(): void { // Go to next stop this._fullCalendarApi.next(); // Update the view title this.viewTitle = this._fullCalendarApi.view.title; // Get the view's current end date const end = moment(this._fullCalendarApi.view.currentEnd); // Prefetch future events this._calendarService.prefetchFutureEvents(end).subscribe(); } /** * On date click * * @param calendarEvent */ onDateClick(calendarEvent): void { // Prepare the event const event = { id : null, calendarId : this.calendars[0].id, recurringEventId: null, isFirstInstance : false, title : '', description : '', start : moment(calendarEvent.date).startOf('day').toISOString(), end : moment(calendarEvent.date).endOf('day').toISOString(), duration : null, allDay : true, recurrence : null, range : { start: moment(calendarEvent.date).startOf('day').toISOString(), end : moment(calendarEvent.date).endOf('day').toISOString() } }; // Set the event this.event = event; // Set the el on calendarEvent for consistency calendarEvent.el = calendarEvent.dayEl; // Reset the form and fill the event this.eventForm.reset(); this.eventForm.patchValue(event); // Open the event panel this._openEventPanel(calendarEvent); // Change the event panel mode this.changeEventPanelMode('add'); } /** * On event click * * @param calendarEvent */ onEventClick(calendarEvent): void { // Find the event with the clicked event's id const event: any = cloneDeep(this.events.find(item => item.id === calendarEvent.event.id)); // Set the event this.event = event; // Prepare the end value let end; // If this is a recurring event... if ( event.recuringEventId ) { // Calculate the end value using the duration end = moment(event.start).add(event.duration, 'minutes').toISOString(); } // Otherwise... else { // Set the end value from the end end = event.end; } // Set the range on the event event.range = { start: event.start, end }; // Reset the form and fill the event this.eventForm.reset(); this.eventForm.patchValue(event); // Open the event panel this._openEventPanel(calendarEvent); } /** * On event render * * @param calendarEvent */ onEventRender(calendarEvent): void { // Get event's calendar const calendar = this.calendars.find(item => item.id === calendarEvent.event.extendedProps.calendarId); // Return if the calendar doesn't exist... if ( !calendar ) { return; } // If current view is year list... if ( this.view === 'listYear' ) { // Create a new 'fc-list-item-date' node const fcListItemDate1 = ` ${moment(calendarEvent.event.start).format('D')} ${moment(calendarEvent.event.start).format('MMM')}, ${moment(calendarEvent.event.start).format('ddd')} `; // Insert the 'fc-list-item-date' into the calendar event element calendarEvent.el.insertAdjacentHTML('afterbegin', fcListItemDate1); // Set the color class of the event dot calendarEvent.el.getElementsByClassName('fc-event-dot')[0].classList.add(calendar.color); // Set the event's title to '(No title)' if event title is not available if ( !calendarEvent.event.title ) { calendarEvent.el.querySelector('.fc-list-item-title').innerText = '(No title)'; } } // If current view is not month list... else { // Set the color class of the event calendarEvent.el.classList.add(calendar.color); // Set the event's title to '(No title)' if event title is not available if ( !calendarEvent.event.title ) { calendarEvent.el.querySelector('.fc-title').innerText = '(No title)'; } } // Set the event's visibility calendarEvent.el.style.display = calendar.visible ? 'flex' : 'none'; } /** * On calendar updated * * @param calendar */ onCalendarUpdated(calendar): void { // Re-render the events this._fullCalendarApi.rerenderEvents(); } /** * Add event */ addEvent(): void { // Get the clone of the event form value let newEvent = clone(this.eventForm.value); // If the event is a recurring event... if ( newEvent.recurrence ) { // Set the event duration newEvent.duration = moment(newEvent.range.end).diff(moment(newEvent.range.start), 'minutes'); } // Modify the event before sending it to the server newEvent = omit(newEvent, ['range', 'recurringEventId']); // Add the event this._calendarService.addEvent(newEvent).subscribe(() => { // Reload events this._calendarService.reloadEvents().subscribe(); // Close the event panel this._closeEventPanel(); }); } /** * Update the event */ updateEvent(): void { // Get the clone of the event form value let event = clone(this.eventForm.value); const { range, ...eventWithoutRange } = event; // Get the original event const originalEvent = this.events.find(item => item.id === event.id); // Return if there are no changes made to the event if ( isEqual(eventWithoutRange, originalEvent) ) { // Close the event panel this._closeEventPanel(); // Return return; } // If the event is a recurring event... if ( event.recurrence && event.recurringEventId ) { // Update the recurring event on the server this._calendarService.updateRecurringEvent(event, originalEvent, this.eventEditMode).subscribe(() => { // Reload events this._calendarService.reloadEvents().subscribe(); // Close the event panel this._closeEventPanel(); }); // Return return; } // If the event is a non-recurring event... if ( !event.recurrence && !event.recurringEventId ) { // Update the event on the server this._calendarService.updateEvent(event.id, event).subscribe(() => { // Close the event panel this._closeEventPanel(); }); // Return return; } // If the event was a non-recurring event but now it will be a recurring event... if ( event.recurrence && !event.recurringEventId ) { // Set the event duration event.duration = moment(event.range.end).diff(moment(event.range.start), 'minutes'); // Omit unnecessary fields event = omit(event, ['range', 'recurringEventId']); // Update the event on the server this._calendarService.updateEvent(event.id, event).subscribe(() => { // Reload events this._calendarService.reloadEvents().subscribe(); // Close the event panel this._closeEventPanel(); }); // Return return; } // If the event was a recurring event but now it will be a non-recurring event... if ( !event.recurrence && event.recurringEventId ) { // Set the end date event.end = moment(event.start).add(event.duration, 'minutes').toISOString(); // Set the duration as null event.duration = null; // Update the recurring event on the server this._calendarService.updateRecurringEvent(event, originalEvent, this.eventEditMode).subscribe(() => { // Reload events this._calendarService.reloadEvents().subscribe(); // Close the event panel this._closeEventPanel(); }); } } /** * Delete the given event * * @param event * @param mode */ deleteEvent(event, mode: CalendarEventEditMode = 'single'): void { // If the event is a recurring event... if ( event.recurrence ) { // Delete the recurring event on the server this._calendarService.deleteRecurringEvent(event, mode).subscribe(() => { // Reload events this._calendarService.reloadEvents().subscribe(); // Close the event panel this._closeEventPanel(); }); } // If the event is a non-recurring, normal event... else { // Update the event on the server this._calendarService.deleteEvent(event.id).subscribe(() => { // Close the event panel this._closeEventPanel(); }); } } // ----------------------------------------------------------------------------------------------------- // @ Private methods // ----------------------------------------------------------------------------------------------------- /** * Create the event panel overlay * * @private */ private _createEventPanelOverlay(positionStrategy): void { // Create the overlay this._eventPanelOverlayRef = this._overlay.create({ panelClass : ['calendar-event-panel'], backdropClass : '', hasBackdrop : true, scrollStrategy: this._overlay.scrollStrategies.reposition(), positionStrategy }); // Detach the overlay from the portal on backdrop click this._eventPanelOverlayRef.backdropClick().subscribe(() => { this._closeEventPanel(); }); } /** * Open the event panel * * @private */ private _openEventPanel(calendarEvent): void { const positionStrategy = this._overlay.position().flexibleConnectedTo(calendarEvent.el).withFlexibleDimensions(false).withPositions([ { originX : 'end', originY : 'top', overlayX: 'start', overlayY: 'top', offsetX : 8 }, { originX : 'start', originY : 'top', overlayX: 'end', overlayY: 'top', offsetX : -8 }, { originX : 'start', originY : 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX : -8 }, { originX : 'end', originY : 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX : 8 } ]); // Create the overlay if it doesn't exist if ( !this._eventPanelOverlayRef ) { this._createEventPanelOverlay(positionStrategy); } // Otherwise, just update the position else { this._eventPanelOverlayRef.updatePositionStrategy(positionStrategy); } // Attach the portal to the overlay this._eventPanelOverlayRef.attach(new TemplatePortal(this._eventPanel, this._viewContainerRef)); // Mark for check this._changeDetectorRef.markForCheck(); } /** * Close the event panel * * @private */ private _closeEventPanel(): void { // Detach the overlay from the portal this._eventPanelOverlayRef.detach(); // Reset the panel and event edit modes this.panelMode = 'view'; this.eventEditMode = 'single'; // Mark for check this._changeDetectorRef.markForCheck(); } /** * Update the recurrence rule based on the event if needed * * @private */ private _updateRecurrenceRule(): void { // Get the event const event = this.eventForm.value; // Return if this is a non-recurring event if ( !event.recurrence ) { return; } // Parse the recurrence rule const parsedRules = {}; event.recurrence.split(';').forEach((rule) => { // Split the rule const parsedRule = rule.split('='); // Add the rule to the parsed rules parsedRules[parsedRule[0]] = parsedRule[1]; }); // If there is a BYDAY rule, split that as well if ( parsedRules['BYDAY'] ) { parsedRules['BYDAY'] = parsedRules['BYDAY'].split(','); } // Do not update the recurrence rule if ... // ... the frequency is DAILY, // ... the frequency is WEEKLY and BYDAY has multiple values, // ... the frequency is MONTHLY and there isn't a BYDAY rule, // ... the frequency is YEARLY, if ( parsedRules['FREQ'] === 'DAILY' || (parsedRules['FREQ'] === 'WEEKLY' && parsedRules['BYDAY'].length > 1) || (parsedRules['FREQ'] === 'MONTHLY' && !parsedRules['BYDAY']) || parsedRules['FREQ'] === 'YEARLY' ) { return; } // If the frequency is WEEKLY, update the BYDAY value with the new one if ( parsedRules['FREQ'] === 'WEEKLY' ) { parsedRules['BYDAY'] = [moment(event.start).format('dd').toUpperCase()]; } // If the frequency is MONTHLY, update the BYDAY value with the new one if ( parsedRules['FREQ'] === 'MONTHLY' ) { // Calculate the weekday const weekday = moment(event.start).format('dd').toUpperCase(); // Calculate the nthWeekday let nthWeekdayNo = 1; while ( moment(event.start).isSame(moment(event.start).subtract(nthWeekdayNo, 'week'), 'month') ) { nthWeekdayNo++; } // Set the BYDAY parsedRules['BYDAY'] = [nthWeekdayNo + weekday]; } // Generate the rule string from the parsed rules const rules = []; Object.keys(parsedRules).forEach((key) => { rules.push(key + '=' + (Array.isArray(parsedRules[key]) ? parsedRules[key].join(',') : parsedRules[key])); }); const rrule = rules.join(';'); // Update the recurrence rule this.eventForm.get('recurrence').setValue(rrule); } /** * Update the end value based on the recurrence and duration * * @private */ private _updateEndValue(): void { // Get the event recurrence const recurrence = this.eventForm.get('recurrence').value; // Return if this is a non-recurring event if ( !recurrence ) { return; } // Parse the recurrence rule const parsedRules = {}; recurrence.split(';').forEach((rule) => { // Split the rule const parsedRule = rule.split('='); // Add the rule to the parsed rules parsedRules[parsedRule[0]] = parsedRule[1]; }); // If there is an UNTIL rule... if ( parsedRules['UNTIL'] ) { // Use that to set the end date this.eventForm.get('end').setValue(parsedRules['UNTIL']); // Return return; } // If there is a COUNT rule... if ( parsedRules['COUNT'] ) { // Generate the RRule string const rrule = 'DTSTART=' + moment(this.eventForm.get('start').value).utc().format('YYYYMMDD[T]HHmmss[Z]') + '\nRRULE:' + recurrence; // Use RRule string to generate dates const dates = RRule.fromString(rrule).all(); // Get the last date from dates array and set that as the end date this.eventForm.get('end').setValue(moment(dates[dates.length - 1]).toISOString()); // Return return; } // If there are no UNTIL or COUNT, set the end date to a fixed value this.eventForm.get('end').setValue(moment().year(9999).endOf('year').toISOString()); } }