Calendar/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts
Janus C. H. Knudsen 58cedb9fad Improves column key extraction in header drawer
Enhances column key retrieval to use consistent key format
Adds null check for filter template to prevent potential errors
Uses buildKeyFromColumn() for more robust key generation
2025-12-15 21:25:01 +01:00

419 lines
14 KiB
TypeScript

import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { CoreEvents } from '../../constants/CoreEvents';
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { FilterTemplate } from '../../core/FilterTemplate';
import {
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload,
IDragEndPayload
} from '../../types/DragTypes';
/**
* Layout information for a header item
*/
interface IHeaderItemLayout {
event: ICalendarEvent;
columnKey: string; // Opaque column identifier
row: number; // 1-indexed
colStart: number; // 1-indexed
colEnd: number; // exclusive
}
/**
* HeaderDrawerRenderer - Handles rendering of items in the header drawer
*
* Listens to drag events from DragDropManager and creates/manages
* swp-header-item elements in the header drawer.
*
* Uses subgrid for column alignment with parent swp-calendar-header.
* Position items via gridArea for explicit row/column placement.
*/
export class HeaderDrawerRenderer {
private currentItem: HTMLElement | null = null;
private container: HTMLElement | null = null;
private sourceElement: HTMLElement | null = null;
private wasExpandedBeforeDrag = false;
private filterTemplate: FilterTemplate | null = null;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private headerDrawerManager: HeaderDrawerManager,
private eventService: EventService,
private dateService: DateService
) {
this.setupListeners();
}
/**
* Render allDay events into the header drawer with row stacking
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store filterTemplate for buildColumnKeyFromEvent
this.filterTemplate = filterTemplate;
const drawer = container.querySelector('swp-header-drawer');
if (!drawer) return;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get column keys from DOM for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Fetch events for date range
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
const events = await this.eventService.getByDateRange(startDate, endDate);
// Filter to allDay events only (allDay !== false)
const allDayEvents = events.filter(event => event.allDay !== false);
// Clear existing items
drawer.innerHTML = '';
if (allDayEvents.length === 0) return;
// Calculate layout with row stacking using columnKeys
const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys);
const rowCount = Math.max(1, ...layouts.map(l => l.row));
// Render each item with layout
layouts.forEach(layout => {
const item = this.createHeaderItem(layout);
drawer.appendChild(item);
});
// Expand drawer to fit all rows
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Create a header item element from layout
*/
private createHeaderItem(layout: IHeaderItemLayout): HTMLElement {
const { event, columnKey, row, colStart, colEnd } = layout;
const item = document.createElement('swp-header-item');
item.dataset.eventId = event.id;
item.dataset.itemType = 'event';
item.dataset.start = event.start.toISOString();
item.dataset.end = event.end.toISOString();
item.dataset.columnKey = columnKey;
item.textContent = event.title;
// Color class
const colorClass = this.getColorClass(event);
if (colorClass) item.classList.add(colorClass);
// Grid position from layout
item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`;
return item;
}
/**
* Calculate layout for all events with row stacking
* Uses track-based algorithm to find available rows for overlapping events
*/
private calculateLayout(events: ICalendarEvent[], visibleColumnKeys: string[]): IHeaderItemLayout[] {
// tracks[row][col] = occupied
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
const layouts: IHeaderItemLayout[] = [];
for (const event of events) {
// Build columnKey from event fields (only place we need to construct it)
const columnKey = this.buildColumnKeyFromEvent(event);
const startCol = visibleColumnKeys.indexOf(columnKey);
const endColumnKey = this.buildColumnKeyFromEvent(event, event.end);
const endCol = visibleColumnKeys.indexOf(endColumnKey);
if (startCol === -1 && endCol === -1) continue;
// Clamp til synlige kolonner
const colStart = Math.max(0, startCol);
const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.length - 1) + 1;
// Find ledig række
const row = this.findAvailableRow(tracks, colStart, colEnd);
// Marker som optaget
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
layouts.push({ event, columnKey, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 });
}
return layouts;
}
/**
* Build columnKey from event using FilterTemplate
* Uses the same template that columns use for matching
*/
private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string {
if (!this.filterTemplate) {
// Fallback if no template - shouldn't happen in normal flow
const dateStr = this.dateService.getDateKey(date || event.start);
return dateStr;
}
// For multi-day events, we need to override the date in the event
if (date && date.getTime() !== event.start.getTime()) {
// Create temporary event with overridden start for key generation
const tempEvent = { ...event, start: date };
return this.filterTemplate.buildKeyFromEvent(tempEvent);
}
return this.filterTemplate.buildKeyFromEvent(event);
}
/**
* Find available row for event spanning columns [colStart, colEnd)
*/
private findAvailableRow(tracks: boolean[][], colStart: number, colEnd: number): number {
for (let row = 0; row < tracks.length; row++) {
let available = true;
for (let c = colStart; c < colEnd; c++) {
if (tracks[row][c]) { available = false; break; }
}
if (available) return row;
}
// Ny række
tracks.push(new Array(tracks[0].length).fill(false));
return tracks.length - 1;
}
/**
* Get color class based on event metadata or type
*/
private getColorClass(event: ICalendarEvent): string {
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Setup event listeners for drag events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
this.handleDragEnter(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
this.handleDragMove(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeave(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => {
this.cleanup();
});
}
/**
* Handle drag entering header zone - create preview item
*/
private handleDragEnter(payload: IDragEnterHeaderPayload): void {
this.container = document.querySelector('swp-header-drawer');
if (!this.container) return;
// Remember if drawer was already expanded
this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded();
// Expand to at least 1 row if collapsed, otherwise keep current height
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.expandToRows(1);
}
// Store reference to source element
this.sourceElement = payload.element;
// Create header item
const item = document.createElement('swp-header-item');
item.dataset.eventId = payload.eventId;
item.dataset.itemType = payload.itemType;
item.dataset.duration = String(payload.duration);
item.dataset.columnKey = payload.sourceColumnKey;
item.textContent = payload.title;
// Apply color class if present
if (payload.colorClass) {
item.classList.add(payload.colorClass);
}
// Add dragging state
item.classList.add('dragging');
// Initial placement (duration determines column span)
// gridArea format: "row / col-start / row+1 / col-end"
const col = payload.sourceColumnIndex + 1;
const endCol = col + payload.duration;
item.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
this.container.appendChild(item);
this.currentItem = item;
// Hide original element while in header
payload.element.style.visibility = 'hidden';
}
/**
* Handle drag moving within header - update column position
*/
private handleDragMove(payload: IDragMoveHeaderPayload): void {
if (!this.currentItem) return;
// Update column position
const col = payload.columnIndex + 1;
const duration = parseInt(this.currentItem.dataset.duration || '1', 10);
const endCol = col + duration;
this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
// Update columnKey to new position
this.currentItem.dataset.columnKey = payload.columnKey;
}
/**
* Handle drag leaving header - cleanup for grid→header drag only
*/
private handleDragLeave(payload: IDragLeaveHeaderPayload): void {
// Only cleanup for grid→header drag (when grid event leaves header back to grid)
// For header→grid drag, the header item stays as ghost until drop
if (payload.source === 'grid') {
this.cleanup();
}
// For header source, do nothing - ghost stays until EVENT_DRAG_END
}
/**
* Handle drag end - finalize based on drop target
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Grid→Header: Finalize the header item (it stays in header)
if (this.currentItem) {
this.currentItem.classList.remove('dragging');
this.recalculateDrawerLayout();
this.currentItem = null;
this.sourceElement = null;
}
} else {
// Header→Grid: Remove ghost header item and recalculate
const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`);
ghost?.remove();
this.recalculateDrawerLayout();
}
}
/**
* Recalculate layout for all items currently in the drawer
* Called after drop to reposition items and adjust height
*/
private recalculateDrawerLayout(): void {
const drawer = document.querySelector('swp-header-drawer');
if (!drawer) return;
const items = Array.from(drawer.querySelectorAll('swp-header-item')) as HTMLElement[];
if (items.length === 0) return;
// Get visible column keys for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Build layout data from DOM items - use columnKey directly (opaque matching)
const itemData = items.map(item => ({
element: item,
columnKey: item.dataset.columnKey || '',
duration: parseInt(item.dataset.duration || '1', 10)
}));
// Calculate new layout using track algorithm
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
for (const item of itemData) {
// Direct columnKey matching - no parsing or construction needed
const startCol = visibleColumnKeys.indexOf(item.columnKey);
if (startCol === -1) continue;
const colStart = startCol;
const colEnd = Math.min(startCol + item.duration, visibleColumnKeys.length);
const row = this.findAvailableRow(tracks, colStart, colEnd);
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
// Update element position
item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`;
}
// Update drawer height
const rowCount = tracks.length;
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Get visible column keys from DOM (preserves order for multi-resource views)
* Uses filterTemplate.buildKeyFromColumn() for consistent key format with events
*/
private getVisibleColumnKeysFromDOM(): string[] {
if (!this.filterTemplate) return [];
const columns = document.querySelectorAll('swp-day-column');
const columnKeys: string[] = [];
columns.forEach(col => {
const columnKey = this.filterTemplate!.buildKeyFromColumn(col as HTMLElement);
if (columnKey) columnKeys.push(columnKey);
});
return columnKeys;
}
/**
* Cleanup preview item and restore source visibility
*/
private cleanup(): void {
// Remove preview item
this.currentItem?.remove();
this.currentItem = null;
// Restore source element visibility
if (this.sourceElement) {
this.sourceElement.style.visibility = '';
this.sourceElement = null;
}
// Collapse drawer if it wasn't expanded before drag
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.collapse();
}
}
}