Refactor calendar rendering with RenderBuilder
Simplifies calendar rendering process by introducing a new RenderBuilder pattern Improves flexibility and modularity of grouping renderer chain Decouples rendering logic from specific grouping types Enables dynamic column span calculation and recursive rendering Reduces complexity in CalendarOrchestrator Enhances type safety and simplifies renderer interfaces
This commit is contained in:
parent
27561750f8
commit
a3a1b9a421
9 changed files with 230 additions and 155 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -4960,9 +4960,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-linq-light": {
|
"node_modules/ts-linq-light": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.1.tgz",
|
||||||
"integrity": "sha512-8GGyHkHlKuKFTbT/Xz/0y72OTKl1hVRMJ0MqXAaWaergCExKw3bd5M6DR3NSOA7UL0gkTMhWsql9kFYyAjkIdQ==",
|
"integrity": "sha512-Qk1TKZ8M/XYH6Vt+zUOtAyVOqezIMd3r7EDtgCPOnWgIs0Xdrj/miqUQAEoRl3LttbQQ/6gBMhM/84S/mTb/sg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import { from } from 'ts-linq-light';
|
import { from } from 'ts-linq-light';
|
||||||
import { ViewConfig, GroupingConfig } from './ViewConfig';
|
import { ViewConfig } from './ViewConfig';
|
||||||
import { RenderContext } from './RenderContext';
|
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
|
||||||
import { IGroupingRenderer } from './IGroupingRenderer';
|
|
||||||
import { IGroupingStore } from './IGroupingStore';
|
import { IGroupingStore } from './IGroupingStore';
|
||||||
|
import { RenderBuilder, RenderData } from './RenderBuilder';
|
||||||
import { EventRenderer } from '../features/event/EventRenderer';
|
import { EventRenderer } from '../features/event/EventRenderer';
|
||||||
|
|
||||||
interface HierarchyNode {
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
data: unknown;
|
|
||||||
parentId?: string;
|
|
||||||
children: HierarchyNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupingData {
|
|
||||||
items: { id: string; data: unknown }[];
|
|
||||||
byParent: Map<string, { id: string; data: unknown }[]> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CalendarOrchestrator {
|
export class CalendarOrchestrator {
|
||||||
constructor(
|
constructor(
|
||||||
private renderers: IGroupingRenderer[],
|
private renderers: IGroupingRenderer[],
|
||||||
|
|
@ -40,115 +27,60 @@ export class CalendarOrchestrator {
|
||||||
throw new Error('Missing swp-calendar-header or swp-day-columns');
|
throw new Error('Missing swp-calendar-header or swp-day-columns');
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupingData = this.fetchAllData(viewConfig.groupings);
|
const context: RenderContext = { headerContainer, columnContainer };
|
||||||
const hierarchy = this.buildHierarchy(viewConfig.groupings, groupingData);
|
|
||||||
const totalColumns = this.countLeaves(hierarchy);
|
|
||||||
|
|
||||||
container.style.setProperty('--grid-columns', String(totalColumns));
|
|
||||||
|
|
||||||
const types = from(viewConfig.groupings).select(g => g.type).toArray();
|
|
||||||
headerContainer.dataset.levels = types.join(' ');
|
|
||||||
|
|
||||||
|
// Clear containers
|
||||||
headerContainer.innerHTML = '';
|
headerContainer.innerHTML = '';
|
||||||
columnContainer.innerHTML = '';
|
columnContainer.innerHTML = '';
|
||||||
|
|
||||||
this.renderHierarchy(hierarchy, headerContainer, columnContainer);
|
// Set header levels
|
||||||
|
const types = from(viewConfig.groupings).select(g => g.type).toArray();
|
||||||
|
headerContainer.dataset.levels = types.join(' ');
|
||||||
|
|
||||||
// Render events from IndexedDB
|
// Hent alt data
|
||||||
|
const data: RenderData = {
|
||||||
|
teams: this.getItems('team', viewConfig),
|
||||||
|
resources: this.getItems('resource', viewConfig),
|
||||||
|
dates: this.getItems('date', viewConfig)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Byg renderer chain
|
||||||
|
const builder = new RenderBuilder(context, data);
|
||||||
|
|
||||||
|
for (const grouping of viewConfig.groupings) {
|
||||||
|
const renderer = this.getRenderer(grouping.type);
|
||||||
|
if (renderer) {
|
||||||
|
const items = this.getItems(grouping.type, viewConfig);
|
||||||
|
builder.add(renderer, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beregn total columns og render
|
||||||
|
const totalColumns = builder.getTotalCount();
|
||||||
|
container.style.setProperty('--grid-columns', String(totalColumns));
|
||||||
|
|
||||||
|
builder.build();
|
||||||
|
|
||||||
|
// Render events
|
||||||
const visibleDates = this.extractVisibleDates(viewConfig);
|
const visibleDates = this.extractVisibleDates(viewConfig);
|
||||||
await this.eventRenderer.render(container, visibleDates);
|
await this.eventRenderer.render(container, visibleDates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getItems(type: string, viewConfig: ViewConfig): ReturnType<typeof from> {
|
||||||
|
const grouping = from(viewConfig.groupings).firstOrDefault(g => g.type === type);
|
||||||
|
if (!grouping) return from([]);
|
||||||
|
|
||||||
|
if (type === 'date') {
|
||||||
|
return from(grouping.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.getStore(type);
|
||||||
|
if (!store) return from([]);
|
||||||
|
|
||||||
|
return from(store.getByIds(grouping.values));
|
||||||
|
}
|
||||||
|
|
||||||
private extractVisibleDates(viewConfig: ViewConfig): string[] {
|
private extractVisibleDates(viewConfig: ViewConfig): string[] {
|
||||||
return from(viewConfig.groupings).firstOrDefault(g => g.type === 'date')?.values || [];
|
return from(viewConfig.groupings).firstOrDefault(g => g.type === 'date')?.values || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAllData(groupings: GroupingConfig[]): Map<string, GroupingData> {
|
|
||||||
const result = new Map<string, GroupingData>();
|
|
||||||
|
|
||||||
for (const g of groupings) {
|
|
||||||
if (g.type === 'date') {
|
|
||||||
result.set(g.type, {
|
|
||||||
items: from(g.values).select(v => ({ id: v, data: { id: v } })).toArray(),
|
|
||||||
byParent: null
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = this.getStore(g.type);
|
|
||||||
if (!store) continue;
|
|
||||||
const rawItems = store.getByIds(g.values);
|
|
||||||
const items = from(rawItems).select((item: any) => ({ id: item.id, data: item })).toArray();
|
|
||||||
let byParent: Map<string, { id: string; data: unknown }[]> | null = null;
|
|
||||||
if (g.parentKey) {
|
|
||||||
byParent = new Map();
|
|
||||||
const grouped = from(items)
|
|
||||||
.where(item => (item.data as any)[g.parentKey!] != null)
|
|
||||||
.groupBy(item => (item.data as any)[g.parentKey!] as string);
|
|
||||||
for (const group of grouped) {
|
|
||||||
byParent.set(group.key, group.toArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.set(g.type, { items, byParent });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildHierarchy(
|
|
||||||
groupings: GroupingConfig[],
|
|
||||||
data: Map<string, GroupingData>,
|
|
||||||
level = 0,
|
|
||||||
parentId?: string
|
|
||||||
): HierarchyNode[] {
|
|
||||||
if (level >= groupings.length) return [];
|
|
||||||
|
|
||||||
const g = groupings[level];
|
|
||||||
const gData = data.get(g.type);
|
|
||||||
if (!gData) return [];
|
|
||||||
|
|
||||||
const items = parentId && gData.byParent
|
|
||||||
? gData.byParent.get(parentId) ?? []
|
|
||||||
: gData.items;
|
|
||||||
|
|
||||||
return from(items).select(item => ({
|
|
||||||
type: g.type,
|
|
||||||
id: item.id,
|
|
||||||
data: item.data,
|
|
||||||
parentId,
|
|
||||||
children: this.buildHierarchy(groupings, data, level + 1, item.id)
|
|
||||||
})).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private countLeaves(nodes: HierarchyNode[]): number {
|
|
||||||
return nodes.reduce((sum, n) =>
|
|
||||||
sum + (n.children.length ? this.countLeaves(n.children) : 1), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderHierarchy(
|
|
||||||
nodes: HierarchyNode[],
|
|
||||||
headerContainer: HTMLElement,
|
|
||||||
columnContainer: HTMLElement,
|
|
||||||
headerRow = 1
|
|
||||||
): void {
|
|
||||||
for (const node of nodes) {
|
|
||||||
const renderer = this.getRenderer(node.type);
|
|
||||||
const colspan = this.countLeaves([node]) || 1;
|
|
||||||
|
|
||||||
renderer?.render({
|
|
||||||
headerContainer,
|
|
||||||
columnContainer,
|
|
||||||
values: [node.id],
|
|
||||||
headerRow,
|
|
||||||
columnIndex: 0, // Not used - grid auto-places
|
|
||||||
colspan,
|
|
||||||
parentId: node.parentId
|
|
||||||
} as RenderContext);
|
|
||||||
|
|
||||||
if (node.children.length) {
|
|
||||||
this.renderHierarchy(node.children, headerContainer, columnContainer, headerRow + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { RenderContext } from './RenderContext';
|
import { IEnumerable } from 'ts-linq-light';
|
||||||
|
import { NextFunction, RenderData } from './RenderBuilder';
|
||||||
|
|
||||||
export interface IGroupingRenderer {
|
export interface RenderContext {
|
||||||
readonly type: string;
|
headerContainer: HTMLElement;
|
||||||
render(context: RenderContext): void;
|
columnContainer: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroupingRenderer<T = unknown> {
|
||||||
|
readonly type: string;
|
||||||
|
render(
|
||||||
|
items: IEnumerable<T>,
|
||||||
|
data: RenderData,
|
||||||
|
next: NextFunction,
|
||||||
|
context: RenderContext
|
||||||
|
): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
src/v2/core/RenderBuilder.ts
Normal file
82
src/v2/core/RenderBuilder.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { from, IEnumerable } from 'ts-linq-light';
|
||||||
|
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
|
||||||
|
|
||||||
|
export interface NextFunction {
|
||||||
|
count(items: IEnumerable<unknown>): number;
|
||||||
|
render(items: IEnumerable<unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderData {
|
||||||
|
teams?: IEnumerable<unknown>;
|
||||||
|
resources?: IEnumerable<unknown>;
|
||||||
|
dates?: IEnumerable<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderLevel {
|
||||||
|
renderer: IGroupingRenderer;
|
||||||
|
items: IEnumerable<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenderBuilder {
|
||||||
|
private levels: RenderLevel[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private context: RenderContext,
|
||||||
|
private data: RenderData
|
||||||
|
) {}
|
||||||
|
|
||||||
|
add(renderer: IGroupingRenderer, items: IEnumerable<unknown>): this {
|
||||||
|
this.levels.push({ renderer, items });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalCount(): number {
|
||||||
|
if (this.levels.length === 0) return 0;
|
||||||
|
|
||||||
|
const chain = this.buildChain(0);
|
||||||
|
return chain.count(this.levels[0].items);
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): void {
|
||||||
|
if (this.levels.length === 0) return;
|
||||||
|
|
||||||
|
const chain = this.buildChain(0);
|
||||||
|
chain.render(this.levels[0].items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildChain(index: number): NextFunction {
|
||||||
|
if (index >= this.levels.length) {
|
||||||
|
// Leaf - ingen flere levels
|
||||||
|
return {
|
||||||
|
count: (items) => from(items).count(),
|
||||||
|
render: () => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = this.levels[index];
|
||||||
|
const nextChain = this.buildChain(index + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: (items) => {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const childItems = this.getChildItems(index, item);
|
||||||
|
total += nextChain.count(childItems);
|
||||||
|
}
|
||||||
|
return total || from(items).count();
|
||||||
|
},
|
||||||
|
render: (items) => {
|
||||||
|
level.renderer.render(items, this.data, nextChain, this.context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChildItems(levelIndex: number, _parentItem: unknown): IEnumerable<unknown> {
|
||||||
|
// Returnerer næste levels items - rendereren selv filtrerer baseret på parent
|
||||||
|
const nextLevel = this.levels[levelIndex + 1];
|
||||||
|
if (!nextLevel) {
|
||||||
|
return from([]);
|
||||||
|
}
|
||||||
|
return nextLevel.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export interface RenderContext {
|
|
||||||
headerContainer: HTMLElement;
|
|
||||||
columnContainer: HTMLElement;
|
|
||||||
values: string[];
|
|
||||||
headerRow: number;
|
|
||||||
colspan: number;
|
|
||||||
parentId?: string;
|
|
||||||
columnIndex: number; // Kept for interface compatibility, but not used by renderers
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
|
import { IEnumerable } from 'ts-linq-light';
|
||||||
import { RenderContext } from '../../core/RenderContext';
|
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { NextFunction, RenderData } from '../../core/RenderBuilder';
|
||||||
import { DateService } from '../../core/DateService';
|
import { DateService } from '../../core/DateService';
|
||||||
|
|
||||||
export class DateRenderer implements IGroupingRenderer {
|
export class DateRenderer implements IGroupingRenderer<string> {
|
||||||
readonly type = 'date';
|
readonly type = 'date';
|
||||||
|
|
||||||
constructor(private dateService: DateService) {}
|
constructor(private dateService: DateService) {}
|
||||||
|
|
||||||
render(context: RenderContext): void {
|
render(
|
||||||
for (const dateStr of context.values) {
|
dates: IEnumerable<string>,
|
||||||
|
_data: RenderData,
|
||||||
|
_next: NextFunction,
|
||||||
|
context: RenderContext
|
||||||
|
): void {
|
||||||
|
for (const dateStr of dates) {
|
||||||
const date = this.dateService.parseISO(dateStr);
|
const date = this.dateService.parseISO(dateStr);
|
||||||
|
|
||||||
const headerCell = document.createElement('swp-day-header');
|
const headerCell = document.createElement('swp-day-header');
|
||||||
|
|
@ -21,9 +27,10 @@ export class DateRenderer implements IGroupingRenderer {
|
||||||
|
|
||||||
const column = document.createElement('swp-day-column');
|
const column = document.createElement('swp-day-column');
|
||||||
column.dataset.date = dateStr;
|
column.dataset.date = dateStr;
|
||||||
if (context.parentId) column.dataset.parentId = context.parentId;
|
|
||||||
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
||||||
context.columnContainer.appendChild(column);
|
context.columnContainer.appendChild(column);
|
||||||
|
|
||||||
|
// Leaf renderer - ingen next.render() kald
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
|
import { from, IEnumerable } from 'ts-linq-light';
|
||||||
import { RenderContext } from '../../core/RenderContext';
|
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { NextFunction, RenderData } from '../../core/RenderBuilder';
|
||||||
|
|
||||||
export class ResourceRenderer implements IGroupingRenderer {
|
interface Resource {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResourceRenderer implements IGroupingRenderer<Resource> {
|
||||||
readonly type = 'resource';
|
readonly type = 'resource';
|
||||||
|
|
||||||
render(context: RenderContext): void {
|
render(
|
||||||
for (const resourceId of context.values) {
|
resources: IEnumerable<Resource>,
|
||||||
|
data: RenderData,
|
||||||
|
next: NextFunction,
|
||||||
|
context: RenderContext
|
||||||
|
): void {
|
||||||
|
const dates = data.dates || from([]);
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
const colspan = next.count(dates);
|
||||||
|
|
||||||
const cell = document.createElement('swp-resource-header');
|
const cell = document.createElement('swp-resource-header');
|
||||||
cell.dataset.resourceId = resourceId;
|
cell.dataset.resourceId = resource.id;
|
||||||
cell.textContent = resourceId;
|
cell.textContent = resource.name || resource.id;
|
||||||
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
|
|
||||||
|
if (colspan > 1) {
|
||||||
|
cell.style.gridColumn = `span ${colspan}`;
|
||||||
|
}
|
||||||
|
|
||||||
context.headerContainer.appendChild(cell);
|
context.headerContainer.appendChild(cell);
|
||||||
|
|
||||||
|
next.render(dates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,47 @@
|
||||||
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
|
import { from, IEnumerable } from 'ts-linq-light';
|
||||||
import { RenderContext } from '../../core/RenderContext';
|
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { NextFunction, RenderData } from '../../core/RenderBuilder';
|
||||||
|
|
||||||
export class TeamRenderer implements IGroupingRenderer {
|
interface Team {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resource {
|
||||||
|
id: string;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamRenderer implements IGroupingRenderer<Team> {
|
||||||
readonly type = 'team';
|
readonly type = 'team';
|
||||||
|
|
||||||
render(context: RenderContext): void {
|
render(
|
||||||
for (const teamId of context.values) {
|
teams: IEnumerable<Team>,
|
||||||
|
data: RenderData,
|
||||||
|
next: NextFunction,
|
||||||
|
context: RenderContext
|
||||||
|
): void {
|
||||||
|
const resources = data.resources as IEnumerable<Resource> || from([]);
|
||||||
|
const resourcesByTeam = from(resources).groupBy((r: Resource) => r.teamId || '');
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
const teamResources = from(resourcesByTeam)
|
||||||
|
.firstOrDefault(g => g.key === team.id);
|
||||||
|
|
||||||
|
const teamResourcesEnum = teamResources ? from(teamResources) : from([]);
|
||||||
|
const colspan = next.count(teamResourcesEnum);
|
||||||
|
|
||||||
const cell = document.createElement('swp-team-header');
|
const cell = document.createElement('swp-team-header');
|
||||||
cell.dataset.teamId = teamId;
|
cell.dataset.teamId = team.id;
|
||||||
cell.textContent = teamId;
|
cell.textContent = team.name || team.id;
|
||||||
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
|
|
||||||
|
if (colspan > 1) {
|
||||||
|
cell.style.gridColumn = `span ${colspan}`;
|
||||||
|
}
|
||||||
|
|
||||||
context.headerContainer.appendChild(cell);
|
context.headerContainer.appendChild(cell);
|
||||||
|
|
||||||
|
next.render(teamResourcesEnum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// Core exports
|
// Core exports
|
||||||
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
|
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
|
||||||
export { RenderContext } from './core/RenderContext';
|
export { IGroupingRenderer, RenderContext } from './core/IGroupingRenderer';
|
||||||
export { IGroupingRenderer } from './core/IGroupingRenderer';
|
|
||||||
export { IGroupingStore } from './core/IGroupingStore';
|
export { IGroupingStore } from './core/IGroupingStore';
|
||||||
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
||||||
export { NavigationAnimator } from './core/NavigationAnimator';
|
export { NavigationAnimator } from './core/NavigationAnimator';
|
||||||
|
export { RenderBuilder, NextFunction, RenderData } from './core/RenderBuilder';
|
||||||
|
|
||||||
// Feature exports
|
// Feature exports
|
||||||
export { DateRenderer } from './features/date';
|
export { DateRenderer } from './features/date';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue