Adds hierarchical grouping and entity resolution support
Enhances calendar rendering with dynamic parent-child relationships between entities Introduces EntityResolver for dot-notation references Supports belongsTo configuration in grouping Implements flexible filtering across hierarchical entities Improves rendering flexibility for complex organizational structures
This commit is contained in:
parent
dd647acab8
commit
d4249eecfb
17 changed files with 403 additions and 44 deletions
|
|
@ -3,9 +3,11 @@ import { buildPipeline } from './RenderBuilder';
|
|||
import { EventRenderer } from '../features/event/EventRenderer';
|
||||
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
|
||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||
import { ViewConfig } from './ViewConfig';
|
||||
import { ViewConfig, GroupingConfig } from './ViewConfig';
|
||||
import { FilterTemplate } from './FilterTemplate';
|
||||
import { DateService } from './DateService';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
import { ISync } from '../types/CalendarTypes';
|
||||
|
||||
export class CalendarOrchestrator {
|
||||
constructor(
|
||||
|
|
@ -13,7 +15,8 @@ export class CalendarOrchestrator {
|
|||
private eventRenderer: EventRenderer,
|
||||
private scheduleRenderer: ScheduleRenderer,
|
||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||
private dateService: DateService
|
||||
private dateService: DateService,
|
||||
private entityServices: IEntityService<ISync>[]
|
||||
) {}
|
||||
|
||||
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
|
||||
|
|
@ -29,13 +32,18 @@ export class CalendarOrchestrator {
|
|||
filter[grouping.type] = grouping.values;
|
||||
}
|
||||
|
||||
// Byg FilterTemplate fra viewConfig groupings
|
||||
// Byg FilterTemplate fra viewConfig groupings (kun de med idProperty)
|
||||
const filterTemplate = new FilterTemplate(this.dateService);
|
||||
for (const grouping of viewConfig.groupings) {
|
||||
filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
|
||||
if (grouping.idProperty) {
|
||||
filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
|
||||
}
|
||||
}
|
||||
|
||||
const context: IRenderContext = { headerContainer, columnContainer, filter };
|
||||
// Resolve belongsTo relations (e.g., team.resourceIds)
|
||||
const { parentChildMap, childType } = await this.resolveBelongsTo(viewConfig.groupings, filter);
|
||||
|
||||
const context: IRenderContext = { headerContainer, columnContainer, filter, parentChildMap, childType };
|
||||
|
||||
// Clear
|
||||
headerContainer.innerHTML = '';
|
||||
|
|
@ -48,10 +56,6 @@ export class CalendarOrchestrator {
|
|||
// Vælg renderers baseret på groupings types
|
||||
const activeRenderers = this.selectRenderers(viewConfig);
|
||||
|
||||
// Beregn total kolonner dynamisk
|
||||
const totalColumns = this.calculateTotalColumns(viewConfig);
|
||||
container.style.setProperty('--grid-columns', String(totalColumns));
|
||||
|
||||
// Byg og kør pipeline
|
||||
const pipeline = buildPipeline(activeRenderers);
|
||||
await pipeline.run(context);
|
||||
|
|
@ -74,9 +78,47 @@ export class CalendarOrchestrator {
|
|||
.filter((r): r is IRenderer => r !== undefined);
|
||||
}
|
||||
|
||||
private calculateTotalColumns(viewConfig: ViewConfig): number {
|
||||
const dateCount = viewConfig.groupings.find(g => g.type === 'date')?.values.length || 1;
|
||||
const resourceCount = viewConfig.groupings.find(g => g.type === 'resource')?.values.length || 1;
|
||||
return dateCount * resourceCount;
|
||||
/**
|
||||
* Resolve belongsTo relations to build parent-child map
|
||||
* e.g., belongsTo: 'team.resourceIds' → { team1: ['EMP001', 'EMP002'], team2: [...] }
|
||||
* Also returns the childType (the grouping type that has belongsTo)
|
||||
*/
|
||||
private async resolveBelongsTo(
|
||||
groupings: GroupingConfig[],
|
||||
filter: Record<string, string[]>
|
||||
): Promise<{ parentChildMap?: Record<string, string[]>; childType?: string }> {
|
||||
// Find grouping with belongsTo
|
||||
const childGrouping = groupings.find(g => g.belongsTo);
|
||||
if (!childGrouping?.belongsTo) return {};
|
||||
|
||||
// Parse belongsTo: 'team.resourceIds'
|
||||
const [entityType, property] = childGrouping.belongsTo.split('.');
|
||||
if (!entityType || !property) return {};
|
||||
|
||||
// Get parent IDs from filter
|
||||
const parentIds = filter[entityType] || [];
|
||||
if (parentIds.length === 0) return {};
|
||||
|
||||
// Find service dynamisk baseret på entityType (ingen hardcoded type check)
|
||||
const service = this.entityServices.find(s =>
|
||||
s.entityType.toLowerCase() === entityType
|
||||
);
|
||||
if (!service) return {};
|
||||
|
||||
// Hent alle entities og filtrer på parentIds
|
||||
const allEntities = await service.getAll();
|
||||
const entities = allEntities.filter(e =>
|
||||
parentIds.includes((e as Record<string, unknown>).id as string)
|
||||
);
|
||||
|
||||
// Byg parent-child map
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const entity of entities) {
|
||||
const entityRecord = entity as Record<string, unknown>;
|
||||
const children = (entityRecord[property] as string[]) || [];
|
||||
map[entityRecord.id as string] = children;
|
||||
}
|
||||
|
||||
return { parentChildMap: map, childType: childGrouping.type };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
src/v2/core/EntityResolver.ts
Normal file
48
src/v2/core/EntityResolver.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { IEntityResolver } from './IEntityResolver';
|
||||
|
||||
/**
|
||||
* EntityResolver - Resolves entities from pre-loaded cache
|
||||
*
|
||||
* Entities must be loaded before use (typically at render time).
|
||||
* This allows synchronous lookups during filtering.
|
||||
*/
|
||||
export class EntityResolver implements IEntityResolver {
|
||||
private cache: Map<string, Map<string, Record<string, unknown>>> = new Map();
|
||||
|
||||
/**
|
||||
* Load entities into cache for a given type
|
||||
* @param entityType - The entity type (e.g., 'resource')
|
||||
* @param entities - Array of entities with 'id' property
|
||||
*/
|
||||
load<T extends { id: string }>(entityType: string, entities: T[]): void {
|
||||
const typeCache = new Map<string, Record<string, unknown>>();
|
||||
for (const entity of entities) {
|
||||
// Cast to Record for storage while preserving original data
|
||||
typeCache.set(entity.id, entity as unknown as Record<string, unknown>);
|
||||
}
|
||||
this.cache.set(entityType, typeCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an entity by type and ID
|
||||
*/
|
||||
resolve(entityType: string, id: string): Record<string, unknown> | undefined {
|
||||
const typeCache = this.cache.get(entityType);
|
||||
if (!typeCache) return undefined;
|
||||
return typeCache.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached entities
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific entity type
|
||||
*/
|
||||
clearType(entityType: string): void {
|
||||
this.cache.delete(entityType);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { DateService } from './DateService';
|
||||
import { IEntityResolver } from './IEntityResolver';
|
||||
|
||||
/**
|
||||
* Field definition for FilterTemplate
|
||||
|
|
@ -9,12 +10,24 @@ interface IFilterField {
|
|||
derivedFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed dot-notation reference
|
||||
*/
|
||||
interface IDotNotation {
|
||||
entityType: string; // e.g., 'resource'
|
||||
property: string; // e.g., 'teamId'
|
||||
foreignKey: string; // e.g., 'resourceId'
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterTemplate - Bygger nøgler til event-kolonne matching
|
||||
*
|
||||
* ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle.
|
||||
* Samme template bruges til at bygge nøgle for både kolonne og event.
|
||||
*
|
||||
* Supports dot-notation for hierarchical relations:
|
||||
* - 'resource.teamId' → looks up event.resourceId → resource entity → teamId
|
||||
*
|
||||
* Princip: Kolonnens nøgle-template bestemmer hvad der matches på.
|
||||
*
|
||||
* @see docs/filter-template.md
|
||||
|
|
@ -22,7 +35,10 @@ interface IFilterField {
|
|||
export class FilterTemplate {
|
||||
private fields: IFilterField[] = [];
|
||||
|
||||
constructor(private dateService: DateService) {}
|
||||
constructor(
|
||||
private dateService: DateService,
|
||||
private entityResolver?: IEntityResolver
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Tilføj felt til template
|
||||
|
|
@ -34,25 +50,62 @@ export class FilterTemplate {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dot-notation string into components
|
||||
* @example 'resource.teamId' → { entityType: 'resource', property: 'teamId', foreignKey: 'resourceId' }
|
||||
*/
|
||||
private parseDotNotation(idProperty: string): IDotNotation | null {
|
||||
if (!idProperty.includes('.')) return null;
|
||||
const [entityType, property] = idProperty.split('.');
|
||||
return {
|
||||
entityType,
|
||||
property,
|
||||
foreignKey: entityType + 'Id' // Convention: resource → resourceId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dataset key for column lookup
|
||||
* For dot-notation 'resource.teamId', we look for 'teamId' in dataset
|
||||
*/
|
||||
private getDatasetKey(idProperty: string): string {
|
||||
const dotNotation = this.parseDotNotation(idProperty);
|
||||
if (dotNotation) {
|
||||
return dotNotation.property; // 'teamId'
|
||||
}
|
||||
return idProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byg nøgle fra kolonne
|
||||
* Læser værdier fra column.dataset[idProperty]
|
||||
* For dot-notation, uses the property part (resource.teamId → teamId)
|
||||
*/
|
||||
buildKeyFromColumn(column: HTMLElement): string {
|
||||
return this.fields
|
||||
.map(f => column.dataset[f.idProperty] || '')
|
||||
.map(f => {
|
||||
const key = this.getDatasetKey(f.idProperty);
|
||||
return column.dataset[key] || '';
|
||||
})
|
||||
.join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Byg nøgle fra event
|
||||
* Læser værdier fra event[idProperty] eller udleder fra derivedFrom
|
||||
* For dot-notation, resolves via EntityResolver
|
||||
*/
|
||||
buildKeyFromEvent(event: ICalendarEvent): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const eventRecord = event as any;
|
||||
return this.fields
|
||||
.map(f => {
|
||||
// Check for dot-notation (e.g., 'resource.teamId')
|
||||
const dotNotation = this.parseDotNotation(f.idProperty);
|
||||
if (dotNotation) {
|
||||
return this.resolveDotNotation(eventRecord, dotNotation);
|
||||
}
|
||||
|
||||
if (f.derivedFrom) {
|
||||
// Udled værdi (f.eks. date fra start)
|
||||
const sourceValue = eventRecord[f.derivedFrom];
|
||||
|
|
@ -66,6 +119,27 @@ export class FilterTemplate {
|
|||
.join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dot-notation reference via EntityResolver
|
||||
*/
|
||||
private resolveDotNotation(eventRecord: Record<string, unknown>, dotNotation: IDotNotation): string {
|
||||
if (!this.entityResolver) {
|
||||
console.warn(`FilterTemplate: EntityResolver required for dot-notation '${dotNotation.entityType}.${dotNotation.property}'`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get foreign key value from event (e.g., resourceId)
|
||||
const foreignId = eventRecord[dotNotation.foreignKey];
|
||||
if (!foreignId) return '';
|
||||
|
||||
// Resolve entity
|
||||
const entity = this.entityResolver.resolve(dotNotation.entityType, String(foreignId));
|
||||
if (!entity) return '';
|
||||
|
||||
// Return property value from entity
|
||||
return String(entity[dotNotation.property] || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match event mod kolonne
|
||||
*/
|
||||
|
|
|
|||
15
src/v2/core/IEntityResolver.ts
Normal file
15
src/v2/core/IEntityResolver.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* IEntityResolver - Resolves entities by type and ID
|
||||
*
|
||||
* Used by FilterTemplate to resolve dot-notation references like 'resource.teamId'
|
||||
* where the value needs to be looked up from a related entity.
|
||||
*/
|
||||
export interface IEntityResolver {
|
||||
/**
|
||||
* Resolve an entity by type and ID
|
||||
* @param entityType - The entity type (e.g., 'resource', 'booking', 'customer')
|
||||
* @param id - The entity ID
|
||||
* @returns The entity record or undefined if not found
|
||||
*/
|
||||
resolve(entityType: string, id: string): Record<string, unknown> | undefined;
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ export interface IRenderContext {
|
|||
headerContainer: HTMLElement;
|
||||
columnContainer: HTMLElement;
|
||||
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
|
||||
parentChildMap?: Record<string, string[]>; // { team1: ['EMP001', 'EMP002'], team2: ['EMP003', 'EMP004'] }
|
||||
childType?: string; // The type of the child grouping (e.g., 'resource' when team has belongsTo)
|
||||
}
|
||||
|
||||
export interface IRenderer {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface ViewConfig {
|
|||
export interface GroupingConfig {
|
||||
type: string;
|
||||
values: string[];
|
||||
idProperty: string; // Property-navn på event (f.eks. 'resourceId', 'teamId')
|
||||
idProperty?: string; // Property-navn på event (f.eks. 'resourceId') - kun for event matching
|
||||
derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start')
|
||||
parentKey?: string;
|
||||
belongsTo?: string; // Parent-child relation (f.eks. 'team.resourceIds')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue