403 lines
11 KiB
TypeScript
403 lines
11 KiB
TypeScript
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<string, IViewDefinition> = new Map();
|
|
private instances: Map<string, HTMLElement> = 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<string, string> } | 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<string, string> | 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<string, string> = {};
|
|
|
|
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<boolean | string> {
|
|
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<string, string>
|
|
): Promise<HTMLElement | null> {
|
|
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<void> {
|
|
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<HTMLElement | null> {
|
|
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<string | (new () => 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<string, string>
|
|
): Promise<void> {
|
|
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;
|
|
}
|
|
}
|