feat(dees-appui-base): overhaul AppUI core: replace simple view rendering with a full-featured ViewRegistry (caching, hide/show lifecycle, async lazy-loading), introduce view lifecycle hooks and activation context, add activity log API/component, remove built-in router and state manager, and update configuration interfaces and demos
This commit is contained in:
@@ -1,13 +1,31 @@
|
||||
import { html, render, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { IViewDefinition } from '../../interfaces/appconfig.js';
|
||||
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
|
||||
@@ -56,20 +74,222 @@ export class ViewRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find view by route
|
||||
* Find view by route (supports parameterized routes like 'settings/:section')
|
||||
*/
|
||||
public findByRoute(route: string): IViewDefinition | undefined {
|
||||
public findByRoute(route: string): { view: IViewDefinition; params: Record<string, string> } | undefined {
|
||||
for (const view of this.views.values()) {
|
||||
const viewRoute = view.route || view.id;
|
||||
if (viewRoute === route) {
|
||||
return view;
|
||||
const params = this.matchRoute(viewRoute, route);
|
||||
if (params !== null) {
|
||||
return { view, params };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a view's content into a container
|
||||
* 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);
|
||||
@@ -78,25 +298,22 @@ export class ViewRegistry {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
// For legacy compatibility, clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
let element: HTMLElement;
|
||||
const content = view.content;
|
||||
|
||||
if (typeof view.content === 'string') {
|
||||
// Tag name string
|
||||
element = document.createElement(view.content);
|
||||
} else if (typeof view.content === 'function') {
|
||||
// Check if it's a class constructor or template function
|
||||
if (view.content.prototype instanceof HTMLElement) {
|
||||
// Element class constructor
|
||||
element = new (view.content as new () => HTMLElement)();
|
||||
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 {
|
||||
// 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 = (view.content as () => TemplateResult)();
|
||||
const template = (content as () => TemplateResult)();
|
||||
render(template, wrapper);
|
||||
element = wrapper;
|
||||
}
|
||||
@@ -126,10 +343,29 @@ export class ViewRegistry {
|
||||
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;
|
||||
}
|
||||
@@ -138,7 +374,7 @@ export class ViewRegistry {
|
||||
* Unregister a view
|
||||
*/
|
||||
public unregister(viewId: string): boolean {
|
||||
this.instances.delete(viewId);
|
||||
this.clearInstance(viewId);
|
||||
return this.views.delete(viewId);
|
||||
}
|
||||
|
||||
@@ -147,8 +383,7 @@ export class ViewRegistry {
|
||||
*/
|
||||
public clear(): void {
|
||||
this.views.clear();
|
||||
this.instances.clear();
|
||||
this.currentViewId = null;
|
||||
this.clearInstances();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user