@progress/kendo-angular-scheduler
Version:
Kendo UI Scheduler Angular - Outlook or Google-style angular scheduler calendar. Full-featured and customizable embedded scheduling from the creator developers trust for professional UI components.
405 lines (404 loc) • 16.8 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { orderBy } from '@progress/kendo-data-query';
import { intersects, findRowIndex, addUTCDays, rectContains, dateWithTime } from '../utils';
import { ItemMap } from '../view-items/item-map';
import { BaseSlotService } from '../view-items/base-slot.service';
import { BORDER_WIDTH, DEFAULT_EVENT_HEIGHT, EVENT_SPACING, MORE_BUTTON_HEIGHT } from '../constants';
/**
* @hidden
*/
export class SlotRange {
index;
get slots() {
return this.slotMap.toArray();
}
get items() {
return this.itemMap.toArray();
}
slotMap = new ItemMap();
itemMap = new ItemMap();
get start() {
const first = this.slotMap.first;
if (!first) {
return null;
}
return first.start;
}
get end() {
const last = this.slotMap.last;
if (!last) {
return null;
}
return addUTCDays(last.end, 1);
}
get hasSlots() {
return this.slotMap.count > 0;
}
get hasItems() {
return this.itemMap.count > 0;
}
get firstSlot() {
return this.slotMap.first;
}
get lastSlot() {
return this.slotMap.last;
}
get rect() {
const first = this.firstSlot.rect;
const last = this.lastSlot.rect;
return {
left: first.left,
top: first.top,
width: last.left - first.left + last.width,
height: last.top - first.top + last.height
};
}
constructor(index) {
this.index = index;
}
registerItem(component) {
this.itemMap.addItem(component.item.index, component);
}
unregisterItem(component, index) {
this.itemMap.removeItem(index, component);
}
registerSlot(slot) {
this.slotMap.addItem(slot.id.index, slot);
}
unregisterSlot(slot) {
this.slotMap.removeItem(slot.id.index, slot);
}
layout(eventHeight, eventsPerDay, adaptiveSlotHeight) {
const dateHeader = this.slots[0].element.nativeElement.firstElementChild;
if (!this.hasItems) {
if (eventHeight !== 'auto') {
const eventsTotalHeight = adaptiveSlotHeight ? eventHeight : eventHeight * eventsPerDay;
const height = (eventsTotalHeight + EVENT_SPACING) + dateHeader.offsetHeight + MORE_BUTTON_HEIGHT + EVENT_SPACING;
this.slots.forEach(slot => {
slot.height = height;
slot.element.nativeElement.style.height = height + 'px';
});
}
return;
}
const items = this.items;
const sorted = orderBy(items, [{ field: "item.startTime", dir: "asc" }, { field: "item.endTime", dir: "desc" }]);
const slotItems = {};
const slots = this.slots;
sorted.forEach(event => slots
.filter(slot => intersects(event.item.startTime, event.item.endTime, slot.start, slot.end))
.forEach(slot => {
const value = slotItems[slot.key] = slotItems[slot.key] || { events: [], height: slot.linkHeight };
value.slot = slot;
const rect = slot.rect;
const data = event.item.data[event.resourceIndex];
data.rowIndex = findRowIndex(value.events, data);
let showMore;
const _eventHeight = eventHeight === 'auto' ? DEFAULT_EVENT_HEIGHT : eventHeight;
if (eventHeight === 'auto' || eventsPerDay) {
showMore = eventsPerDay && eventsPerDay !== 'auto' && data.rowIndex >= eventsPerDay;
}
else {
showMore = value.height + _eventHeight + EVENT_SPACING + MORE_BUTTON_HEIGHT > rect.height || data.hidden;
}
if (showMore) {
data.hidden = true;
// Needed for when eventHeight is 'auto' in order to render the button at later point
slot.hasShowMore = true;
// If eventHeight is 'auto', showMore button needs to be displayed after slot accumulated height is calculated
if (eventHeight !== 'auto') {
slot.showMore({ width: rect.width, left: rect.left, top: rect.top + slot.linkHeight + ((data.rowIndex) * (eventHeight + EVENT_SPACING)) });
}
}
else {
if (eventHeight === 'auto') {
// If multiday event spanning on new row (group), it will be rendered on top (1st item)
// for simplicity and for consistency with jQuery's implementation
if (event.item.tail) {
event.item.data[event.resourceIndex].rowIndex = 0;
}
if (value.events[data.rowIndex]) {
event.item.data[event.resourceIndex].rowIndex = value.events.length;
}
}
value.events[data.rowIndex] = event;
if (eventHeight !== 'auto') {
// eventHeight is fixed => each event can be rendered on the go
if (!event.rect) {
event.rect = {
top: rect.top + slot.linkHeight + (data.rowIndex * (eventHeight + EVENT_SPACING)),
left: rect.left,
height: eventHeight,
width: 0
};
}
event.rect.width += rect.width + BORDER_WIDTH;
value.height += eventHeight + EVENT_SPACING;
// Calculate tha actual rendered items to be able to calculate the height
if (adaptiveSlotHeight) {
slots.forEach(_slot => {
if (_slot.key === slot.key) {
_slot.eventsCount = !_slot.eventsCount || data.rowIndex + 1 > _slot.eventsCount ? data.rowIndex + 1 : _slot.eventsCount;
}
});
}
}
}
}));
if (eventHeight === 'auto') {
// eventHeight is 'auto' => first get all slotItems for a slot and then render them, after they are in the correct rendering order (L140-L146)
this.renderAutoHeightEvents(slotItems, dateHeader);
}
else if (eventsPerDay) {
slots.forEach(slot => {
const multiplier = !adaptiveSlotHeight ? eventsPerDay : slot.eventsCount;
const height = BORDER_WIDTH +
dateHeader.offsetHeight +
EVENT_SPACING +
(multiplier * (eventHeight + EVENT_SPACING)) +
MORE_BUTTON_HEIGHT +
EVENT_SPACING;
slot.eventsCount = 0;
slot.height = height;
slot.element.nativeElement.style.height = height + 'px';
});
}
sorted.forEach(event => {
if (event.rect) {
event.rect.width -= BORDER_WIDTH;
}
event.reflow();
});
}
renderAutoHeightEvents(slotItems, dateHeader) {
// Iterate through the slotItems
Object.keys(slotItems).forEach((key) => {
const slotItem = slotItems[key];
const slotRect = slotItem.slot.rect;
// Iterate over each event in the events array
let accumulatedHeight = dateHeader.offsetHeight;
slotItem.events.forEach((event, index) => {
const prevEvent = slotItem.events[index - 1];
if (!event.rect) {
// Each event is position depending on where the previous event is already positioned
const eventOffset = !prevEvent ? 0 : prevEvent.element.nativeElement.clientHeight + prevEvent.rect.top;
const top = !prevEvent ? slotRect.top + slotItem.slot.linkHeight : eventOffset + EVENT_SPACING;
event.rect = {
top: top,
left: slotRect.left,
width: 0
};
}
// The number of slots an event spans in current group
const eventWidth = this.calculateEventWidth(event, prevEvent, slotItems, key, slotRect);
event.rect.width = eventWidth;
event.element.nativeElement.style.width = event.rect.width + 'px';
event.element.nativeElement.style.height = 'auto';
accumulatedHeight += event.element.nativeElement.clientHeight + EVENT_SPACING;
});
const slotHeight = slotItem.height = BORDER_WIDTH +
dateHeader.offsetHeight +
EVENT_SPACING +
accumulatedHeight +
(slotItem.slot.hasShowMore ? 0 : MORE_BUTTON_HEIGHT);
slotItem.slot.element.nativeElement.style.height = slotHeight + 'px';
// After events are rendered, position the showMore button
if (slotItem.slot.hasShowMore) {
const top = slotRect.top + slotItem.slot.linkHeight + accumulatedHeight - MORE_BUTTON_HEIGHT;
slotItem.slot.showMore({ width: slotRect.width, left: slotRect.left, top: top });
}
});
}
/**
* Extracted to a separate method to address SonarQube suggestion:
* "Refactor this code to not nest functions more than 4 levels deep"
*/
calculateEventWidth = (event, prevEvent, slotItems, key, slotRect) => {
let eventWidth;
if (event.item.isMultiDay) {
const slotMatch = this.slots.filter(slot => intersects(event.item.startTime, event.item.endTime, slot.start, slot.end));
eventWidth = slotMatch.reduce((acc, currentValue) => acc + currentValue.rect.width + BORDER_WIDTH, 0) - BORDER_WIDTH;
if (prevEvent) {
const newHeight = prevEvent.element.nativeElement.clientHeight + prevEvent.rect.top;
const newTop = newHeight + EVENT_SPACING;
// If event is spanning in multiple slots, it needs to be positioned so that its top
// is calculated based on the most 'accumulated height' among all slots
if (event.rect.top < newTop) {
event.rect.top = newTop;
// Consequently, all previously renderd events (after that multi-span event) need to
// be reposition so that they don't overlap
slotMatch.forEach(slot => {
const slotKey = slot.id.resourceIndex + ':' + slot.id.rangeIndex + ':' + slot.id.index;
if (slotKey !== key) {
slotItems[slotKey].events.forEach((e, index) => {
if (index > event.item.data[event.resourceIndex].rowIndex) {
e.rect.top = event.rect.top + event.element.nativeElement.clientHeight + EVENT_SPACING;
}
});
}
});
}
}
}
else {
eventWidth = slotRect.width;
}
return eventWidth;
};
}
/**
* @hidden
*/
export class MonthResourceGroup {
index;
dayRanges = [];
constructor(index) {
this.index = index;
}
get hasSlots() {
return Boolean(this.dayRanges.find(range => range && range.hasSlots));
}
registerSlot(slot) {
const range = this.slotRange(slot);
range.registerSlot(slot);
}
unregisterSlot(slot) {
const range = this.dayRanges[slot.id.rangeIndex];
range.unregisterSlot(slot);
if (!range.hasSlots) {
delete this.dayRanges[slot.id.rangeIndex];
}
}
registerItem(component) {
const range = this.dayRanges[component.rangeIndex];
range.registerItem(component);
}
unregisterItem(component, id) {
const range = this.dayRanges[id.rangeIndex];
if (range) {
range.unregisterItem(component, id.index);
}
}
slotRange(slot) {
const ranges = this.dayRanges;
const rangeIndex = slot.id.rangeIndex;
if (!ranges[rangeIndex]) {
ranges[rangeIndex] = new SlotRange(rangeIndex);
}
return ranges[rangeIndex];
}
forEachRange(callback) {
for (let i = 0; i < this.dayRanges.length; i++) {
callback(this.dayRanges[i]);
}
}
cleanRanges() {
this.dayRanges = this.dayRanges.filter(r => Boolean(r));
}
}
/**
* @hidden
*/
export class MonthSlotService extends BaseSlotService {
layout(eventHeight, eventsPerDay, adaptiveSlotHeight) {
this.groups.forEach((group) => group.forEachRange(range => range.layout(eventHeight, eventsPerDay, adaptiveSlotHeight)));
}
slotByIndex(slotIndex) {
const [resourceIndex, rangeIndex, index] = slotIndex.split(':').map(part => parseInt(part, 10));
return this.groups[resourceIndex].dayRanges[rangeIndex].slots[index];
}
forEachSlot(callback) {
this.groups.forEach((group) => {
group.dayRanges.forEach(range => {
range.slots.forEach(slot => callback(slot));
});
});
}
forEachItem(callback) {
this.groups.forEach((group) => {
group.dayRanges.forEach(range => {
range.items.forEach(viewItem => callback(viewItem));
});
});
}
createGroup(index) {
return new MonthResourceGroup(index);
}
invalidate() {
super.invalidate();
this.forEachItem((viewItem) => {
const data = viewItem.item.data;
Object.keys(data).forEach(resourceIndex => {
data[resourceIndex].hidden = false;
});
});
}
slotByPosition(x, y) {
let range;
for (let i = 0; i < this.groups.length; i++) {
const group = this.groups[i];
range = group.dayRanges.find(r => rectContains(r.rect, x, y, this.calculateScaleX()));
if (range) {
return range.slots.find(slot => rectContains(slot.rect, x, y, this.calculateScaleX()));
}
}
}
dragRanges(currentSlot, offset) {
const start = new Date(currentSlot.start.getTime() - offset.start);
const end = new Date(currentSlot.start.getTime() + offset.end);
const group = this.groups[currentSlot.id.resourceIndex];
const ranges = [];
group.dayRanges.forEach(range => {
const slots = range.slots.filter(s => intersects(start, end, s.start, s.end));
if (slots.length) {
ranges.push(slots);
}
});
return {
start,
end,
ranges
};
}
groupSlotByPosition(currentSlot, x, y) {
const range = this.groups[currentSlot.id.resourceIndex].dayRanges.find(r => rectContains(r.rect, x, y, this.calculateScaleX()));
if (range) {
return range.slots.find(slot => rectContains(slot.rect, x, y, this.calculateScaleX()));
}
}
resizeRanges(currentSlot, task, resizeStart, offset) {
const group = this.groups[task.resources[0].leafIdx];
const ranges = [];
const startDate = task.start.toUTCDate();
const endDate = task.end.toUTCDate();
let start, end;
if (resizeStart) {
start = currentSlot.start.getTime() + offset.start;
if (start > endDate.getTime()) {
start = new Date(Math.min(dateWithTime(endDate, startDate).getTime(), endDate.getTime()));
}
end = endDate;
}
else {
start = startDate;
end = currentSlot.start.getTime() + offset.end;
if (end < start.getTime()) {
end = new Date(Math.max(dateWithTime(startDate, endDate).getTime(), start.getTime()));
}
}
group.dayRanges.forEach(range => {
const slots = range.slots.filter(s => intersects(start, end, s.start, s.end));
if (slots.length) {
ranges.push(slots);
}
});
return {
start: new Date(start),
end: new Date(end),
ranges: ranges
};
}
}