@eclipse-scout/core
Version:
Eclipse Scout runtime
522 lines (449 loc) • 17.8 kB
text/typescript
/*
* Copyright (c) 2010, 2024 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {aria, Calendar, CalendarComponentEventMap, CalendarComponentModel, DateRange, dates, icons, InitModelOf, JsonDateRange, Label, Popup, Range, scout, strings, Widget, WidgetPopup} from '../index';
import $ from 'jquery';
export class CalendarComponent extends Widget implements CalendarComponentModel {
declare model: CalendarComponentModel;
declare eventMap: CalendarComponentEventMap;
declare self: CalendarComponent;
declare parent: Calendar;
fromDate: string;
toDate: string;
selected: boolean;
fullDay: boolean;
fullDayIndex: number;
draggable: boolean;
item: CalendarItem;
stack: Record<string, { x?: number; w?: number }>;
coveredDaysRange: DateRange;
/** @internal */
_$parts: JQuery[];
constructor() {
super();
this.selected = false;
this.fullDay = false;
this.fullDayIndex = -1;
this.item = null;
this._$parts = [];
}
/**
* If day of a month is smaller than 100px, the components get the class compact
*/
static MONTH_COMPACT_THRESHOLD = 100;
static DAY_OF_MONTH_HEIGHT = 30;
static COMPONENT_HEIGHT = 24;
static COMPONENT_VGAP = 2;
protected override _init(model: InitModelOf<this>) {
super._init(model);
this._syncCoveredDaysRange(model.coveredDaysRange as JsonDateRange);
}
protected _syncCoveredDaysRange(coveredDaysRange: JsonDateRange) {
if (coveredDaysRange) {
this.coveredDaysRange = new DateRange(
dates.parseJsonDate(coveredDaysRange.from),
dates.parseJsonDate(coveredDaysRange.to));
}
}
protected override _remove() {
// remove $parts because they're not children of this.$container
this._$parts.forEach($part => $part.remove());
this._$parts = [];
super._remove();
}
protected _startLoopDay(): Date {
// start date is either beginning of the component or beginning of viewRange
if (dates.compare(this.coveredDaysRange.from, this.parent.viewRange.from) > 0) {
return this.coveredDaysRange.from;
}
return this.parent.viewRange.from;
}
protected override _render() {
let partDay: Date, $day: JQuery, $part: JQuery;
if (!this.coveredDaysRange) {
// coveredDaysRange is not set on current CalendarComponent. Cannot show calendar component without from and to values.
return;
}
// Calculate visible
let resource = this.parent.findResourceForComponent(this);
this.setVisible(resource.visible);
if (!this.visible) {
return;
}
let loopDay = this._startLoopDay();
let item = this.item || {} as CalendarItem;
let appointmentToDate: Date | string = dates.parseJsonDate(this.toDate);
let appointmentFromDate = dates.parseJsonDate(this.fromDate);
let coveredDaysRangeTo = this.coveredDaysRange.to;
let calendarCssClass = resource ? resource.cssClass : null;
if (!this.fullDay) {
let truncToDate = dates.trunc(appointmentToDate);
if (!dates.isSameDay(appointmentFromDate, appointmentToDate) && dates.compare(appointmentToDate, truncToDate) === 0) {
appointmentToDate = dates.shiftTime(appointmentToDate, 0, 0, 0, -1);
coveredDaysRangeTo = dates.shift(coveredDaysRangeTo, 0, 0, -1);
}
}
appointmentToDate = dates.toJsonDate(appointmentToDate);
let lastComponentDay = dates.shift(coveredDaysRangeTo, 0, 0, 1);
if (dates.compare(loopDay, lastComponentDay) > 0) {
// start day for the while loop is greater than the exit condition
return;
}
while (!dates.isSameDay(loopDay, lastComponentDay)) {
partDay = loopDay;
loopDay = dates.shift(loopDay, 0, 0, 1); // increase day for loop
// check if day is in visible view range
if (dates.compare(partDay, this.parent.viewRange.to) > 0) {
// break condition, partDay is now out of range.
break;
}
if (this.fullDay && !this.parent.isMonth()) {
$day = this._findDayInGrid(partDay, this.parent.$topGrid);
} else {
$day = this._findDayInGrid(partDay, this.parent.$grid);
}
if (!$day) {
// next day, partDay not found in grid
continue;
}
// Find corresponding calendar
let $calendar = this._findResourceColumnInDay($day, resource.resourceId);
if (!$calendar) {
continue;
}
$part = $calendar.appendDiv('calendar-component');
$part
.addClass(item.cssClass)
.addClass(calendarCssClass)
.data('component', this)
.data('partDay', partDay)
.on('mouseenter', this._onMouseEnter.bind(this))
.on('mouseleave', this._onMouseLeave.bind(this))
.on('mousedown', this._onMouseDown.bind(this))
.on('contextmenu', this._onContextMenu.bind(this));
$part.appendDiv('calendar-component-leftcolorborder');
let $partContent = $part.appendDiv('content');
if (item.subjectIconId) {
$partContent.appendIcon(this.item.subjectIconId);
}
$partContent.appendSpan('subject', item.subject);
this._$parts.push($part);
if (this.parent.isMonth()) {
let width = $day.data('new-width') || $day.width(); // prefer width from layoutSize
$part.addClass('component-month')
.toggleClass('compact', width < CalendarComponent.MONTH_COMPACT_THRESHOLD);
} else {
if (this.fullDay) {
// Full day tasks are rendered in the topGrid
// Offset of initial task: CalendarComponent.DAY_OF_MONTH_HEIGHT
// Offset of following tasks: (CalendarComponent.COMPONENT_HEIGHT + CalendarComponent.COMPONENT_VGAP) * preceding number of tasks
this._arrangeTask(CalendarComponent.DAY_OF_MONTH_HEIGHT + (CalendarComponent.COMPONENT_HEIGHT + CalendarComponent.COMPONENT_VGAP) * Math.max(this.fullDayIndex, 0));
$part.addClass('component-task');
} else {
let
fromDate = dates.parseJsonDate(this.fromDate),
toDate = dates.parseJsonDate(appointmentToDate),
partFrom = this._getHours(this.fromDate),
partTo = this._getHours(appointmentToDate);
// position and height depending on start and end date
$part.addClass('component-day');
if (dates.isSameDay(dates.trunc(this.coveredDaysRange.from), dates.trunc(coveredDaysRangeTo))) {
this._partPosition($part, partFrom, partTo);
} else if (dates.isSameDay(partDay, fromDate)) {
this._partPosition($part, partFrom, 25) // 25: indicate that it takes longer than that day
.addClass('component-open-bottom');
} else if (dates.isSameDay(partDay, toDate)) {
// Start at zero: No need to indicate that it starts earlier since indicator needs no extra space
this._partPosition($part, 0, partTo)
.addClass('component-open-top');
} else {
this._partPosition($part, 0, 25) // 25: indicate that it takes longer than that day
.addClass('component-open-top')
.addClass('component-open-bottom');
}
}
}
}
}
protected _getHours(date: string): number {
let d = dates.parseJsonDate(date);
return d.getHours() + d.getMinutes() / 60;
}
getLengthInHoursDecimal(): number {
let toTimestamp = dates.parseJsonDate(this.toDate);
let fromTimestamp = dates.parseJsonDate(this.fromDate);
return (toTimestamp.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60);
}
getResourceId(): string {
if (!this.item || !this.parent.isLeafResource(this.item.resourceId)) {
return this.parent.defaultResource.resourceId;
}
return this.item.resourceId;
}
protected _findDayInGrid(date: Date, $grid: JQuery): JQuery {
return $grid.find('.calendar-day')
.filter(function(i, elem) {
return dates.isSameDay($(this).data('date'), date);
}).eq(0);
}
protected _findResourceColumnInDay($day: JQuery, resouceId: string): JQuery {
if (!this.parent.isDay()) {
resouceId = this.parent.defaultResource.resourceId;
}
// Validate resourceId
resouceId = this.parent.findResourceForId(resouceId).resourceId;
return $day.find('.resource-column')
.filter((index, element) => $(element).data('resourceId') === resouceId);
}
protected _isTask(): boolean {
return !this.parent.isMonth() && this.fullDay;
}
protected _arrangeTask(taskOffset: number) {
this._$parts.forEach($part => $part.css('top', taskOffset + 'px'));
}
protected _isDayPart(): boolean {
return !this.parent.isMonth() && !this.fullDay;
}
protected _getHourRange(day: Date): Range {
let hourRange = new Range(this._getHours(this.fromDate), this._getHours(this.toDate));
let dateRange = new DateRange(dates.parseJsonDate(this.fromDate), dates.parseJsonDate(this.toDate));
if (dates.isSameDay(day, dateRange.from) && dates.isSameDay(day, dateRange.to)) {
return new Range(hourRange.from, hourRange.to);
}
if (dates.isSameDay(day, dateRange.from)) {
return new Range(hourRange.from, 24);
}
if (dates.isSameDay(day, dateRange.to)) {
return new Range(0, hourRange.to);
}
return new Range(0, 24);
}
getPartDayPosition(day: Date): Range {
return this._getDisplayDayPosition(this._getHourRange(day));
}
protected _getDisplayDayPosition(range: Range): Range {
// Doesn't support minutes yet...
let preferredRange = new Range(this.parent._dayPosition(range.from, 0), this.parent._dayPosition(range.to, 0));
// Fixed number of divisions...
let minRangeSize = Math.round(100 * 100 / 24 / this.parent.numberOfHourDivisions) / 100; // Round to two digits
if (preferredRange.size() < minRangeSize) {
return new Range(preferredRange.from, preferredRange.from + minRangeSize);
}
return preferredRange;
}
protected _partPosition($part: JQuery, y1: number, y2: number): JQuery {
// Compensate open bottom (height: square of 16px, rotated 45°, approx. 23px = sqrt(16^2 + 16^2)
let compensateBottom = y2 === 25 ? 23 : 0;
y2 = Math.min(24, y2);
let range = new Range(y1, y2);
let r = this._getDisplayDayPosition(range);
// Convert to %, rounded to two decimal places
compensateBottom = Math.round(100 * (100 / 1920 * compensateBottom)) / 100;
return $part
.css('top', r.from + '%')
.css('height', r.to - r.from - compensateBottom + '%');
}
protected override _renderProperties() {
super._renderProperties();
this._renderSelected();
}
protected _renderSelected() {
this._$parts.forEach($part => $part.toggleClass('comp-selected', this.selected));
}
setSelected(selected: boolean) {
this.setProperty('selected', selected);
}
updateSelectedComponent($part: JQuery, updateScrollPosition: boolean) {
this.parent._selectedComponentChanged(this, this.getResourceId(), $part.data('partDay') as Date, updateScrollPosition);
}
protected _onMouseEnter(event: JQuery.MouseEnterEvent) {
this._$parts.forEach($part => $part.addClass('hover'));
}
protected _onMouseLeave(evenet: JQuery.MouseLeaveEvent) {
this._$parts.forEach($part => $part.removeClass('hover'));
}
protected _onMouseDown(event: JQuery.MouseDownEvent) {
this.applySelection($(event.delegateTarget), event.button === 0, event.originalEvent.clientY);
}
applySelection($part: JQuery, openPopup: boolean, popupY: number) {
// don't show popup if dragging is in process
if (this.parent._moveData && this.parent._moveData.moving) {
return;
}
// don't show popup when range selection is in progress
if (this.parent._rangeSelectionStarted) {
return;
}
this.updateSelectedComponent($part, false);
this.parent.setSelectedRange(null);
if (openPopup) {
let popup = scout.create((WidgetPopup<Label>), {
parent: this.parent,
$anchor: $part,
closeOnAnchorMouseDown: true,
closeOnMouseDownOutside: true,
closeOnOtherPopupOpen: true,
horizontalAlignment: Popup.Alignment.LEFT,
verticalAlignment: Popup.Alignment.CENTER,
trimWidth: false,
trimHeight: true,
horizontalSwitch: true,
verticalSwitch: false,
withArrow: true,
cssClass: 'popup',
scrollType: 'remove',
location: {
y: popupY
},
content: {
objectType: Label,
htmlEnabled: true,
scrollable: true,
cssClass: 'calendar-component-tooltip-content tooltip-content',
value: this.description(true)
}
});
popup.open();
popup.$container.find('.app-link')
.on('click', this._onAppLinkAction.bind(this));
}
}
/** @internal */
_onContextMenu(event: JQuery.ContextMenuEvent) {
this.parent._showContextMenu(event, Calendar.MenuType.CalendarComponent);
}
protected _format(date: Date, pattern: string): string {
return dates.format(date, this.session.locale, pattern);
}
description(linkAllowed: boolean): string {
let range = null,
$container = $('<div>'),
fromDate = dates.parseJsonDate(this.fromDate),
toDate = dates.parseJsonDate(this.toDate),
descriptionAvailable = strings.hasText(this.item.description) || this.item.descriptionElements;
let $header = $container.appendDiv('calendar-component-header');
if (descriptionAvailable) {
$header.addClass('with-description');
}
// subject
if (strings.hasText(this.item.subject)) {
if (strings.hasText(this.item.subjectLabel)) {
$header.appendDiv('calendar-component-title-label', this.item.subjectLabel);
}
let $subject = $header.appendDiv('calendar-component-title', this.item.subject);
if (linkAllowed && strings.hasText(this.item.subjectAppLink)) {
$subject.addClass('app-link').attr('data-ref', this.item.subjectAppLink);
aria.role($subject, 'link');
}
}
// time-range
if (this.fullDay) {
// NOP
} else if (dates.isSameDay(fromDate, toDate)) {
range = this.session.text('ui.FromXToY', this._format(fromDate, 'HH:mm'), this._format(toDate, 'HH:mm'));
} else {
range = this.session.text('ui.FromXToY', this._format(fromDate, 'EEEE HH:mm '), this._format(toDate, 'EEEE HH:mm'));
}
if (strings.hasText(range)) {
let $timeContainer = $header.appendDiv('calendar-component-intro');
$timeContainer.appendIcon(icons.CLOCK);
$timeContainer.appendSpan('', range);
}
if (descriptionAvailable) {
let $description = $container.appendDiv('calendar-component-description-container');
// description
if (strings.hasText(this.item.description)) {
$description.appendSpan('calendar-component-description').html(strings.nl2br(this.item.description));
}
if (this.item.descriptionElements) {
let descriptionIconExists = false;
for (let i = 0; i < this.item.descriptionElements.length; i++) {
if (this.item.descriptionElements[i].iconId) {
descriptionIconExists = true;
break;
}
}
for (let i = 0; i < this.item.descriptionElements.length; i++) {
let descriptionElement = this.item.descriptionElements[i];
let $descriptionElementContainer = $description.appendDiv('calendar-component-description-element');
if (i === 0) {
$descriptionElementContainer.addClass('first');
}
if (i === this.item.descriptionElements.length - 1) {
$descriptionElementContainer.addClass('last');
}
if (strings.hasText(descriptionElement.iconId) || descriptionIconExists) {
$descriptionElementContainer.appendIcon(descriptionElement.iconId);
}
let $text = $descriptionElementContainer.appendDiv('text').html(strings.nl2br(descriptionElement.text));
if (linkAllowed && strings.hasText(descriptionElement.appLink)) {
$text.addClass(' app-link').attr('data-ref', descriptionElement.appLink);
aria.role($text, 'link');
}
}
}
}
return $container[0].innerHTML;
}
triggerAppLinkAction(ref: string) {
this.trigger('appLinkAction', {
ref: ref
});
}
_onAppLinkAction(event: JQuery.ClickEvent) {
let $target = $(event.delegateTarget);
let ref = $target.data('ref') as string;
this.triggerAppLinkAction(ref);
}
}
/**
* See JsonCalendarItem.java
*/
export type CalendarItem = {
exists: boolean;
lastModified: number;
itemId: any;
owner: string;
cssClass: string;
resourceId: string;
subject: string;
description: string;
recurrencePattern: {
lastModified: number;
regenerate: boolean;
startTimeMinutes: number;
endTimeMinutes: number;
durationMinutes: number;
firstDate: Date;
lastDate: Date;
occurrences: number;
noEndDate: boolean;
/**
* @see RecurrencePattern.java TYPE* constants
*/
type: number;
interval: number;
/**
* @see RecurrencePattern.java INST_* constants
*/
instance: number;
dayOfWeekBits: number;
dayOfMonth: number;
monthOfYear: number;
};
subjectLabel: string;
subjectAppLink: string;
subjectIconId: string;
descriptionElements: CalendarItemDescriptionElement[];
};
export type CalendarItemDescriptionElement = {
text: string;
iconId: string;
appLink: string;
};