Implements custom scroll and event logging
Adds custom scroll management for the calendar week view, replacing native scrollbars with a custom handle. Introduces categorized event logging with console grouping and styling, enhancing debug output. It also allows configuring logging for specific event categories.
This commit is contained in:
parent
001443ce11
commit
9f6d4333cb
7 changed files with 606 additions and 63 deletions
|
|
@ -9,6 +9,17 @@ export class EventBus implements IEventBus {
|
|||
private eventLog: EventLogEntry[] = [];
|
||||
private debug: boolean = false;
|
||||
private listeners: Set<ListenerEntry> = new Set();
|
||||
|
||||
// Log configuration for different categories
|
||||
private logConfig: { [key: string]: boolean } = {
|
||||
calendar: true,
|
||||
grid: true,
|
||||
event: true,
|
||||
scroll: true,
|
||||
navigation: true,
|
||||
view: true,
|
||||
default: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to an event via DOM addEventListener
|
||||
|
|
@ -55,9 +66,9 @@ export class EventBus implements IEventBus {
|
|||
cancelable: true
|
||||
});
|
||||
|
||||
// Log event
|
||||
// Log event with grouping
|
||||
if (this.debug) {
|
||||
console.log(`📢 Event: ${eventType}`, detail);
|
||||
this.logEventWithGrouping(eventType, detail);
|
||||
}
|
||||
|
||||
this.eventLog.push({
|
||||
|
|
@ -70,6 +81,77 @@ export class EventBus implements IEventBus {
|
|||
return !document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event with console grouping
|
||||
*/
|
||||
private logEventWithGrouping(eventType: string, detail: any): void {
|
||||
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
|
||||
const category = this.extractCategory(eventType);
|
||||
|
||||
// Only log if category is enabled
|
||||
if (!this.logConfig[category]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category emoji and color
|
||||
const { emoji, color } = this.getCategoryStyle(category);
|
||||
|
||||
// Use collapsed group to reduce visual noise
|
||||
console.groupCollapsed(`%c${emoji} ${category.toUpperCase()}`, `color: ${color}; font-weight: bold`);
|
||||
console.log(`Event: ${eventType}`, detail);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract category from event type
|
||||
*/
|
||||
private extractCategory(eventType: string): string {
|
||||
if (eventType.includes(':')) {
|
||||
return eventType.split(':')[0];
|
||||
}
|
||||
|
||||
// Fallback: try to detect category from event name patterns
|
||||
const lowerType = eventType.toLowerCase();
|
||||
if (lowerType.includes('grid') || lowerType.includes('rendered')) return 'grid';
|
||||
if (lowerType.includes('event') || lowerType.includes('sync')) return 'event';
|
||||
if (lowerType.includes('scroll')) return 'scroll';
|
||||
if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation';
|
||||
if (lowerType.includes('view')) return 'view';
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styling for different categories
|
||||
*/
|
||||
private getCategoryStyle(category: string): { emoji: string; color: string } {
|
||||
const styles: { [key: string]: { emoji: string; color: string } } = {
|
||||
calendar: { emoji: '🗓️', color: '#2196F3' },
|
||||
grid: { emoji: '📊', color: '#4CAF50' },
|
||||
event: { emoji: '📅', color: '#FF9800' },
|
||||
scroll: { emoji: '📜', color: '#9C27B0' },
|
||||
navigation: { emoji: '🧭', color: '#F44336' },
|
||||
view: { emoji: '👁️', color: '#00BCD4' },
|
||||
default: { emoji: '📢', color: '#607D8B' }
|
||||
};
|
||||
|
||||
return styles[category] || styles.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure logging for specific categories
|
||||
*/
|
||||
setLogConfig(config: { [key: string]: boolean }): void {
|
||||
this.logConfig = { ...this.logConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log configuration
|
||||
*/
|
||||
getLogConfig(): { [key: string]: boolean } {
|
||||
return { ...this.logConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event history
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ViewManager } from './managers/ViewManager.js';
|
|||
import { EventManager } from './managers/EventManager.js';
|
||||
import { EventRenderer } from './managers/EventRenderer.js';
|
||||
import { GridManager } from './managers/GridManager.js';
|
||||
import { ScrollManager } from './managers/ScrollManager.js';
|
||||
import { CalendarConfig } from './core/CalendarConfig.js';
|
||||
|
||||
/**
|
||||
|
|
@ -23,6 +24,7 @@ function initializeCalendar(): void {
|
|||
const viewManager = new ViewManager(eventBus);
|
||||
const eventManager = new EventManager(eventBus);
|
||||
const eventRenderer = new EventRenderer(eventBus);
|
||||
const scrollManager = new ScrollManager(); // Initialize BEFORE GridManager
|
||||
const gridManager = new GridManager();
|
||||
|
||||
// Enable debug mode for development
|
||||
|
|
@ -41,7 +43,8 @@ function initializeCalendar(): void {
|
|||
viewManager,
|
||||
eventManager,
|
||||
eventRenderer,
|
||||
gridManager
|
||||
gridManager,
|
||||
scrollManager
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,16 +49,10 @@ export class EventRenderer {
|
|||
}
|
||||
|
||||
private renderEvents(events: CalendarEvent[]): void {
|
||||
console.log(`EventRenderer: Rendering ${events.length} events`);
|
||||
|
||||
// Clear existing events first
|
||||
this.clearEvents();
|
||||
|
||||
// For now, just log events - proper rendering will be implemented later
|
||||
events.forEach(event => {
|
||||
console.log(`EventRenderer: Event "${event.title}" from ${event.start} to ${event.end}`);
|
||||
});
|
||||
|
||||
// For now, just emit event rendered - proper rendering will be implemented later
|
||||
this.eventBus.emit(EventTypes.EVENT_RENDERED, {
|
||||
count: events.length
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,7 +94,9 @@ export class GridManager {
|
|||
this.renderGrid();
|
||||
|
||||
// Emit grid rendered event
|
||||
console.log('GridManager: Emitting GRID_RENDERED event');
|
||||
eventBus.emit(EventTypes.GRID_RENDERED);
|
||||
console.log('GridManager: GRID_RENDERED event emitted');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,15 +117,48 @@ export class GridManager {
|
|||
// Clear existing grid and rebuild POC structure
|
||||
this.grid.innerHTML = '';
|
||||
|
||||
// Create POC structure: time-axis + week-container
|
||||
// Create POC structure: header-spacer + time-axis + week-container + right-side
|
||||
this.createHeaderSpacer();
|
||||
this.createRightHeaderSpacer();
|
||||
this.createTimeAxis();
|
||||
this.createRightColumn();
|
||||
this.createWeekContainer();
|
||||
|
||||
console.log('GridManager: Grid rendered successfully with POC structure');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create time axis (left column) like in POC
|
||||
* Create header spacer to align time axis with week content
|
||||
*/
|
||||
private createHeaderSpacer(): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
const headerSpacer = document.createElement('swp-header-spacer');
|
||||
this.grid.appendChild(headerSpacer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create right header spacer to align right column with week content
|
||||
*/
|
||||
private createRightHeaderSpacer(): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
const rightHeaderSpacer = document.createElement('swp-right-header-spacer');
|
||||
this.grid.appendChild(rightHeaderSpacer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create right column beside week container
|
||||
*/
|
||||
private createRightColumn(): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
const rightColumn = document.createElement('swp-right-column');
|
||||
this.grid.appendChild(rightColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create time axis (positioned beside week container) like in POC
|
||||
*/
|
||||
private createTimeAxis(): void {
|
||||
if (!this.grid) return;
|
||||
|
|
@ -132,7 +167,7 @@ export class GridManager {
|
|||
const startHour = calendarConfig.get('dayStartHour');
|
||||
const endHour = calendarConfig.get('dayEndHour');
|
||||
|
||||
for (let hour = startHour; hour <= endHour; hour++) {
|
||||
for (let hour = startHour; hour < endHour; hour++) {
|
||||
const marker = document.createElement('swp-hour-marker');
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
||||
|
|
@ -221,7 +256,6 @@ export class GridManager {
|
|||
const column = document.createElement('swp-day-column');
|
||||
(column as any).dataset.date = this.formatDate(date);
|
||||
|
||||
console.log(`GridManager: Creating day column ${dayIndex} for date ${this.formatDate(date)}`);
|
||||
|
||||
// Add dummy content to force column width (temporary test)
|
||||
const dummyContent = document.createElement('div');
|
||||
|
|
|
|||
356
src/managers/ScrollManager.ts
Normal file
356
src/managers/ScrollManager.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
// Custom scroll management for calendar week container
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
|
||||
/**
|
||||
* Manages custom scrolling functionality for the calendar
|
||||
*/
|
||||
export class ScrollManager {
|
||||
private rightColumn: HTMLElement | null = null;
|
||||
private scrollHandle: HTMLElement | null = null;
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private calendarContainer: HTMLElement | null = null;
|
||||
private timeAxis: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private isDragging: boolean = false;
|
||||
private dragStartY: number = 0;
|
||||
private scrollStartTop: number = 0;
|
||||
private maxScrollTop: number = 0;
|
||||
private handleHeight: number = 40;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Initialize scroll when grid is rendered
|
||||
eventBus.on(EventTypes.GRID_RENDERED, () => {
|
||||
console.log('ScrollManager: Received GRID_RENDERED event');
|
||||
this.setupScrolling();
|
||||
});
|
||||
|
||||
// Handle mouse events for dragging
|
||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scrolling functionality after grid is rendered
|
||||
*/
|
||||
private setupScrolling(): void {
|
||||
this.findElements();
|
||||
|
||||
if (this.rightColumn && this.scrollableContent && this.calendarContainer) {
|
||||
this.setupResizeObserver();
|
||||
this.updateScrollableHeight();
|
||||
this.createScrollHandle();
|
||||
this.hideNativeScrollbar();
|
||||
this.setupScrollSynchronization();
|
||||
this.calculateScrollBounds();
|
||||
this.updateHandlePosition();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DOM elements needed for scrolling
|
||||
*/
|
||||
private findElements(): void {
|
||||
this.rightColumn = document.querySelector('swp-right-column');
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
this.calendarContainer = document.querySelector('swp-calendar-container');
|
||||
this.timeAxis = document.querySelector('swp-time-axis');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and add scroll handle to right column
|
||||
*/
|
||||
private createScrollHandle(): void {
|
||||
if (!this.rightColumn) return;
|
||||
|
||||
// Remove existing handle if any
|
||||
const existingHandle = this.rightColumn.querySelector('swp-scroll-handle');
|
||||
if (existingHandle) {
|
||||
existingHandle.remove();
|
||||
}
|
||||
|
||||
// Create new handle
|
||||
this.scrollHandle = document.createElement('swp-scroll-handle');
|
||||
this.scrollHandle.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
|
||||
this.rightColumn.appendChild(this.scrollHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scroll bounds based on content and container heights
|
||||
*/
|
||||
private calculateScrollBounds(): void {
|
||||
if (!this.scrollableContent || !this.rightColumn) return;
|
||||
|
||||
const contentHeight = this.scrollableContent.scrollHeight;
|
||||
const containerHeight = this.scrollableContent.clientHeight;
|
||||
// Use container height as track height since right column should match scrollable area
|
||||
const trackHeight = containerHeight;
|
||||
|
||||
console.log('ScrollManager Debug:');
|
||||
console.log('- contentHeight (scrollHeight):', contentHeight);
|
||||
console.log('- containerHeight (clientHeight):', containerHeight);
|
||||
console.log('- trackHeight (using containerHeight):', trackHeight);
|
||||
console.log('- scrollableContent element:', this.scrollableContent);
|
||||
|
||||
this.maxScrollTop = Math.max(0, contentHeight - containerHeight);
|
||||
|
||||
// Calculate proportional handle height based on content ratio
|
||||
if (contentHeight > 0 && containerHeight > 0) {
|
||||
const visibleRatio = containerHeight / contentHeight;
|
||||
this.handleHeight = Math.max(20, Math.min(trackHeight * visibleRatio, trackHeight - 10));
|
||||
} else {
|
||||
this.handleHeight = 40; // fallback
|
||||
}
|
||||
|
||||
console.log('- maxScrollTop:', this.maxScrollTop);
|
||||
console.log('- visibleRatio:', (containerHeight / contentHeight).toFixed(3));
|
||||
console.log('- calculated handleHeight:', this.handleHeight);
|
||||
|
||||
// Update handle height in DOM
|
||||
if (this.scrollHandle) {
|
||||
this.scrollHandle.style.height = `${this.handleHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down on scroll handle
|
||||
*/
|
||||
private handleMouseDown(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
this.isDragging = true;
|
||||
this.dragStartY = e.clientY;
|
||||
|
||||
if (this.scrollHandle && this.scrollableContent) {
|
||||
this.scrollHandle.classList.add('dragging');
|
||||
this.scrollStartTop = this.scrollableContent.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move during drag
|
||||
*/
|
||||
private handleMouseMove(e: MouseEvent): void {
|
||||
if (!this.isDragging || !this.scrollHandle || !this.scrollableContent) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const deltaY = e.clientY - this.dragStartY;
|
||||
// Use container height as track height
|
||||
const trackHeight = this.scrollableContent.clientHeight - this.handleHeight;
|
||||
|
||||
// Ensure trackHeight is positive to avoid division by zero
|
||||
if (trackHeight <= 0) return;
|
||||
|
||||
const scrollRatio = deltaY / trackHeight;
|
||||
const newScrollTop = this.scrollStartTop + (scrollRatio * this.maxScrollTop);
|
||||
|
||||
// Clamp scroll position
|
||||
const clampedScrollTop = Math.max(0, Math.min(newScrollTop, this.maxScrollTop));
|
||||
|
||||
// Apply scroll to content
|
||||
this.scrollableContent.scrollTop = clampedScrollTop;
|
||||
|
||||
// Update handle position (this will also trigger time-axis sync via scroll event)
|
||||
this.updateHandlePosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up to end drag
|
||||
*/
|
||||
private handleMouseUp(e: MouseEvent): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
|
||||
if (this.scrollHandle) {
|
||||
this.scrollHandle.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update handle position based on current scroll
|
||||
*/
|
||||
private updateHandlePosition(): void {
|
||||
if (!this.scrollHandle || !this.scrollableContent) return;
|
||||
|
||||
const scrollTop = this.scrollableContent.scrollTop;
|
||||
const scrollRatio = this.maxScrollTop > 0 ? scrollTop / this.maxScrollTop : 0;
|
||||
// Use container height as track height
|
||||
const trackHeight = this.scrollableContent.clientHeight - this.handleHeight;
|
||||
const handleTop = Math.max(0, Math.min(scrollRatio * trackHeight, trackHeight));
|
||||
|
||||
this.scrollHandle.style.top = `${handleTop}px`;
|
||||
|
||||
// Debug logging for handle position
|
||||
if (scrollTop % 200 === 0) { // Log every 200px to avoid spam
|
||||
console.log(`ScrollManager: Handle position - scrollTop: ${scrollTop}, ratio: ${scrollRatio.toFixed(3)}, handleTop: ${handleTop.toFixed(1)}, trackHeight: ${trackHeight}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific position
|
||||
*/
|
||||
scrollTo(scrollTop: number): void {
|
||||
if (!this.scrollableContent) return;
|
||||
|
||||
const clampedScrollTop = Math.max(0, Math.min(scrollTop, this.maxScrollTop));
|
||||
this.scrollableContent.scrollTop = clampedScrollTop;
|
||||
this.updateHandlePosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific hour
|
||||
*/
|
||||
scrollToHour(hour: number): void {
|
||||
const hourHeight = calendarConfig.get('hourHeight');
|
||||
const dayStartHour = calendarConfig.get('dayStartHour');
|
||||
const scrollTop = (hour - dayStartHour) * hourHeight;
|
||||
|
||||
this.scrollTo(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup ResizeObserver to monitor container size changes
|
||||
*/
|
||||
private setupResizeObserver(): void {
|
||||
if (!this.calendarContainer) return;
|
||||
|
||||
// Clean up existing observer
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
console.log('ScrollManager: Container resized', entry.contentRect);
|
||||
this.updateScrollableHeight();
|
||||
}
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.calendarContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and update scrollable content height dynamically
|
||||
*/
|
||||
private updateScrollableHeight(): void {
|
||||
if (!this.scrollableContent || !this.calendarContainer) return;
|
||||
|
||||
// Get calendar container height
|
||||
const containerRect = this.calendarContainer.getBoundingClientRect();
|
||||
|
||||
// Find navigation height
|
||||
const navigation = document.querySelector('swp-calendar-nav');
|
||||
const navHeight = navigation ? navigation.getBoundingClientRect().height : 0;
|
||||
|
||||
// Find week header height
|
||||
const weekHeader = document.querySelector('swp-week-header');
|
||||
const headerHeight = weekHeader ? weekHeader.getBoundingClientRect().height : 80;
|
||||
|
||||
// Calculate available height for scrollable content
|
||||
const availableHeight = containerRect.height - headerHeight;
|
||||
|
||||
console.log('ScrollManager: Dynamic height calculation');
|
||||
console.log('- Container height:', containerRect.height);
|
||||
console.log('- Navigation height:', navHeight);
|
||||
console.log('- Header height:', headerHeight);
|
||||
console.log('- Available height:', availableHeight);
|
||||
|
||||
// Set the height on scrollable content
|
||||
if (availableHeight > 0) {
|
||||
this.scrollableContent.style.height = `${availableHeight}px`;
|
||||
|
||||
// Recalculate scroll bounds after height change
|
||||
setTimeout(() => {
|
||||
this.calculateScrollBounds();
|
||||
this.updateHandlePosition();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide native scrollbar while keeping scroll functionality
|
||||
*/
|
||||
private hideNativeScrollbar(): void {
|
||||
if (!this.scrollableContent) return;
|
||||
|
||||
// Apply CSS to hide scrollbar
|
||||
this.scrollableContent.style.scrollbarWidth = 'none'; // Firefox
|
||||
(this.scrollableContent.style as any).msOverflowStyle = 'none'; // IE/Edge
|
||||
|
||||
// Add webkit scrollbar hiding
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
swp-scrollable-content::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/Opera */
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll synchronization between scrollable content and time axis
|
||||
*/
|
||||
private setupScrollSynchronization(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
console.log('ScrollManager: Setting up scroll synchronization');
|
||||
|
||||
// Throttle scroll events for better performance
|
||||
let scrollTimeout: number | null = null;
|
||||
|
||||
this.scrollableContent.addEventListener('scroll', () => {
|
||||
if (scrollTimeout) {
|
||||
cancelAnimationFrame(scrollTimeout);
|
||||
}
|
||||
|
||||
scrollTimeout = requestAnimationFrame(() => {
|
||||
this.syncTimeAxisPosition();
|
||||
this.updateHandlePosition();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize time axis position with scrollable content
|
||||
*/
|
||||
private syncTimeAxisPosition(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
const scrollTop = this.scrollableContent.scrollTop;
|
||||
|
||||
// Use transform for smooth performance
|
||||
this.timeAxis.style.transform = `translateY(-${scrollTop}px)`;
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam
|
||||
console.log(`ScrollManager: Synced time-axis to scrollTop: ${scrollTop}px`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue