Design
Ecosistema completo para organización y gestión de contenido con interfaces espaciales
Widget System - Architecture & Design
Diseño técnico y decisiones arquitectónicas para
@gograb-space/widget-system
🎯 Design Goals
- Zero Duplication: Reusar tipos y servicios existentes
- Minimal Changes: Mínimo impacto en código existente
- Clean Integration: Integración natural con arquitectura actual
- Future Proof: Extensible para features post-MVP
🏗️ Architecture Overview
System Context
┌──────────────────────────────────────────────────────────────────┐
│ EXISTING INFRASTRUCTURE (No Modifications) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ @gograb-space/module-base │
│ ├─ ModuleEmbeddableComponent (interface) │
│ │ └─ Defines widget contracts in module manifests │
│ ├─ ComponentRegistryService │
│ │ └─ Centralized registry for embeddable components │
│ └─ ModuleParameterMetadata │
│ └─ Widget input definitions │
│ │
│ @gograb-space/module-config │
│ ├─ ResolvedWidgetConfig (interface) │
│ │ ├─ id, widgetTypeId, displayName │
│ │ ├─ position: { x, y, width, height } ← Grid positioning │
│ │ ├─ enabled, componentType, permissions │
│ ├─ WidgetRegistryEntry (interface) │
│ │ └─ Widget metadata for catalog │
│ └─ AppConfigService │
│ ├─ dashboardConfig() signal │
│ └─ availableWidgets() signal │
│ │
│ @gograb-space/event-system │
│ └─ EventBusService │
│ └─ Widget-to-widget communication │
│ │
└──────────────────────────────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ @gograb-space/widget-system (NEW - UI Infrastructure) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ UI Components │
│ ├─ WidgetGridComponent │
│ │ ├─ Renders widgets in CSS Grid layout │
│ │ ├─ Uses ResolvedWidgetConfig.position │
│ │ └─ Responsive breakpoint handling │
│ ├─ WidgetContainerComponent │
│ │ ├─ Error boundary wrapper │
│ │ ├─ Loading state management │
│ │ └─ Dynamic component loading (NgComponentOutlet) │
│ ├─ WidgetPlaceholderComponent │
│ │ └─ Skeleton UI during loading │
│ └─ WidgetErrorComponent │
│ └─ Error display with retry │
│ │
│ Base Classes (Optional) │
│ └─ BaseWidgetComponent │
│ ├─ Event emission helpers │
│ ├─ Lifecycle hooks │
│ └─ Logger integration │
│ │
│ Services │
│ ├─ WidgetLayoutService │
│ │ ├─ Responsive breakpoint logic │
│ │ ├─ Grid positioning calculations │
│ │ └─ Layout validation │
│ └─ WidgetCommunicationService │
│ └─ Thin wrapper over EventBusService │
│ │
│ Models & Interfaces │
│ ├─ GridConfig │
│ ├─ GridBreakpointConfig │
│ └─ WidgetLifecycle (interface) │
│ │
└──────────────────────────────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ gograb-admin (Application Usage) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ dashboard.component.ts │
│ ├─ Consumes AppConfigService.dashboardConfig() │
│ ├─ Renders <lib-gs-widget-grid [widgets]="enabledWidgets()" /> │
│ └─ Minimal changes (< 20 LOC) │
│ │
└──────────────────────────────────────────────────────────────────┘
📦 Component Architecture
WidgetGridComponent
Responsibility: Grid layout rendering with responsive breakpoints
@Component({
selector: 'lib-gs-widget-grid',
standalone: true,
imports: [WidgetContainerComponent, CommonModule],
template: `
<div class="widget-grid"
[style.--grid-columns]="gridColumns()"
[style.--grid-gap]="gridConfig().gap + 'px'"
[style.--row-height]="rowHeight()">
@for (widget of enabledWidgets(); track widget.id) {
<lib-gs-widget-container
[widgetConfig]="widget"
[style.grid-column]="getGridColumn(widget.position)"
[style.grid-row]="getGridRow(widget.position)" />
}
</div>
`,
styles: [`
.widget-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
gap: var(--grid-gap);
grid-auto-rows: var(--row-height);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetGridComponent implements OnInit {
// === Inputs ===
widgets = input.required<ResolvedWidgetConfig[]>();
gridConfig = input<GridConfig>({ ...DEFAULT_GRID_CONFIG });
// === Services ===
private breakpointObserver = inject(BreakpointObserver);
private destroyRef = inject(DestroyRef);
// === State ===
private currentBreakpoint = signal<'mobile' | 'tablet' | 'desktop'>('desktop');
// === Computed ===
enabledWidgets = computed(() =>
this.widgets().filter(w => w.enabled)
);
gridColumns = computed(() => {
const bp = this.currentBreakpoint();
return this.gridConfig().breakpoints[bp].columns;
});
rowHeight = computed(() => {
const bp = this.currentBreakpoint();
const height = this.gridConfig().breakpoints[bp].rowHeight;
return typeof height === 'number' ? `${height}px` : 'auto';
});
ngOnInit(): void {
this.setupResponsiveBreakpoints();
}
private setupResponsiveBreakpoints(): void {
this.breakpointObserver
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
if (result.breakpoints[Breakpoints.XSmall] || result.breakpoints[Breakpoints.Small]) {
this.currentBreakpoint.set('mobile');
} else if (result.breakpoints[Breakpoints.Medium]) {
this.currentBreakpoint.set('tablet');
} else {
this.currentBreakpoint.set('desktop');
}
});
}
getGridColumn(position: ResolvedWidgetConfig['position']): string {
return `${position.x + 1} / span ${position.width}`;
}
getGridRow(position: ResolvedWidgetConfig['position']): string {
return `${position.y + 1} / span ${position.height}`;
}
}
Key Decisions:
- ✅ Uses
ResolvedWidgetConfigdirectly (no new types) - ✅ CSS Grid for layout (browser-native, performant)
- ✅ CSS custom properties for dynamic values
- ✅ BreakpointObserver from Angular CDK
- ✅ OnPush change detection
WidgetContainerComponent
Responsibility: Error boundary, loading states, dynamic component rendering
@Component({
selector: 'lib-gs-widget-container',
standalone: true,
imports: [NgComponentOutlet, CommonModule, WidgetPlaceholderComponent, WidgetErrorComponent],
template: `
<div class="widget-container"
[class.widget-loading]="state().loading"
[class.widget-error]="state().error"
[attr.aria-busy]="state().loading"
[attr.aria-label]="widgetConfig().displayName">
@if (state().loading) {
<gs-widget-placeholder />
} @else if (state().error) {
<gs-widget-error
[error]="state().error!"
[widgetName]="widgetConfig().displayName"
(retry)="handleRetry()" />
} @else {
<ng-container
[ngComponentOutlet]="widgetConfig().componentType"
[ngComponentOutletInputs]="componentInputs()" />
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetContainerComponent {
// === Inputs ===
widgetConfig = input.required<ResolvedWidgetConfig>();
// === Services ===
private logger = inject(LoggerService).child({ source: 'WidgetContainer' });
// === State ===
state = signal<WidgetState>({
widgetId: '',
loading: false,
ready: false,
error: null
});
// === Computed ===
componentInputs = computed(() => {
const config = this.widgetConfig();
return {
widgetId: config.id,
widgetConfig: config,
// Additional inputs can be passed here from config
};
});
constructor() {
// Error boundary setup
effect(() => {
const config = this.widgetConfig();
this.state.update(s => ({
...s,
widgetId: config.id
}));
});
}
handleRetry(): void {
this.state.update(s => ({ ...s, error: null, loading: true }));
// Retry logic
}
// API for child widgets to report status
onWidgetReady(): void {
this.state.update(s => ({ ...s, ready: true, loading: false }));
}
onWidgetError(error: Error): void {
this.logger.error(`Widget ${this.widgetConfig().id} error`, error);
this.state.update(s => ({ ...s, error, loading: false }));
}
}
Key Decisions:
- ✅ Error boundary pattern (isolates widget errors)
- ✅ NgComponentOutlet for dynamic loading
- ✅ Accessibility attributes (aria-busy, aria-label)
- ✅ Loading/error states managed locally
- ✅ Retry capability for resilience
BaseWidgetComponent (Optional)
Responsibility: Optional base class with helper methods
@Component({
standalone: true,
template: ''
})
export abstract class BaseWidgetComponent implements WidgetLifecycle, OnInit, OnDestroy {
// === Inputs (common to all widgets) ===
widgetId = input.required<string>();
widgetConfig = input<ResolvedWidgetConfig>();
// === Outputs (common events) ===
widgetError = output<Error>();
widgetReady = output<void>();
// === Services (injected) ===
protected eventBus = inject(EventBusService);
protected logger = inject(LoggerService);
protected destroyRef = inject(DestroyRef);
// === State (common signals) ===
protected loading = signal(false);
protected error = signal<Error | null>(null);
// === Lifecycle ===
ngOnInit(): void {
this.logger.debug(`Widget ${this.widgetId()} initializing`);
this.onWidgetInit();
}
ngOnDestroy(): void {
this.logger.debug(`Widget ${this.widgetId()} destroying`);
this.onWidgetDestroy();
}
// === Abstract Methods (widgets implement) ===
abstract onWidgetInit(): void;
abstract onWidgetDestroy(): void;
// === Helper Methods ===
protected emitWidgetEvent<T = unknown>(eventType: string, data?: T): void {
this.eventBus.emit({
type: `widget.${eventType}`,
payload: data,
metadata: {
widgetId: this.widgetId(),
timestamp: Date.now()
}
});
}
protected subscribeToWidgetEvent<T = unknown>(
eventType: string,
handler: (data: T) => void
): void {
this.eventBus.on(`widget.${eventType}`)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(event => handler(event.payload as T));
}
protected reportError(error: Error): void {
this.error.set(error);
this.widgetError.emit(error);
this.logger.error(`Widget ${this.widgetId()} error`, error);
}
protected markReady(): void {
this.widgetReady.emit();
this.logger.debug(`Widget ${this.widgetId()} ready`);
}
}
Key Decisions:
- ✅ OPTIONAL - existing widgets don't need to change
- ✅ Provides helpers for common patterns
- ✅ Event communication integration
- ✅ Standardized lifecycle hooks
- ✅ Error handling helpers
🔌 Service Architecture
WidgetLayoutService
Responsibility: Grid layout calculations and validation
@Injectable({ providedIn: 'root' })
export class WidgetLayoutService {
private logger = inject(LoggerService).child({ source: 'WidgetLayoutService' });
/**
* Validates widget placement against grid configuration
*/
validatePlacement(
placement: ResolvedWidgetConfig['position'],
gridConfig: GridConfig
): boolean {
const { x, y, width, height } = placement;
// Basic validation
if (x < 0 || y < 0 || width <= 0 || height <= 0) {
return false;
}
// Validate doesn't exceed grid columns
if (x + width > gridConfig.columns) {
this.logger.warn('Widget placement exceeds grid columns', {
placement,
gridColumns: gridConfig.columns
});
return false;
}
return true;
}
/**
* Calculates grid positioning styles
*/
calculateGridStyles(position: ResolvedWidgetConfig['position']): {
gridColumn: string;
gridRow: string;
} {
return {
gridColumn: `${position.x + 1} / span ${position.width}`,
gridRow: `${position.y + 1} / span ${position.height}`
};
}
/**
* Detects overlapping widgets
*/
detectOverlaps(widgets: ResolvedWidgetConfig[]): boolean {
// Implementation for overlap detection
// Useful for drag-and-drop validation (post-MVP)
return false;
}
}
WidgetCommunicationService
Responsibility: Thin wrapper over EventBusService for widget events
@Injectable({ providedIn: 'root' })
export class WidgetCommunicationService {
private eventBus = inject(EventBusService);
private logger = inject(LoggerService).child({ source: 'WidgetCommunication' });
/**
* Emit event from a widget
*/
emitFromWidget<T = unknown>(
widgetId: string,
eventType: string,
data?: T
): void {
this.eventBus.emit({
type: `widget.${eventType}`,
payload: data,
metadata: {
source: 'widget',
widgetId,
timestamp: Date.now()
}
});
this.logger.debug('Widget event emitted', {
widgetId,
eventType,
hasData: !!data
});
}
/**
* Subscribe to widget events
*/
onWidgetEvent<T = unknown>(
eventType: string,
handler: (data: T, widgetId: string) => void
): Observable<void> {
return this.eventBus.on(`widget.${eventType}`).pipe(
map(event => {
handler(
event.payload as T,
event.metadata?.widgetId as string
);
})
);
}
/**
* Subscribe to events from specific widget
*/
onWidgetEventFrom<T = unknown>(
widgetId: string,
eventType: string,
handler: (data: T) => void
): Observable<void> {
return this.eventBus.on(`widget.${eventType}`).pipe(
filter(event => event.metadata?.widgetId === widgetId),
map(event => handler(event.payload as T))
);
}
}
Key Decisions:
- ✅ Wrapper pattern (no replacement of EventBusService)
- ✅ Namespace convention:
widget.* - ✅ Metadata standardization
- ✅ Logging integration
📐 Data Models
GridConfig
/**
* Grid layout configuration
*/
export interface GridConfig {
/** Number of columns in the grid */
columns: number;
/** Gap between widgets (px) */
gap: number;
/** Row height (px or 'auto') */
rowHeight: number | 'auto';
/** Responsive breakpoint configurations */
breakpoints: {
mobile: GridBreakpointConfig;
tablet: GridBreakpointConfig;
desktop: GridBreakpointConfig;
};
}
export interface GridBreakpointConfig {
/** Number of columns for this breakpoint */
columns: number;
/** Gap for this breakpoint (px) */
gap: number;
/** Row height for this breakpoint */
rowHeight: number | 'auto';
}
/** Default grid configuration */
export const DEFAULT_GRID_CONFIG: GridConfig = {
columns: 12,
gap: 16,
rowHeight: 100,
breakpoints: {
mobile: { columns: 1, gap: 8, rowHeight: 'auto' },
tablet: { columns: 6, gap: 12, rowHeight: 100 },
desktop: { columns: 12, gap: 16, rowHeight: 100 }
}
};
WidgetState
/**
* Runtime state of a widget instance
*/
export interface WidgetState {
/** Widget ID */
widgetId: string;
/** Loading state */
loading: boolean;
/** Error if exists */
error: Error | null;
/** Widget ready state */
ready: boolean;
/** Optional widget-specific data */
data?: unknown;
}
WidgetLifecycle
/**
* Widget lifecycle hooks interface
*/
export interface WidgetLifecycle {
/**
* Called when widget initializes
*/
onWidgetInit(): void;
/**
* Called when widget destroys
*/
onWidgetDestroy(): void;
/**
* Called when widget config changes (optional)
*/
onWidgetConfigChange?(config: ResolvedWidgetConfig): void;
/**
* Called when widget should refresh data (optional)
*/
onWidgetRefresh?(): void;
}
🔄 Integration Patterns
Pattern 1: Dashboard Integration
// dashboard.component.ts
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [WidgetGridComponent],
template: `
<lib-gs-widget-grid
[widgets]="enabledWidgets()"
[gridConfig]="gridConfig" />
`
})
export class DashboardComponent {
private appConfig = inject(AppConfigService);
gridConfig: GridConfig = DEFAULT_GRID_CONFIG;
// Consumes existing AppConfigService
enabledWidgets = computed(() =>
this.appConfig.dashboardConfig()?.widgets.filter(w => w.enabled) ?? []
);
}
Integration Points:
- ✅ Uses
AppConfigService.dashboardConfig()signal - ✅ Filters enabled widgets
- ✅ Passes
ResolvedWidgetConfig[]directly to grid - ✅ Minimal changes to existing code
Pattern 2: Widget Creation (Extending BaseWidgetComponent)
// my-widget.component.ts
@Component({
selector: 'my-widget',
standalone: true,
template: `<div>My Widget Content</div>`
})
export class MyWidgetComponent extends BaseWidgetComponent {
// Widget-specific inputs
customInput = input<string>();
// Widget-specific state
data = signal<MyData[]>([]);
onWidgetInit(): void {
// Subscribe to events
this.subscribeToWidgetEvent('data.updated', (newData) => {
this.data.set(newData);
});
// Load initial data
this.loadData();
}
onWidgetDestroy(): void {
// Cleanup handled by DestroyRef
}
private loadData(): void {
// Data loading logic
}
}
Integration Points:
- ✅ Extends BaseWidgetComponent (optional)
- ✅ Uses event helpers
- ✅ Lifecycle hooks
- ✅ Error reporting helpers
Pattern 3: Widget Registration (No Changes)
// module manifest (UNCHANGED)
@ModuleDecorator({
id: 'my-module',
// ...
})
export class MyModule implements BrowserModuleContract {
readonly manifest: ModuleManifest = {
// ...
embeddableComponents: [
{
id: 'my-widget',
componentType: MyWidgetComponent,
displayName: 'My Widget',
category: 'widget',
permissions: ['my-module:read']
}
]
};
}
Integration Points:
- ✅ NO changes to module manifest definition
- ✅ ComponentRegistryService automatically registers
- ✅ AppConfigService merges into dashboard config
🎨 Styling Strategy
Design Tokens Integration
// widget-grid.component.scss
.widget-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
gap: var(--grid-gap);
grid-auto-rows: var(--row-height);
padding: var(--spacing-md); // from ui-framework
}
// widget-container.component.scss
.widget-container {
background: var(--color-surface); // from ui-framework
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
&.widget-loading {
opacity: 0.7;
}
&.widget-error {
border-color: var(--color-error);
}
}
Key Decisions:
- ✅ Uses ui-framework design tokens
- ✅ No custom token definitions
- ✅ Consistent with system design
- ✅ CSS custom properties for dynamic values
🧪 Testing Strategy
Unit Testing
// widget-grid.component.spec.ts
describe('WidgetGridComponent', () => {
it('should render enabled widgets only', () => {
const mockWidgets: ResolvedWidgetConfig[] = [
{ id: 'w1', enabled: true, /* ... */ },
{ id: 'w2', enabled: false, /* ... */ }
];
component.widgets = signal(mockWidgets);
fixture.detectChanges();
expect(component.enabledWidgets().length).toBe(1);
});
it('should calculate grid styles correctly', () => {
const position = { x: 0, y: 0, width: 4, height: 2 };
const column = component.getGridColumn(position);
const row = component.getGridRow(position);
expect(column).toBe('1 / span 4');
expect(row).toBe('1 / span 2');
});
});
Integration Testing
// widget-grid.integration.spec.ts
describe('WidgetGrid Integration', () => {
it('should integrate with AppConfigService', () => {
const mockConfig: ResolvedDashboardConfig = {
columns: 12,
widgets: [/* mock widgets */],
availableWidgets: []
};
appConfigService.dashboardConfig.set(mockConfig);
const widgets = component.enabledWidgets();
expect(widgets).toBeDefined();
});
});
🚀 Performance Considerations
OnPush Change Detection
All components use ChangeDetectionStrategy.OnPush:
- Reduces change detection cycles
- Better performance with many widgets
- Works well with signals
Lazy Loading (Post-MVP)
// Future implementation
async loadWidgetComponent(widgetTypeId: string): Promise<Type<unknown>> {
const module = await import(`./widgets/${widgetTypeId}.component`);
return module.WidgetComponent;
}
Memory Management
- ✅ DestroyRef for automatic subscription cleanup
- ✅ takeUntilDestroyed for RxJS observables
- ✅ No manual unsubscribe needed
- ✅ Widget state cleared on destroy
📊 Deployment Considerations
Library Build
// project.json
{
"name": "widget-system",
"targets": {
"build": {
"executor": "@nx/angular:ng-packagr-lite",
"options": {
"project": "libs/core/widget-system/ng-package.json"
}
}
}
}
Barrel Exports
// src/index.ts
export * from './lib/components/widget-grid.component';
export * from './lib/components/widget-container.component';
export * from './lib/base/base-widget.component';
export * from './lib/services/widget-layout.service';
export * from './lib/models/grid-config.interface';
📱 Responsive Positioning Strategy
Problem Statement
The current ResolvedWidgetConfig.position system uses fixed grid coordinates:
interface ResolvedWidgetConfig {
position: {
x: number; // Grid column start (0-indexed)
y: number; // Grid row start (0-indexed)
width: number; // Column span
height: number; // Row span
};
}
Challenge: A widget configured as { x: 0, y: 0, width: 4, height: 2 } works perfectly on desktop (12 columns), but on mobile (1 column) it would overflow horizontally since width: 4 exceeds available columns.
Two-Phase Solution
Phase 1: MVP - Auto-Shrink Logic (No Config Changes)
Goal: Make existing widget configurations responsive without requiring any changes to ResolvedWidgetConfig.
Implementation: Add intelligent positioning logic in WidgetGridComponent:
// widget-grid.component.ts
export class WidgetGridComponent {
// ... existing code ...
/**
* Applies responsive positioning based on current breakpoint
* WITHOUT requiring changes to ResolvedWidgetConfig
*/
private applyResponsivePositioning(
widget: ResolvedWidgetConfig
): WidgetPosition {
const bp = this.currentBreakpoint();
const gridCols = this.gridColumns();
const basePos = widget.position;
switch (bp) {
case 'mobile':
// Mobile: Stack widgets vertically (1 column)
return {
x: 0,
y: basePos.y,
width: 1,
height: basePos.height
};
case 'tablet':
// Tablet: Shrink to fit if needed
return {
...basePos,
width: Math.min(basePos.width, gridCols),
x: Math.min(basePos.x, gridCols - 1)
};
default: // desktop
// Desktop: Use original position
return basePos;
}
}
getGridColumn(position: ResolvedWidgetConfig['position']): string {
const responsive = this.applyResponsivePositioning({ position } as ResolvedWidgetConfig);
return `${responsive.x + 1} / span ${responsive.width}`;
}
getGridRow(position: ResolvedWidgetConfig['position']): string {
const responsive = this.applyResponsivePositioning({ position } as ResolvedWidgetConfig);
return `${responsive.y + 1} / span ${responsive.height}`;
}
}
Auto-Shrink Rules:
| Breakpoint | Columns | Behavior |
|---|---|---|
| Mobile | 1 | Force all widgets to width: 1, stack vertically, preserve height |
| Tablet | 6 | Shrink width to fit if exceeds 6, adjust x if out of bounds |
| Desktop | 12 | Use original position as-is |
Benefits:
- ✅ Zero changes to
ResolvedWidgetConfiginterface - ✅ No database/config migrations needed
- ✅ Backwards compatible with ALL existing configurations
- ✅ Works immediately for all widgets
- ✅ Simple, predictable behavior
Example:
// Widget configured in DB/config:
const widget: ResolvedWidgetConfig = {
id: 'stats-widget',
position: { x: 0, y: 0, width: 4, height: 2 },
// ... other fields
};
// Rendered positions by breakpoint:
// Desktop (12 cols): x=0, y=0, width=4, height=2 ← Original
// Tablet (6 cols): x=0, y=0, width=4, height=2 ← Fits, no change
// Mobile (1 col): x=0, y=0, width=1, height=2 ← Auto-shrunk
Phase 2: Post-MVP - Optional Responsive Overrides
Goal: Allow fine-grained control for specific widgets that need custom responsive behavior.
Design: Extend ResolvedWidgetConfig.position with optional responsive overrides:
// NEW: Enhanced position interface (Post-MVP)
interface WidgetPosition {
/** Base position (desktop default) */
x: number;
y: number;
width: number;
height: number;
/** Optional responsive overrides */
responsive?: {
mobile?: Partial<Omit<WidgetPosition, 'responsive'>>;
tablet?: Partial<Omit<WidgetPosition, 'responsive'>>;
};
}
// Updated ResolvedWidgetConfig
interface ResolvedWidgetConfig {
// ... existing fields ...
position: WidgetPosition; // Now supports responsive overrides
}
Implementation:
// widget-grid.component.ts (Post-MVP)
private applyResponsivePositioning(
widget: ResolvedWidgetConfig
): WidgetPosition {
const bp = this.currentBreakpoint();
const gridCols = this.gridColumns();
const basePos = widget.position;
// 1. Check for explicit responsive overrides
if (basePos.responsive?.[bp]) {
return {
...basePos,
...basePos.responsive[bp]
};
}
// 2. Fallback to auto-shrink logic (Phase 1)
switch (bp) {
case 'mobile':
return { x: 0, y: basePos.y, width: 1, height: basePos.height };
case 'tablet':
return {
...basePos,
width: Math.min(basePos.width, gridCols),
x: Math.min(basePos.x, gridCols - 1)
};
default:
return basePos;
}
}
Usage Examples:
// Example 1: Widget that needs custom mobile layout
const customWidget: ResolvedWidgetConfig = {
id: 'chart-widget',
position: {
x: 0,
y: 0,
width: 6,
height: 3,
responsive: {
mobile: {
width: 1,
height: 4 // Taller on mobile for better chart visibility
}
}
}
};
// Example 2: Widget that needs different tablet positioning
const sidebarWidget: ResolvedWidgetConfig = {
id: 'sidebar-widget',
position: {
x: 8,
y: 0,
width: 4,
height: 2,
responsive: {
tablet: {
x: 0, // Move to left on tablet
width: 3
},
mobile: {
width: 1,
height: 3
}
}
}
};
// Example 3: Widget using auto-shrink (no overrides needed)
const simpleWidget: ResolvedWidgetConfig = {
id: 'simple-widget',
position: { x: 0, y: 0, width: 4, height: 2 }
// No responsive overrides → uses Phase 1 auto-shrink
};
Benefits:
- ✅ Opt-in complexity: Only use when needed
- ✅ Backwards compatible: Widgets without
responsiveuse auto-shrink - ✅ Flexible: Per-breakpoint, per-widget control
- ✅ Gradual migration: Can add overrides incrementally
Migration Path:
- MVP Launch: All widgets use auto-shrink (Phase 1)
- Identify Issues: Monitor which widgets need custom responsive behavior
- Incremental Enhancement: Add
responsiveoverrides only where needed - Bulk Optimization: Eventually optimize all critical widgets
Implementation in WidgetGridComponent
Complete responsive implementation combining both phases:
@Component({
selector: 'lib-gs-widget-grid',
standalone: true,
template: `
<div class="widget-grid"
[style.--grid-columns]="gridColumns()"
[style.--grid-gap]="gridGap()"
[style.--row-height]="rowHeight()">
@for (widget of enabledWidgets(); track widget.id) {
<lib-gs-widget-container
[widgetConfig]="widget"
[style.grid-column]="getResponsiveGridColumn(widget)"
[style.grid-row]="getResponsiveGridRow(widget)" />
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetGridComponent {
// ... existing fields ...
/**
* Get responsive grid column based on current breakpoint
*/
getResponsiveGridColumn(widget: ResolvedWidgetConfig): string {
const pos = this.applyResponsivePositioning(widget);
return `${pos.x + 1} / span ${pos.width}`;
}
/**
* Get responsive grid row based on current breakpoint
*/
getResponsiveGridRow(widget: ResolvedWidgetConfig): string {
const pos = this.applyResponsivePositioning(widget);
return `${pos.y + 1} / span ${pos.height}`;
}
/**
* Apply responsive positioning with optional overrides
* Supports both Phase 1 (auto-shrink) and Phase 2 (explicit overrides)
*/
private applyResponsivePositioning(
widget: ResolvedWidgetConfig
): Required<Omit<WidgetPosition, 'responsive'>> {
const bp = this.currentBreakpoint();
const gridCols = this.gridColumns();
const basePos = widget.position;
// Phase 2: Check for explicit responsive overrides (if implemented)
if ('responsive' in basePos && basePos.responsive?.[bp]) {
return {
x: basePos.x,
y: basePos.y,
width: basePos.width,
height: basePos.height,
...basePos.responsive[bp]
};
}
// Phase 1: Auto-shrink fallback
switch (bp) {
case 'mobile':
return {
x: 0,
y: basePos.y,
width: 1,
height: basePos.height
};
case 'tablet':
return {
x: Math.min(basePos.x, gridCols - 1),
y: basePos.y,
width: Math.min(basePos.width, gridCols),
height: basePos.height
};
default: // desktop
return {
x: basePos.x,
y: basePos.y,
width: basePos.width,
height: basePos.height
};
}
}
}
Validation & Testing
Unit Tests for Responsive Positioning
// widget-grid.component.spec.ts
describe('WidgetGridComponent - Responsive Positioning', () => {
describe('Phase 1: Auto-Shrink', () => {
it('should stack widgets vertically on mobile', () => {
component.currentBreakpoint.set('mobile');
component.gridConfig.set({
...DEFAULT_GRID_CONFIG,
breakpoints: {
mobile: { columns: 1, gap: 8, rowHeight: 'auto' }
}
});
const widget: ResolvedWidgetConfig = {
id: 'w1',
position: { x: 4, y: 0, width: 4, height: 2 },
// ... other fields
};
const responsive = component['applyResponsivePositioning'](widget);
expect(responsive.x).toBe(0);
expect(responsive.width).toBe(1);
expect(responsive.height).toBe(2); // Preserved
});
it('should shrink to fit on tablet if exceeds columns', () => {
component.currentBreakpoint.set('tablet');
component.gridColumns.set(6);
const widget: ResolvedWidgetConfig = {
id: 'w1',
position: { x: 0, y: 0, width: 8, height: 2 }, // width > 6
// ... other fields
};
const responsive = component['applyResponsivePositioning'](widget);
expect(responsive.width).toBe(6); // Shrunk to fit
});
it('should use original position on desktop', () => {
component.currentBreakpoint.set('desktop');
component.gridColumns.set(12);
const widget: ResolvedWidgetConfig = {
id: 'w1',
position: { x: 4, y: 2, width: 4, height: 2 },
// ... other fields
};
const responsive = component['applyResponsivePositioning'](widget);
expect(responsive).toEqual({
x: 4,
y: 2,
width: 4,
height: 2
});
});
});
describe('Phase 2: Responsive Overrides (Post-MVP)', () => {
it('should apply mobile override when specified', () => {
component.currentBreakpoint.set('mobile');
const widget: ResolvedWidgetConfig = {
id: 'w1',
position: {
x: 0,
y: 0,
width: 4,
height: 2,
responsive: {
mobile: {
width: 1,
height: 4 // Custom height for mobile
}
}
}
};
const responsive = component['applyResponsivePositioning'](widget);
expect(responsive.width).toBe(1);
expect(responsive.height).toBe(4); // Override applied
});
it('should fallback to auto-shrink if no override for breakpoint', () => {
component.currentBreakpoint.set('tablet');
const widget: ResolvedWidgetConfig = {
id: 'w1',
position: {
x: 0,
y: 0,
width: 8,
height: 2,
responsive: {
mobile: { width: 1 } // No tablet override
}
}
};
const responsive = component['applyResponsivePositioning'](widget);
expect(responsive.width).toBe(6); // Auto-shrink applied
});
});
});
Documentation Updates Required
When implementing Phase 2, update:
module-configTypes:// libs/core/module-config/src/lib/models/resolved-dashboard-config.ts export interface WidgetPosition { x: number; y: number; width: number; height: number; responsive?: { mobile?: Partial<Omit<WidgetPosition, 'responsive'>>; tablet?: Partial<Omit<WidgetPosition, 'responsive'>>; }; }Database Schema (if persisted):
- Add
responsivefield to widget configuration storage - Make it optional/nullable for backwards compatibility
- Add
UI Customization Interface:
- Add responsive position controls to tenant customization UI
- Allow admins to preview responsive behavior
Rollout Strategy
MVP (Phase 1)
✅ Timeline: Initial widget-system release
- Implement auto-shrink logic in WidgetGridComponent
- No interface changes needed
- Works with all existing widget configs
- Test on mobile/tablet/desktop
Post-MVP (Phase 2)
⏳ Timeline: After MVP validation, based on feedback
- Extend WidgetPosition interface with
responsivefield - Update WidgetGridComponent to check for overrides first
- Create UI tools for configuring responsive overrides
- Gradually migrate critical widgets to use overrides
Performance Considerations
Phase 1 (Auto-Shrink):
- ✅ Zero overhead: Simple switch statement
- ✅ Computed during render only
- ✅ No extra network requests
- ✅ No config parsing overhead
Phase 2 (Responsive Overrides):
- ✅ Minimal overhead: Optional property check
- ✅ Only parsed if
responsivefield exists - ✅ Falls back to Phase 1 if not specified
- ✅ No performance degradation for simple widgets
🔮 Future Enhancements (Post-MVP)
Drag-and-Drop
// Future: WidgetGridComponent with drag-and-drop
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
handleWidgetDrop(event: CdkDragDrop<ResolvedWidgetConfig[]>): void {
const widgets = [...this.widgets()];
moveItemInArray(widgets, event.previousIndex, event.currentIndex);
// Update positions
this.updateWidgetPositions(widgets);
// Emit changes to AppConfigService
this.saveWidgetLayout(widgets);
}
Widget Resize
// Future: Resize handles
import { CdkDrag } from '@angular/cdk/drag-drop';
onWidgetResize(widgetId: string, newSize: { width: number; height: number }): void {
// Update widget size
// Validate against grid
// Save to config
}
Widget Maximize
// Future: Fullscreen mode
maximizeWidget(widgetId: string): void {
this.maximizedWidget.set(widgetId);
// Apply fullscreen styles
// Escape key to restore
}
✅ Design Validation Checklist
- ✅ Zero duplication of existing types (
ResolvedWidgetConfig,WidgetRegistryEntry) - ✅ Integrates with existing services (
ComponentRegistryService,AppConfigService,EventBusService) - ✅ Minimal changes to existing code (dashboard only)
- ✅ OnPush change detection everywhere
- ✅ Signals for reactive state
- ✅ Accessibility (ARIA labels, keyboard support)
- ✅ Error isolation (error boundaries)
- ✅ Responsive design (breakpoints)
- ✅ Performance optimized (lazy loading ready)
- ✅ Extensible architecture (drag-and-drop, resize)
- ✅ Comprehensive testing strategy
- ✅ Clear integration patterns
- ✅ Design tokens integration
📝 Architecture Decision Records
ADR-001: Use ResolvedWidgetConfig Instead of New Type
Decision: Use existing ResolvedWidgetConfig from @gograb-space/module-config
Rationale:
- Already has position: { x, y, width, height }
- Avoid duplication
- Consistent with existing architecture
Alternatives Considered:
- Create new WidgetConfig type (rejected - duplication)
ADR-002: CSS Grid Over Alternative Layouts
Decision: Use CSS Grid for widget layout
Rationale:
- Browser-native, performant
- Responsive-friendly
- Simple positioning logic
- Well-supported
Alternatives Considered:
- Flexbox (rejected - harder positioning)
- Absolute positioning (rejected - responsive issues)
- Grid libraries (rejected - unnecessary dependency)
ADR-003: Optional BaseWidgetComponent
Decision: BaseWidgetComponent is optional, not required
Rationale:
- Existing widgets don't need changes
- New widgets can opt-in for helpers
- No breaking changes
Alternatives Considered:
- Required base class (rejected - breaking change)
- No base class (rejected - lose helper benefits)
ADR-004: Error Boundary Pattern
Decision: WidgetContainerComponent provides error boundaries
Rationale:
- Isolate widget errors
- Dashboard remains functional
- Better UX
Alternatives Considered:
- Global error handler (rejected - doesn't isolate)
- No error handling (rejected - poor UX)
ADR-005: Two-Phase Responsive Positioning
Decision: Implement responsive positioning in two phases: MVP with auto-shrink logic, Post-MVP with optional responsive overrides
Rationale:
Phase 1 (MVP) - Auto-Shrink Logic:
- Zero changes to
ResolvedWidgetConfiginterface - No database migrations required
- Backwards compatible with all existing configurations
- Simple, predictable behavior (mobile=1 col, tablet=shrink to fit, desktop=original)
- Allows immediate MVP launch without config changes
Phase 2 (Post-MVP) - Responsive Overrides:
- Opt-in complexity for widgets needing custom responsive behavior
- Backwards compatible: widgets without
responsivefield use auto-shrink - Gradual migration path: add overrides only where needed
- Fine-grained control per breakpoint, per widget
Benefits:
- Progressive enhancement approach
- No breaking changes at any stage
- Validates auto-shrink approach before adding complexity
- Flexibility to optimize critical widgets later
Alternatives Considered:
Immediate responsive overrides in MVP (Rejected)
- Would require interface changes upfront
- More complex MVP implementation
- Unknown if override complexity is needed
- Database migrations before validation
Separate position configs per breakpoint (Rejected)
- Too complex for config management
- Massive duplication in configuration
- Hard to maintain consistency
- Poor UX in admin interface
Auto-shrink only (no Phase 2) (Rejected)
- Some widgets may need custom responsive behavior
- Charts, complex layouts may not work well with simple shrink
- Limits flexibility for future optimization
Percentage-based sizing (Rejected)
- Doesn't match existing grid column system
- Complex mental model for admins
- Breaking change to ResolvedWidgetConfig
- Harder to reason about positioning
Migration Path:
- MVP: Launch with auto-shrink, validate with real usage
- Monitoring: Identify widgets that need custom behavior
- Post-MVP: Add
responsiveoverrides to problematic widgets - Long-term: Optimize all critical widgets incrementally
Performance Impact:
- Phase 1: Zero overhead (simple switch statement)
- Phase 2: Minimal overhead (optional property check, falls back to Phase 1)
Example:
// MVP: All widgets use auto-shrink
const widget: ResolvedWidgetConfig = {
position: { x: 0, y: 0, width: 4, height: 2 }
// Mobile auto-shrinks to width: 1
};
// Post-MVP: Widgets can override when needed
const customWidget: ResolvedWidgetConfig = {
position: {
x: 0, y: 0, width: 6, height: 3,
responsive: {
mobile: { width: 1, height: 4 } // Custom height for better mobile UX
}
}
};