UNPKG

@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
/**----------------------------------------------------------------------------------------- * 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 }; } }