import { html, render, type TemplateResult } from '@design.estate/dees-element'; import type { IViewDefinition, IViewActivationContext, IViewLifecycle, TDeesAppuiBase } from '../../interfaces/appconfig.js'; /** * Registry for managing views and their lifecycle * * Key features: * - View caching with hide/show pattern (not destroy/create) * - Async content loading support (lazy loading) * - View lifecycle hooks (onActivate, onDeactivate, canDeactivate) */ export class ViewRegistry { private views: Map = new Map(); private instances: Map = new Map(); private currentViewId: string | null = null; private appui: TDeesAppuiBase | null = null; /** * Set the appui reference for view activation context */ public setAppuiRef(appui: TDeesAppuiBase): void { this.appui = appui; } /** * Register a single view */ public register(view: IViewDefinition): void { if (this.views.has(view.id)) { console.warn(`View with id "${view.id}" already registered. Overwriting.`); } this.views.set(view.id, view); } /** * Register multiple views */ public registerAll(views: IViewDefinition[]): void { views.forEach((view) => this.register(view)); } /** * Get a view definition by ID */ public get(viewId: string): IViewDefinition | undefined { return this.views.get(viewId); } /** * Get all registered view IDs */ public getViewIds(): string[] { return Array.from(this.views.keys()); } /** * Get all views */ public getAll(): IViewDefinition[] { return Array.from(this.views.values()); } /** * Get route for a view */ public getRoute(viewId: string): string { const view = this.views.get(viewId); return view?.route || view?.id || ''; } /** * Find view by route (supports parameterized routes like 'settings/:section') */ public findByRoute(route: string): { view: IViewDefinition; params: Record } | undefined { for (const view of this.views.values()) { const viewRoute = view.route || view.id; const params = this.matchRoute(viewRoute, route); if (params !== null) { return { view, params }; } } return undefined; } /** * Match a route pattern against an actual route * Returns params if matched, null otherwise */ private matchRoute(pattern: string, route: string): Record | null { const patternParts = pattern.split('/'); const routeParts = route.split('/'); // Check for optional trailing param (ends with ?) const hasOptionalParam = patternParts.length > 0 && patternParts[patternParts.length - 1].endsWith('?'); if (hasOptionalParam) { // Allow route to be shorter by 1 if (routeParts.length < patternParts.length - 1 || routeParts.length > patternParts.length) { return null; } } else if (patternParts.length !== routeParts.length) { return null; } const params: Record = {}; for (let i = 0; i < patternParts.length; i++) { let part = patternParts[i]; const isOptional = part.endsWith('?'); if (isOptional) { part = part.slice(0, -1); } if (part.startsWith(':')) { // This is a parameter const paramName = part.slice(1); if (routeParts[i] !== undefined) { params[paramName] = routeParts[i]; } else if (!isOptional) { return null; } } else if (routeParts[i] !== part) { return null; } } return params; } /** * Check if navigation away from current view is allowed */ public async canLeaveCurrentView(): Promise { if (!this.currentViewId) return true; const instance = this.instances.get(this.currentViewId); if (!instance) return true; const lifecycle = instance as unknown as IViewLifecycle; if (typeof lifecycle.canDeactivate === 'function') { return await lifecycle.canDeactivate(); } return true; } /** * Activate a view - handles caching, lifecycle, and rendering */ public async activateView( viewId: string, container: HTMLElement, params?: Record ): Promise { const view = this.views.get(viewId); if (!view) { console.error(`View "${viewId}" not found in registry`); return null; } // Check if caching is enabled for this view (default: true) const shouldCache = view.cache !== false; // Deactivate current view if (this.currentViewId && this.currentViewId !== viewId) { await this.deactivateView(this.currentViewId); } // Check for cached instance let element = shouldCache ? this.instances.get(viewId) : undefined; if (element) { // Reuse cached instance - just show it element.style.display = ''; } else { // Create new instance element = await this.createViewElement(view); if (!element) { console.error(`Failed to create element for view "${viewId}"`); return null; } // Add to container container.appendChild(element); // Cache if enabled if (shouldCache) { this.instances.set(viewId, element); } } this.currentViewId = viewId; // Call onActivate lifecycle hook await this.callOnActivate(element, viewId, params); return element; } /** * Deactivate a view (hide and call lifecycle hook) */ private async deactivateView(viewId: string): Promise { const instance = this.instances.get(viewId); if (!instance) return; // Call onDeactivate lifecycle hook const lifecycle = instance as unknown as IViewLifecycle; if (typeof lifecycle.onDeactivate === 'function') { await lifecycle.onDeactivate(); } // Hide the element instance.style.display = 'none'; } /** * Create a view element from its definition (supports async content) */ private async createViewElement(view: IViewDefinition): Promise { let content = view.content; // Handle async content (lazy loading) if (typeof content === 'function' && !(content.prototype instanceof HTMLElement) && content.constructor.name === 'AsyncFunction') { try { content = await (content as () => Promise HTMLElement) | (() => TemplateResult)>)(); } catch (error) { console.error(`Failed to load async content for view "${view.id}":`, error); return null; } } let element: HTMLElement; if (typeof content === 'string') { // Tag name string element = document.createElement(content); } else if (typeof content === 'function') { // Check if it's a class constructor or template function if (content.prototype instanceof HTMLElement) { // Element class constructor element = new (content as new () => HTMLElement)(); } else { // Template function - wrap in a container and use Lit's render const wrapper = document.createElement('div'); wrapper.className = 'view-content-wrapper'; wrapper.style.cssText = 'display: contents;'; const template = (content as () => TemplateResult)(); render(template, wrapper); element = wrapper; } } else { console.error(`Invalid content type for view "${view.id}"`); return null; } // Add view ID as data attribute for debugging element.dataset.viewId = view.id; return element; } /** * Call onActivate lifecycle hook on a view element */ private async callOnActivate( element: HTMLElement, viewId: string, params?: Record ): Promise { const lifecycle = element as unknown as IViewLifecycle; if (typeof lifecycle.onActivate === 'function') { const context: IViewActivationContext = { appui: this.appui!, viewId, params, }; await lifecycle.onActivate(context); } } /** * Legacy method - renders view without caching * @deprecated Use activateView instead */ public renderView(viewId: string, container: HTMLElement): HTMLElement | null { const view = this.views.get(viewId); if (!view) { console.error(`View "${viewId}" not found in registry`); return null; } // For legacy compatibility, clear container container.innerHTML = ''; let element: HTMLElement; const content = view.content; if (typeof content === 'string') { element = document.createElement(content); } else if (typeof content === 'function') { if ((content as any).prototype instanceof HTMLElement) { element = new (content as new () => HTMLElement)(); } else { const wrapper = document.createElement('div'); wrapper.className = 'view-content-wrapper'; wrapper.style.cssText = 'display: contents;'; const template = (content as () => TemplateResult)(); render(template, wrapper); element = wrapper; } } else { console.error(`Invalid content type for view "${viewId}"`); return null; } container.appendChild(element); this.instances.set(viewId, element); this.currentViewId = viewId; return element; } /** * Get currently active view ID */ public getCurrentViewId(): string | null { return this.currentViewId; } /** * Get cached instance of a view */ public getInstance(viewId: string): HTMLElement | undefined { return this.instances.get(viewId); } /** * Clear a specific cached instance */ public clearInstance(viewId: string): void { const instance = this.instances.get(viewId); if (instance && instance.parentNode) { instance.parentNode.removeChild(instance); } this.instances.delete(viewId); if (this.currentViewId === viewId) { this.currentViewId = null; } } /** * Clear all instances */ public clearInstances(): void { for (const [viewId, instance] of this.instances) { if (instance.parentNode) { instance.parentNode.removeChild(instance); } } this.instances.clear(); this.currentViewId = null; } /** * Unregister a view */ public unregister(viewId: string): boolean { this.clearInstance(viewId); return this.views.delete(viewId); } /** * Clear the registry */ public clear(): void { this.views.clear(); this.clearInstances(); } /** * Check if a view is registered */ public has(viewId: string): boolean { return this.views.has(viewId); } /** * Get the number of registered views */ public get size(): number { return this.views.size; } }