feat(dees-appui-base): Add unified App UI API to dees-appui-base with ViewRegistry, AppRouter and StateManager

This commit is contained in:
2025-12-09 08:26:24 +00:00
parent 3616bbb9a7
commit 13fa654c0f
10 changed files with 1233 additions and 2 deletions

View File

@@ -0,0 +1,271 @@
import type { IRoutingConfig, IViewDefinition } from '../../interfaces/appconfig.js';
import type { ViewRegistry } from './view.registry.js';
export type TRouteChangeCallback = (viewId: string, params?: Record<string, string>) => void;
/**
* Router for managing view navigation and URL synchronization
*/
export class AppRouter {
private config: Required<Omit<IRoutingConfig, 'notFound'>> & Pick<IRoutingConfig, 'notFound'>;
private viewRegistry: ViewRegistry;
private listeners: Set<TRouteChangeCallback> = new Set();
private currentViewId: string | null = null;
private isInitialized: boolean = false;
constructor(config: IRoutingConfig, viewRegistry: ViewRegistry) {
this.config = {
mode: config.mode,
basePath: config.basePath || '',
defaultView: config.defaultView || '',
syncUrl: config.syncUrl ?? true,
notFound: config.notFound,
};
this.viewRegistry = viewRegistry;
}
/**
* Initialize the router
*/
public init(): void {
if (this.isInitialized) return;
if (this.config.mode === 'hash') {
window.addEventListener('hashchange', this.handleHashChange);
// Check initial hash
const initialView = this.getViewFromHash();
if (initialView) {
this.navigate(initialView, { source: 'initial' });
} else if (this.config.defaultView) {
this.navigate(this.config.defaultView, { source: 'initial' });
}
} else if (this.config.mode === 'history') {
window.addEventListener('popstate', this.handlePopState);
// Check initial path
const initialView = this.getViewFromPath();
if (initialView) {
this.navigate(initialView, { source: 'initial' });
} else if (this.config.defaultView) {
this.navigate(this.config.defaultView, { source: 'initial' });
}
} else if (this.config.mode === 'none' && this.config.defaultView) {
this.navigate(this.config.defaultView, { source: 'initial' });
}
// For 'external' mode, we don't set up listeners - the external router handles it
this.isInitialized = true;
}
/**
* Navigate to a view by ID
*/
public navigate(
viewId: string,
options: {
source?: 'navigation' | 'popstate' | 'initial' | 'programmatic';
replace?: boolean;
params?: Record<string, string>;
} = {}
): boolean {
const { source = 'programmatic', replace = false, params } = options;
const view = this.viewRegistry.get(viewId);
if (!view) {
console.warn(`Cannot navigate to unknown view: ${viewId}`);
if (this.config.notFound) {
if (typeof this.config.notFound === 'function') {
this.config.notFound();
} else {
return this.navigate(this.config.notFound, { source, replace: true });
}
}
return false;
}
const previousViewId = this.currentViewId;
this.currentViewId = viewId;
// Update URL if configured
if (this.config.syncUrl && this.config.mode !== 'none' && this.config.mode !== 'external') {
this.updateUrl(view, replace);
}
// Notify listeners
this.notifyListeners(viewId, params);
return true;
}
/**
* Navigate back in history
*/
public back(): void {
if (this.config.mode === 'hash' || this.config.mode === 'history') {
window.history.back();
}
}
/**
* Navigate forward in history
*/
public forward(): void {
if (this.config.mode === 'hash' || this.config.mode === 'history') {
window.history.forward();
}
}
/**
* Get current view ID
*/
public getCurrentViewId(): string | null {
return this.currentViewId;
}
/**
* Add a route change listener
*/
public onRouteChange(callback: TRouteChangeCallback): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
/**
* Handle external navigation (for external router mode)
*/
public handleExternalNavigation(viewId: string, params?: Record<string, string>): void {
if (this.config.mode !== 'external') {
console.warn('handleExternalNavigation should only be used in external mode');
}
const previousViewId = this.currentViewId;
this.currentViewId = viewId;
this.notifyListeners(viewId, params);
}
/**
* Sync state with URL (for external router integration)
*/
public syncWithUrl(): string | null {
if (this.config.mode === 'hash') {
return this.getViewFromHash();
} else if (this.config.mode === 'history') {
return this.getViewFromPath();
}
return null;
}
/**
* Get the current route from the URL
*/
public getCurrentRoute(): string {
if (this.config.mode === 'hash') {
return window.location.hash.slice(1) || '';
} else if (this.config.mode === 'history') {
let path = window.location.pathname;
if (this.config.basePath && path.startsWith(this.config.basePath)) {
path = path.slice(this.config.basePath.length);
}
return path.replace(/^\//, '');
}
return '';
}
/**
* Build a URL for a view
*/
public buildUrl(viewId: string): string {
const view = this.viewRegistry.get(viewId);
const route = view?.route || viewId;
if (this.config.mode === 'hash') {
return `#${route}`;
} else if (this.config.mode === 'history') {
return `${this.config.basePath}/${route}`;
}
return '';
}
/**
* Destroy the router
*/
public destroy(): void {
if (this.config.mode === 'hash') {
window.removeEventListener('hashchange', this.handleHashChange);
} else if (this.config.mode === 'history') {
window.removeEventListener('popstate', this.handlePopState);
}
this.listeners.clear();
this.isInitialized = false;
}
// Private methods
private handleHashChange = (): void => {
const viewId = this.getViewFromHash();
if (viewId && viewId !== this.currentViewId) {
this.navigate(viewId, { source: 'popstate' });
}
};
private handlePopState = (): void => {
const viewId = this.getViewFromPath();
if (viewId && viewId !== this.currentViewId) {
this.navigate(viewId, { source: 'popstate' });
}
};
private getViewFromHash(): string | null {
const hash = window.location.hash.slice(1); // Remove #
if (!hash) return null;
// Try to find view by route
const view = this.viewRegistry.findByRoute(hash);
return view?.id || null;
}
private getViewFromPath(): string | null {
let path = window.location.pathname;
// Remove base path if configured
if (this.config.basePath) {
if (path.startsWith(this.config.basePath)) {
path = path.slice(this.config.basePath.length);
}
}
// Remove leading slash
path = path.replace(/^\//, '');
if (!path) return null;
const view = this.viewRegistry.findByRoute(path);
return view?.id || null;
}
private updateUrl(view: IViewDefinition, replace: boolean): void {
const route = view.route || view.id;
if (this.config.mode === 'hash') {
const newHash = `#${route}`;
if (replace) {
window.history.replaceState(null, '', newHash);
} else {
window.history.pushState(null, '', newHash);
}
} else if (this.config.mode === 'history') {
const basePath = this.config.basePath || '';
const newPath = `${basePath}/${route}`;
if (replace) {
window.history.replaceState({ viewId: view.id }, '', newPath);
} else {
window.history.pushState({ viewId: view.id }, '', newPath);
}
}
}
private notifyListeners(viewId: string, params?: Record<string, string>): void {
for (const listener of this.listeners) {
listener(viewId, params);
}
}
}

View File

@@ -17,6 +17,11 @@ import type { DeesAppuiMaincontent } from '../dees-appui-maincontent/dees-appui-
import type { DeesAppuiActivitylog } from '../dees-appui-activitylog/dees-appui-activitylog.js';
import { demoFunc } from './dees-appui-base.demo.js';
// New module imports
import { ViewRegistry } from './view.registry.js';
import { AppRouter } from './app.router.js';
import { StateManager } from './state.manager.js';
// Import child components
import '../dees-appui-appbar/index.js';
import '../dees-appui-mainmenu/dees-appui-mainmenu.js';
@@ -116,6 +121,19 @@ export class DeesAppuiBase extends DeesElement {
@state()
accessor activitylog: DeesAppuiActivitylog | undefined = undefined;
// NEW: Unified config property
@property({ type: Object })
accessor config: interfaces.IAppConfig | undefined = undefined;
// NEW: Current view state
@state()
accessor currentView: interfaces.IViewDefinition | undefined = undefined;
// NEW: Internal services (not reactive, managed internally)
private viewRegistry: ViewRegistry = new ViewRegistry();
private router: AppRouter | null = null;
private stateManager: StateManager | null = null;
public static styles = [
cssManager.defaultStyles,
css`
@@ -155,6 +173,15 @@ export class DeesAppuiBase extends DeesElement {
position: relative;
z-index: 1;
}
/* View container for dynamically loaded views */
.view-container {
display: contents;
}
.view-container:empty {
display: none;
}
`,
];
@@ -200,6 +227,7 @@ export class DeesAppuiBase extends DeesElement {
<dees-appui-maincontent
.tabs=${this.maincontentTabs}
>
<div class="view-container"></div>
<slot name="maincontent"></slot>
</dees-appui-maincontent>
<dees-appui-activitylog></dees-appui-activitylog>
@@ -214,6 +242,24 @@ export class DeesAppuiBase extends DeesElement {
this.secondarymenu = this.shadowRoot.querySelector('dees-appui-secondarymenu');
this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent');
this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog');
// Initialize from config if provided
if (this.config) {
this.applyConfig(this.config);
// Restore state if enabled
if (this.config.statePersistence?.enabled) {
this.loadState();
}
// Initialize router after state restore
this.router?.init();
}
}
async disconnectedCallback() {
await super.disconnectedCallback();
this.router?.destroy();
}
// Event handlers for appbar
@@ -295,4 +341,242 @@ export class DeesAppuiBase extends DeesElement {
composed: true
}));
}
// ==========================================
// NEW: Public methods for unified config API
// ==========================================
/**
* Configure the app shell with a unified config object
*/
public configure(config: interfaces.IAppConfig): void {
this.config = config;
this.applyConfig(config);
}
/**
* Navigate to a view by ID
*/
public navigateToView(viewId: string): boolean {
if (this.router) {
return this.router.navigate(viewId);
}
// Fallback for non-routed mode
const view = this.viewRegistry.get(viewId);
if (view) {
this.loadView(view);
return true;
}
return false;
}
/**
* Get the current view
*/
public getCurrentView(): interfaces.IViewDefinition | undefined {
return this.currentView;
}
/**
* Get UI state for serialization
*/
public getUIState(): interfaces.IAppUIState {
return {
currentViewId: this.currentView?.id,
mainMenuCollapsed: this.mainmenuCollapsed,
secondaryMenuCollapsed: this.secondarymenuCollapsed,
secondaryMenuSelectedKey: this.secondarymenuSelectedItem?.key,
collapsedGroups: [], // TODO: Get from secondarymenu if needed
timestamp: Date.now(),
};
}
/**
* Restore UI state from a state object
*/
public restoreUIState(state: interfaces.IAppUIState): void {
if (state.mainMenuCollapsed !== undefined) {
this.mainmenuCollapsed = state.mainMenuCollapsed;
}
if (state.secondaryMenuCollapsed !== undefined) {
this.secondarymenuCollapsed = state.secondaryMenuCollapsed;
}
if (state.currentViewId) {
this.navigateToView(state.currentViewId);
}
}
/**
* Save current UI state
*/
public saveState(): void {
this.stateManager?.save(this.getUIState());
}
/**
* Load and restore saved UI state
*/
public loadState(): boolean {
const state = this.stateManager?.load();
if (state) {
this.restoreUIState(state);
return true;
}
return false;
}
/**
* Get access to the view registry
*/
public getViewRegistry(): ViewRegistry {
return this.viewRegistry;
}
/**
* Get access to the router
*/
public getRouter(): AppRouter | null {
return this.router;
}
// ==========================================
// NEW: Private helper methods
// ==========================================
private applyConfig(config: interfaces.IAppConfig): void {
// Register views
if (config.views) {
this.viewRegistry.clear();
this.viewRegistry.registerAll(config.views);
}
// Apply branding
if (config.branding) {
this.mainmenuLogoIcon = config.branding.logoIcon || '';
this.mainmenuLogoText = config.branding.logoText || '';
}
// Apply app bar config
if (config.appBar) {
this.appbarMenuItems = config.appBar.menuItems || [];
this.appbarBreadcrumbs = config.appBar.breadcrumbs || '';
this.appbarBreadcrumbSeparator = config.appBar.breadcrumbSeparator || ' > ';
this.appbarShowWindowControls = config.appBar.showWindowControls ?? true;
this.appbarShowSearch = config.appBar.showSearch ?? false;
this.appbarUser = config.appBar.user;
this.appbarProfileMenuItems = config.appBar.profileMenuItems || [];
}
// Build main menu from view references
if (config.mainMenu) {
this.mainmenuGroups = this.buildMainMenuGroups(config);
this.mainmenuBottomTabs = this.buildBottomTabs(config);
}
// Initialize state manager
if (config.statePersistence) {
this.stateManager = new StateManager(config.statePersistence);
}
// Initialize router
if (config.routing && config.routing.mode !== 'none') {
this.router = new AppRouter(config.routing, this.viewRegistry);
this.router.onRouteChange((viewId) => {
const view = this.viewRegistry.get(viewId);
if (view) {
this.loadView(view);
}
});
}
// Bind event callbacks
if (config.onViewChange) {
this.addEventListener('view-change', ((e: CustomEvent) => {
config.onViewChange!(e.detail.viewId, e.detail.view);
}) as EventListener);
}
if (config.onSearch) {
this.addEventListener('appbar-search-click', () => {
config.onSearch!();
});
}
}
private buildMainMenuGroups(config: interfaces.IAppConfig): interfaces.IMenuGroup[] {
if (!config.mainMenu?.sections) return [];
return config.mainMenu.sections.map((section) => ({
name: section.name,
tabs: section.views
.map((viewId) => {
const view = this.viewRegistry.get(viewId);
if (!view) {
console.warn(`View "${viewId}" not found in registry`);
return null;
}
return {
key: view.name,
iconName: view.iconName,
action: () => this.navigateToView(viewId),
};
})
.filter(Boolean) as interfaces.ITab[],
}));
}
private buildBottomTabs(config: interfaces.IAppConfig): interfaces.ITab[] {
if (!config.mainMenu?.bottomItems) return [];
return config.mainMenu.bottomItems
.map((viewId) => {
const view = this.viewRegistry.get(viewId);
if (!view) {
console.warn(`View "${viewId}" not found in registry`);
return null;
}
return {
key: view.name,
iconName: view.iconName,
action: () => this.navigateToView(viewId),
};
})
.filter(Boolean) as interfaces.ITab[];
}
private loadView(view: interfaces.IViewDefinition): void {
const previousView = this.currentView;
this.currentView = view;
// Update secondary menu
if (view.secondaryMenu) {
this.secondarymenuGroups = view.secondaryMenu;
this.secondarymenuHeading = view.name;
}
// Update content tabs
if (view.contentTabs) {
this.maincontentTabs = view.contentTabs;
}
// Render view content into the view container
const viewContainer = this.maincontent?.shadowRoot?.querySelector('.view-container')
|| this.shadowRoot?.querySelector('.view-container');
if (viewContainer) {
this.viewRegistry.renderView(view.id, viewContainer as HTMLElement);
}
// Save state if configured
this.stateManager?.update({ currentViewId: view.id });
// Dispatch event
this.dispatchEvent(
new CustomEvent('view-change', {
detail: { viewId: view.id, view, previousView },
bubbles: true,
composed: true,
})
);
}
}

View File

@@ -1 +1,4 @@
export * from './dees-appui-base.js';
export * from './view.registry.js';
export * from './app.router.js';
export * from './state.manager.js';

View File

@@ -0,0 +1,185 @@
import type { IStatePersistenceConfig, IAppUIState } from '../../interfaces/appconfig.js';
/**
* Manager for persisting and restoring UI state
*/
export class StateManager {
private config: Required<IStatePersistenceConfig>;
private memoryStorage: Map<string, string> = new Map();
constructor(config: IStatePersistenceConfig = { enabled: false }) {
this.config = {
enabled: config.enabled,
storageKey: config.storageKey || 'dees-appui-state',
storage: config.storage || 'localStorage',
persist: {
mainMenuCollapsed: true,
secondaryMenuCollapsed: true,
selectedView: true,
secondaryMenuSelection: true,
collapsedGroups: true,
...config.persist,
},
};
}
/**
* Check if state persistence is enabled
*/
public isEnabled(): boolean {
return this.config.enabled;
}
/**
* Save current UI state
*/
public save(state: Partial<IAppUIState>): void {
if (!this.config.enabled) return;
const existingState = this.load() || {};
const newState: IAppUIState = {
...existingState,
timestamp: Date.now(),
};
// Only save what's configured
if (this.config.persist.selectedView && state.currentViewId !== undefined) {
newState.currentViewId = state.currentViewId;
}
if (this.config.persist.mainMenuCollapsed && state.mainMenuCollapsed !== undefined) {
newState.mainMenuCollapsed = state.mainMenuCollapsed;
}
if (this.config.persist.secondaryMenuCollapsed && state.secondaryMenuCollapsed !== undefined) {
newState.secondaryMenuCollapsed = state.secondaryMenuCollapsed;
}
if (this.config.persist.secondaryMenuSelection && state.secondaryMenuSelectedKey !== undefined) {
newState.secondaryMenuSelectedKey = state.secondaryMenuSelectedKey;
}
if (this.config.persist.collapsedGroups && state.collapsedGroups !== undefined) {
newState.collapsedGroups = state.collapsedGroups;
}
this.setItem(this.config.storageKey, JSON.stringify(newState));
}
/**
* Load persisted UI state
*/
public load(): IAppUIState | null {
if (!this.config.enabled) return null;
try {
const data = this.getItem(this.config.storageKey);
if (!data) return null;
return JSON.parse(data) as IAppUIState;
} catch (e) {
console.warn('Failed to load UI state:', e);
return null;
}
}
/**
* Clear persisted state
*/
public clear(): void {
this.removeItem(this.config.storageKey);
}
/**
* Check if state exists
*/
public hasState(): boolean {
return this.getItem(this.config.storageKey) !== null;
}
/**
* Get state age in milliseconds
*/
public getStateAge(): number | null {
const state = this.load();
if (!state?.timestamp) return null;
return Date.now() - state.timestamp;
}
/**
* Update specific state properties
*/
public update(updates: Partial<IAppUIState>): void {
const currentState = this.load() || {};
this.save({ ...currentState, ...updates });
}
/**
* Get the storage key being used
*/
public getStorageKey(): string {
return this.config.storageKey;
}
// Storage abstraction methods
private getItem(key: string): string | null {
switch (this.config.storage) {
case 'localStorage':
try {
return localStorage.getItem(key);
} catch {
return null;
}
case 'sessionStorage':
try {
return sessionStorage.getItem(key);
} catch {
return null;
}
case 'memory':
return this.memoryStorage.get(key) || null;
default:
return null;
}
}
private setItem(key: string, value: string): void {
switch (this.config.storage) {
case 'localStorage':
try {
localStorage.setItem(key, value);
} catch (e) {
console.warn('Failed to save to localStorage:', e);
}
break;
case 'sessionStorage':
try {
sessionStorage.setItem(key, value);
} catch (e) {
console.warn('Failed to save to sessionStorage:', e);
}
break;
case 'memory':
this.memoryStorage.set(key, value);
break;
}
}
private removeItem(key: string): void {
switch (this.config.storage) {
case 'localStorage':
try {
localStorage.removeItem(key);
} catch {
// Ignore
}
break;
case 'sessionStorage':
try {
sessionStorage.removeItem(key);
} catch {
// Ignore
}
break;
case 'memory':
this.memoryStorage.delete(key);
break;
}
}
}

View File

@@ -0,0 +1,167 @@
import { html, render, type TemplateResult } from '@design.estate/dees-element';
import type { IViewDefinition } from '../../interfaces/appconfig.js';
/**
* Registry for managing views and their lifecycle
*/
export class ViewRegistry {
private views: Map<string, IViewDefinition> = new Map();
private instances: Map<string, HTMLElement> = new Map();
private currentViewId: string | null = null;
/**
* 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
*/
public findByRoute(route: string): IViewDefinition | undefined {
for (const view of this.views.values()) {
const viewRoute = view.route || view.id;
if (viewRoute === route) {
return view;
}
}
return undefined;
}
/**
* Render a view's content into a container
*/
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;
}
// Clear container
container.innerHTML = '';
let element: HTMLElement;
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)();
} 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)();
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 all instances
*/
public clearInstances(): void {
this.instances.clear();
this.currentViewId = null;
}
/**
* Unregister a view
*/
public unregister(viewId: string): boolean {
this.instances.delete(viewId);
return this.views.delete(viewId);
}
/**
* Clear the registry
*/
public clear(): void {
this.views.clear();
this.instances.clear();
this.currentViewId = null;
}
/**
* 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;
}
}