Moving away from Azure Devops #1
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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.0.tgz",
|
||||
"integrity": "sha512-8GGyHkHlKuKFTbT/Xz/0y72OTKl1hVRMJ0MqXAaWaergCExKw3bd5M6DR3NSOA7UL0gkTMhWsql9kFYyAjkIdQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.1.tgz",
|
||||
"integrity": "sha512-Qk1TKZ8M/XYH6Vt+zUOtAyVOqezIMd3r7EDtgCPOnWgIs0Xdrj/miqUQAEoRl3LttbQQ/6gBMhM/84S/mTb/sg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import { from } from 'ts-linq-light';
|
||||
import { ViewConfig, GroupingConfig } from './ViewConfig';
|
||||
import { RenderContext } from './RenderContext';
|
||||
import { IGroupingRenderer } from './IGroupingRenderer';
|
||||
import { ViewConfig } from './ViewConfig';
|
||||
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
|
||||
import { IGroupingStore } from './IGroupingStore';
|
||||
import { RenderBuilder, RenderData } from './RenderBuilder';
|
||||
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 {
|
||||
constructor(
|
||||
private renderers: IGroupingRenderer[],
|
||||
|
|
@ -40,115 +27,60 @@ export class CalendarOrchestrator {
|
|||
throw new Error('Missing swp-calendar-header or swp-day-columns');
|
||||
}
|
||||
|
||||
const groupingData = this.fetchAllData(viewConfig.groupings);
|
||||
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(' ');
|
||||
const context: RenderContext = { headerContainer, columnContainer };
|
||||
|
||||
// Clear containers
|
||||
headerContainer.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);
|
||||
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[] {
|
||||
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 {
|
||||
readonly type: string;
|
||||
render(context: RenderContext): void;
|
||||
export interface RenderContext {
|
||||
headerContainer: HTMLElement;
|
||||
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 { RenderContext } from '../../core/RenderContext';
|
||||
import { IEnumerable } from 'ts-linq-light';
|
||||
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
|
||||
import { NextFunction, RenderData } from '../../core/RenderBuilder';
|
||||
import { DateService } from '../../core/DateService';
|
||||
|
||||
export class DateRenderer implements IGroupingRenderer {
|
||||
export class DateRenderer implements IGroupingRenderer<string> {
|
||||
readonly type = 'date';
|
||||
|
||||
constructor(private dateService: DateService) {}
|
||||
|
||||
render(context: RenderContext): void {
|
||||
for (const dateStr of context.values) {
|
||||
render(
|
||||
dates: IEnumerable<string>,
|
||||
_data: RenderData,
|
||||
_next: NextFunction,
|
||||
context: RenderContext
|
||||
): void {
|
||||
for (const dateStr of dates) {
|
||||
const date = this.dateService.parseISO(dateStr);
|
||||
|
||||
const headerCell = document.createElement('swp-day-header');
|
||||
|
|
@ -21,9 +27,10 @@ export class DateRenderer implements IGroupingRenderer {
|
|||
|
||||
const column = document.createElement('swp-day-column');
|
||||
column.dataset.date = dateStr;
|
||||
if (context.parentId) column.dataset.parentId = context.parentId;
|
||||
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
||||
context.columnContainer.appendChild(column);
|
||||
|
||||
// Leaf renderer - ingen next.render() kald
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
|
||||
import { RenderContext } from '../../core/RenderContext';
|
||||
import { from, IEnumerable } from 'ts-linq-light';
|
||||
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';
|
||||
|
||||
render(context: RenderContext): void {
|
||||
for (const resourceId of context.values) {
|
||||
render(
|
||||
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');
|
||||
cell.dataset.resourceId = resourceId;
|
||||
cell.textContent = resourceId;
|
||||
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
|
||||
cell.dataset.resourceId = resource.id;
|
||||
cell.textContent = resource.name || resource.id;
|
||||
|
||||
if (colspan > 1) {
|
||||
cell.style.gridColumn = `span ${colspan}`;
|
||||
}
|
||||
|
||||
context.headerContainer.appendChild(cell);
|
||||
|
||||
next.render(dates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,47 @@
|
|||
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
|
||||
import { RenderContext } from '../../core/RenderContext';
|
||||
import { from, IEnumerable } from 'ts-linq-light';
|
||||
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';
|
||||
|
||||
render(context: RenderContext): void {
|
||||
for (const teamId of context.values) {
|
||||
render(
|
||||
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');
|
||||
cell.dataset.teamId = teamId;
|
||||
cell.textContent = teamId;
|
||||
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
|
||||
cell.dataset.teamId = team.id;
|
||||
cell.textContent = team.name || team.id;
|
||||
|
||||
if (colspan > 1) {
|
||||
cell.style.gridColumn = `span ${colspan}`;
|
||||
}
|
||||
|
||||
context.headerContainer.appendChild(cell);
|
||||
|
||||
next.render(teamResourcesEnum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Core exports
|
||||
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
|
||||
export { RenderContext } from './core/RenderContext';
|
||||
export { IGroupingRenderer } from './core/IGroupingRenderer';
|
||||
export { IGroupingRenderer, RenderContext } from './core/IGroupingRenderer';
|
||||
export { IGroupingStore } from './core/IGroupingStore';
|
||||
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
||||
export { NavigationAnimator } from './core/NavigationAnimator';
|
||||
export { RenderBuilder, NextFunction, RenderData } from './core/RenderBuilder';
|
||||
|
||||
// Feature exports
|
||||
export { DateRenderer } from './features/date';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue