/** * DSS Router * * Centralized hash-based routing with guards, lifecycle hooks, and * declarative route definitions for enterprise-grade navigation management. * * @module router */ import { notifyError, ErrorCode } from './messaging.js'; /** * Route configuration object * @typedef {Object} RouteConfig * @property {string} path - Route path (e.g., '/dashboard', '/projects') * @property {string} name - Route name for programmatic navigation * @property {Function} handler - Route handler function * @property {Function} [beforeEnter] - Guard called before entering route * @property {Function} [afterEnter] - Hook called after entering route * @property {Function} [onLeave] - Hook called when leaving route * @property {Object} [meta] - Route metadata */ /** * Router class for centralized route management */ class Router { constructor() { this.routes = new Map(); this.currentRoute = null; this.previousRoute = null; this.defaultRoute = 'projects'; // Updated default for new architecture this.isNavigating = false; // Bind handlers this.handleHashChange = this.handleHashChange.bind(this); this.handlePopState = this.handlePopState.bind(this); } /** * Initialize the router */ init() { // Register new feature module routes this.registerAll([ { path: '/projects', name: 'Projects', handler: () => this.loadModule('dss-projects-module', () => import('../modules/projects/ProjectsModule.js')) }, { path: '/config', name: 'Configuration', handler: () => this.loadModule('dss-config-module', () => import('../modules/config/ConfigModule.js')) }, { path: '/components', name: 'Components', handler: () => this.loadModule('dss-components-module', () => import('../modules/components/ComponentsModule.js')) }, { path: '/translations', name: 'Translations', handler: () => this.loadModule('dss-translations-module', () => import('../modules/translations/TranslationsModule.js')) }, { path: '/discovery', name: 'Discovery', handler: () => this.loadModule('dss-discovery-module', () => import('../modules/discovery/DiscoveryModule.js')) }, { path: '/admin', name: 'Admin', handler: () => this.loadModule('dss-admin-module', () => import('../modules/admin/AdminModule.js')) } ]); // Listen for hash changes window.addEventListener('hashchange', this.handleHashChange); window.addEventListener('popstate', this.handlePopState); // Handle initial route this.handleHashChange(); } /** * Helper to load dynamic modules into the stage * @param {string} tagName - Custom element tag name * @param {Function} importFn - Dynamic import function */ async loadModule(tagName, importFn) { try { // 1. Load the module file await importFn(); // 2. Update the stage content const stageContent = document.querySelector('#stage-workdesk-content'); if (stageContent) { stageContent.innerHTML = ''; const element = document.createElement(tagName); stageContent.appendChild(element); } } catch (error) { console.error(`Failed to load module ${tagName}:`, error); notifyError(`Failed to load module`, ErrorCode.SYSTEM_UNEXPECTED); } } /** * Register a route * @param {RouteConfig} config - Route configuration */ register(config) { if (!config.path) { throw new Error('Route path is required'); } if (!config.handler) { throw new Error('Route handler is required'); } // Normalize path (remove leading slash for hash routing) const path = config.path.replace(/^\//, ''); this.routes.set(path, { path, name: config.name || path, handler: config.handler, beforeEnter: config.beforeEnter || null, afterEnter: config.afterEnter || null, onLeave: config.onLeave || null, meta: config.meta || {}, }); return this; } /** * Register multiple routes * @param {RouteConfig[]} routes - Array of route configurations */ registerAll(routes) { routes.forEach(route => this.register(route)); return this; } /** * Set default route * @param {string} path - Default route path */ setDefaultRoute(path) { this.defaultRoute = path.replace(/^\//, ''); return this; } /** * Navigate to a route * @param {string} path - Route path or name * @param {Object} [options] - Navigation options * @param {boolean} [options.replace] - Replace history instead of push * @param {Object} [options.state] - State to pass to route */ navigate(path, options = {}) { const normalizedPath = path.replace(/^\//, ''); // Update hash if (options.replace) { window.location.replace(`#${normalizedPath}`); } else { window.location.hash = normalizedPath; } return this; } /** * Navigate to a route by name * @param {string} name - Route name * @param {Object} [options] - Navigation options */ navigateByName(name, options = {}) { const route = Array.from(this.routes.values()).find(r => r.name === name); if (!route) { notifyError(`Route not found: ${name}`, ErrorCode.SYSTEM_UNEXPECTED); return this; } return this.navigate(route.path, options); } /** * Handle hash change event */ async handleHashChange() { if (this.isNavigating) return; this.isNavigating = true; try { // Get path from hash let path = window.location.hash.replace(/^#/, '') || this.defaultRoute; // Extract route and params const { routePath, params } = this.parseRoute(path); // Find route const route = this.routes.get(routePath); if (!route) { console.warn(`Route not registered: ${routePath}, falling back to default`); this.navigate(this.defaultRoute, { replace: true }); return; } // Call onLeave hook for previous route if (this.currentRoute && this.currentRoute.onLeave) { await this.callHook(this.currentRoute.onLeave, { from: this.currentRoute, to: route }); } // Call beforeEnter guard if (route.beforeEnter) { const canEnter = await this.callGuard(route.beforeEnter, { route, params }); if (!canEnter) { // Guard rejected, stay on current route or go to default if (this.currentRoute) { this.navigate(this.currentRoute.path, { replace: true }); } else { this.navigate(this.defaultRoute, { replace: true }); } return; } } // Update route tracking this.previousRoute = this.currentRoute; this.currentRoute = route; // Call route handler await route.handler({ route, params, router: this }); // Call afterEnter hook if (route.afterEnter) { await this.callHook(route.afterEnter, { route, params, from: this.previousRoute }); } // Emit route change event this.emitRouteChange(route, params); } catch (error) { console.error('Router navigation error:', error); notifyError('Navigation failed', ErrorCode.SYSTEM_UNEXPECTED, { path: window.location.hash, error: error.message, }); } finally { this.isNavigating = false; } } /** * Handle popstate event (browser back/forward) */ handlePopState(event) { this.handleHashChange(); } /** * Parse route path and extract params * @param {string} path - Route path * @returns {Object} Route path and params */ parseRoute(path) { // For now, simple implementation - just return the path // Can be extended to support params like '/projects/:id' const [routePath, queryString] = path.split('?'); const params = {}; if (queryString) { new URLSearchParams(queryString).forEach((value, key) => { params[key] = value; }); } return { routePath, params }; } /** * Call a route guard * @param {Function} guard - Guard function * @param {Object} context - Guard context * @returns {Promise} Whether navigation should proceed */ async callGuard(guard, context) { try { const result = await guard(context); return result !== false; // Undefined or true = proceed } catch (error) { console.error('Route guard error:', error); return false; } } /** * Call a lifecycle hook * @param {Function} hook - Hook function * @param {Object} context - Hook context */ async callHook(hook, context) { try { await hook(context); } catch (error) { console.error('Route hook error:', error); } } /** * Emit route change event * @param {Object} route - Current route * @param {Object} params - Route params */ emitRouteChange(route, params) { window.dispatchEvent(new CustomEvent('route-changed', { detail: { route, params, previous: this.previousRoute, } })); } /** * Get current route * @returns {Object|null} Current route config */ getCurrentRoute() { return this.currentRoute; } /** * Get previous route * @returns {Object|null} Previous route config */ getPreviousRoute() { return this.previousRoute; } /** * Check if a route exists * @param {string} path - Route path * @returns {boolean} Whether route exists */ hasRoute(path) { const normalizedPath = path.replace(/^\//, ''); return this.routes.has(normalizedPath); } /** * Get route by path * @param {string} path - Route path * @returns {Object|null} Route config */ getRoute(path) { const normalizedPath = path.replace(/^\//, ''); return this.routes.get(normalizedPath) || null; } /** * Get all routes * @returns {Array} All registered routes */ getAllRoutes() { return Array.from(this.routes.values()); } /** * Go back in history */ back() { window.history.back(); return this; } /** * Go forward in history */ forward() { window.history.forward(); return this; } /** * Destroy the router (cleanup) */ destroy() { window.removeEventListener('hashchange', this.handleHashChange); window.removeEventListener('popstate', this.handlePopState); this.routes.clear(); this.currentRoute = null; this.previousRoute = null; } } // Singleton instance const router = new Router(); // Export both the instance and the class export { Router }; export default router; /** * Common route guards */ export const guards = { /** * Require authentication */ requireAuth({ route }) { // Check if user is authenticated // For now, always allow (implement auth later) return true; }, /** * Require specific permission */ requirePermission(permission) { return ({ route }) => { // Check if user has permission // For now, always allow (implement RBAC later) return true; }; }, /** * Require project selected */ requireProject({ route, params }) { const projectId = localStorage.getItem('dss_selected_project'); if (!projectId) { notifyError('Please select a project first', ErrorCode.USER_ACTION_FORBIDDEN); return false; } return true; }, };