Introduces CalendarApp with centralized event-driven rendering
Refactors calendar component initialization to a single, encapsulated entry point Simplifies host application integration by: - Centralizing complex setup in CalendarApp - Implementing command-driven rendering via custom events - Providing flexible, zero-knowledge calendar component - Maintaining existing ViewConfig contract
This commit is contained in:
parent
2c1af56718
commit
6a56396721
5 changed files with 815 additions and 63 deletions
91
src/v2/core/BaseGroupingRenderer.ts
Normal file
91
src/v2/core/BaseGroupingRenderer.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { IRenderer, IRenderContext } from './IGroupingRenderer';
|
||||
|
||||
/**
|
||||
* Entity must have id
|
||||
*/
|
||||
export interface IGroupingEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a grouping renderer
|
||||
*/
|
||||
export interface IGroupingRendererConfig {
|
||||
elementTag: string; // e.g., 'swp-team-header'
|
||||
idAttribute: string; // e.g., 'teamId' -> data-team-id
|
||||
colspanVar: string; // e.g., '--team-cols'
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for grouping renderers
|
||||
*
|
||||
* Handles:
|
||||
* - Fetching entities by IDs
|
||||
* - Calculating colspan from parentChildMap
|
||||
* - Creating header elements
|
||||
* - Appending to container
|
||||
*
|
||||
* Subclasses override:
|
||||
* - renderHeader() for custom content
|
||||
* - getDisplayName() for entity display text
|
||||
*/
|
||||
export abstract class BaseGroupingRenderer<T extends IGroupingEntity> implements IRenderer {
|
||||
abstract readonly type: string;
|
||||
protected abstract readonly config: IGroupingRendererConfig;
|
||||
|
||||
/**
|
||||
* Fetch entities from service
|
||||
*/
|
||||
protected abstract getEntities(ids: string[]): Promise<T[]>;
|
||||
|
||||
/**
|
||||
* Get display name for entity
|
||||
*/
|
||||
protected abstract getDisplayName(entity: T): string;
|
||||
|
||||
/**
|
||||
* Main render method - handles common logic
|
||||
*/
|
||||
async render(context: IRenderContext): Promise<void> {
|
||||
const allowedIds = context.filter[this.type] || [];
|
||||
if (allowedIds.length === 0) return;
|
||||
|
||||
const entities = await this.getEntities(allowedIds);
|
||||
const dateCount = context.filter['date']?.length || 1;
|
||||
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const entityChildIds = context.parentChildMap?.[entity.id] || [];
|
||||
const childCount = entityChildIds.filter(id => childIds.includes(id)).length;
|
||||
const colspan = childCount * dateCount;
|
||||
|
||||
const header = document.createElement(this.config.elementTag);
|
||||
header.dataset[this.config.idAttribute] = entity.id;
|
||||
header.style.setProperty(this.config.colspanVar, String(colspan));
|
||||
|
||||
// Allow subclass to customize header content
|
||||
this.renderHeader(entity, header, context);
|
||||
|
||||
context.headerContainer.appendChild(header);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method for custom header rendering
|
||||
* Default: just sets textContent to display name
|
||||
*/
|
||||
protected renderHeader(entity: T, header: HTMLElement, _context: IRenderContext): void {
|
||||
header.textContent = this.getDisplayName(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to render a single entity header.
|
||||
* Can be used by subclasses that override render() but want consistent header creation.
|
||||
*/
|
||||
protected createHeader(entity: T, context: IRenderContext): HTMLElement {
|
||||
const header = document.createElement(this.config.elementTag);
|
||||
header.dataset[this.config.idAttribute] = entity.id;
|
||||
this.renderHeader(entity, header, context);
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,25 @@
|
|||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
||||
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||
import { DepartmentService } from '../../storage/departments/DepartmentService';
|
||||
import { IDepartment } from '../../types/CalendarTypes';
|
||||
|
||||
export class DepartmentRenderer implements IRenderer {
|
||||
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
|
||||
readonly type = 'department';
|
||||
|
||||
constructor(private departmentService: DepartmentService) {}
|
||||
protected readonly config: IGroupingRendererConfig = {
|
||||
elementTag: 'swp-department-header',
|
||||
idAttribute: 'departmentId',
|
||||
colspanVar: '--department-cols'
|
||||
};
|
||||
|
||||
async render(context: IRenderContext): Promise<void> {
|
||||
const allowedIds = context.filter[this.type] || [];
|
||||
if (allowedIds.length === 0) return;
|
||||
constructor(private departmentService: DepartmentService) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Fetch departments from IndexedDB (only for name display)
|
||||
const departments = await this.departmentService.getByIds(allowedIds);
|
||||
protected getEntities(ids: string[]): Promise<IDepartment[]> {
|
||||
return this.departmentService.getByIds(ids);
|
||||
}
|
||||
|
||||
const dateCount = context.filter['date']?.length || 1;
|
||||
|
||||
// Get child filter values using childType from context (not hardcoded)
|
||||
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||
|
||||
// Render department headers
|
||||
for (const dept of departments) {
|
||||
// Get children from parentChildMap (resolved from belongsTo config)
|
||||
const deptChildIds = context.parentChildMap?.[dept.id] || [];
|
||||
|
||||
// Count children that belong to this department AND are in the filter
|
||||
const childCount = deptChildIds.filter(id => childIds.includes(id)).length;
|
||||
const colspan = childCount * dateCount;
|
||||
|
||||
const header = document.createElement('swp-department-header');
|
||||
header.dataset.departmentId = dept.id;
|
||||
header.textContent = dept.name;
|
||||
header.style.setProperty('--department-cols', String(colspan));
|
||||
context.headerContainer.appendChild(header);
|
||||
}
|
||||
protected getDisplayName(entity: IDepartment): string {
|
||||
return entity.name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,34 @@
|
|||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
||||
import { IRenderContext } from '../../core/IGroupingRenderer';
|
||||
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||
import { ResourceService } from '../../storage/resources/ResourceService';
|
||||
import { IResource } from '../../types/CalendarTypes';
|
||||
|
||||
export class ResourceRenderer implements IRenderer {
|
||||
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
|
||||
readonly type = 'resource';
|
||||
|
||||
constructor(private resourceService: ResourceService) {}
|
||||
protected readonly config: IGroupingRendererConfig = {
|
||||
elementTag: 'swp-resource-header',
|
||||
idAttribute: 'resourceId',
|
||||
colspanVar: '--resource-cols'
|
||||
};
|
||||
|
||||
constructor(private resourceService: ResourceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected getEntities(ids: string[]): Promise<IResource[]> {
|
||||
return this.resourceService.getByIds(ids);
|
||||
}
|
||||
|
||||
protected getDisplayName(entity: IResource): string {
|
||||
return entity.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override render to handle:
|
||||
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
|
||||
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
|
||||
*/
|
||||
async render(context: IRenderContext): Promise<void> {
|
||||
const resourceIds = context.filter['resource'] || [];
|
||||
const dateCount = context.filter['date']?.length || 1;
|
||||
|
|
@ -29,7 +52,7 @@ export class ResourceRenderer implements IRenderer {
|
|||
orderedResourceIds = resourceIds;
|
||||
}
|
||||
|
||||
const resources = await this.resourceService.getByIds(orderedResourceIds);
|
||||
const resources = await this.getEntities(orderedResourceIds);
|
||||
|
||||
// Create a map for quick lookup to preserve order
|
||||
const resourceMap = new Map(resources.map(r => [r.id, r]));
|
||||
|
|
@ -38,9 +61,7 @@ export class ResourceRenderer implements IRenderer {
|
|||
const resource = resourceMap.get(resourceId);
|
||||
if (!resource) continue;
|
||||
|
||||
const header = document.createElement('swp-resource-header');
|
||||
header.dataset.resourceId = resource.id;
|
||||
header.textContent = resource.displayName;
|
||||
const header = this.createHeader(resource, context);
|
||||
header.style.gridColumn = `span ${dateCount}`;
|
||||
context.headerContainer.appendChild(header);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,25 @@
|
|||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
||||
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||
import { TeamService } from '../../storage/teams/TeamService';
|
||||
import { ITeam } from '../../types/CalendarTypes';
|
||||
|
||||
export class TeamRenderer implements IRenderer {
|
||||
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
|
||||
readonly type = 'team';
|
||||
|
||||
constructor(private teamService: TeamService) {}
|
||||
protected readonly config: IGroupingRendererConfig = {
|
||||
elementTag: 'swp-team-header',
|
||||
idAttribute: 'teamId',
|
||||
colspanVar: '--team-cols'
|
||||
};
|
||||
|
||||
async render(context: IRenderContext): Promise<void> {
|
||||
const allowedIds = context.filter[this.type] || [];
|
||||
if (allowedIds.length === 0) return;
|
||||
constructor(private teamService: TeamService) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Fetch teams from IndexedDB (only for name display)
|
||||
const teams = await this.teamService.getByIds(allowedIds);
|
||||
protected getEntities(ids: string[]): Promise<ITeam[]> {
|
||||
return this.teamService.getByIds(ids);
|
||||
}
|
||||
|
||||
const dateCount = context.filter['date']?.length || 1;
|
||||
|
||||
// Get child filter values using childType from context (not hardcoded)
|
||||
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||
|
||||
// Render team headers
|
||||
for (const team of teams) {
|
||||
// Get children from parentChildMap (resolved from belongsTo config)
|
||||
const teamChildIds = context.parentChildMap?.[team.id] || [];
|
||||
|
||||
// Count children that belong to this team AND are in the filter
|
||||
const childCount = teamChildIds.filter(id => childIds.includes(id)).length;
|
||||
const colspan = childCount * dateCount;
|
||||
|
||||
const header = document.createElement('swp-team-header');
|
||||
header.dataset.teamId = team.id;
|
||||
header.textContent = team.name;
|
||||
header.style.setProperty('--team-cols', String(colspan));
|
||||
context.headerContainer.appendChild(header);
|
||||
}
|
||||
protected getDisplayName(entity: ITeam): string {
|
||||
return entity.name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue