Extending Ux Widgets
Ecosistema completo para organización y gestión de contenido con interfaces espaciales
Extending UX Widgets System
This guide provides instructions and best practices for extending the UX Widgets system, creating new widgets, and integrating with existing components.
Table of Contents
- Overview
- Creating New Widgets
- Widget Communication
- Storage Integration
- Theme Support
- Testing Widgets
- Best Practices
Overview
The UX Widgets system follows a modular architecture that allows for easy extension. Each widget is a self-contained Angular component with its own configuration, storage, and state management. Widgets can communicate with each other through a central event bus or directly through services.
Creating New Widgets
Widget Template
To create a new widget, start with this template:
// my-new-widget.component.ts
import { Component, Input, Output, EventEmitter, HostBinding, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
// Define the widget configuration interface
export interface MyNewWidgetConfig {
id: string;
title: string;
theme: string;
// Add widget-specific configuration properties
}
@Component({
selector: 'app-my-new-widget',
standalone: true,
imports: [CommonModule], // Add other required imports
templateUrl: './my-new-widget.component.html',
styleUrls: ['./my-new-widget.component.scss']
})
export class MyNewWidgetComponent implements OnInit, OnDestroy {
// Required inputs
@Input() id: string = '';
// Optional configuration with defaults
private _config: MyNewWidgetConfig = {
id: '',
title: 'New Widget',
theme: 'light'
};
@Input()
set config(value: Partial<MyNewWidgetConfig>) {
this._config = { ...this._config, ...value, id: this.id || value.id || this._config.id };
this.applyTheme(this._config.theme);
}
get config(): MyNewWidgetConfig {
return this._config;
}
// Events
@Output() configChanged = new EventEmitter<MyNewWidgetConfig>();
// Theme handling with HostBinding
@HostBinding('class')
get hostClasses(): string {
return `widget theme-${this._config.theme}`;
}
// Lifecycle management
private destroy$ = new Subject<void>();
constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) {}
ngOnInit(): void {
// Initialize widget
if (!this.id) {
this.id = `widget-${Date.now()}`;
this._config.id = this.id;
}
this.initializeWidget();
}
ngOnDestroy(): void {
// Clean up subscriptions
this.destroy$.next();
this.destroy$.complete();
}
private initializeWidget(): void {
// Widget initialization logic
}
private applyTheme(theme: string): void {
// Theme-related logic
}
// Public methods for widget functionality
public updateConfig(changes: Partial<MyNewWidgetConfig>): void {
this.config = { ...this._config, ...changes };
this.configChanged.emit(this._config);
}
}
HTML Template
<!-- my-new-widget.component.html -->
<div class="widget-container">
<div class="widget-header">
<h2>{{ config.title }}</h2>
<div class="widget-controls">
<!-- Widget controls (settings, minimize, close, etc.) -->
</div>
</div>
<div class="widget-content">
<!-- Widget content -->
</div>
<div class="widget-footer">
<!-- Widget footer -->
</div>
</div>
SCSS Styling
/* my-new-widget.component.scss */
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
&.theme-light {
--widget-bg: #f8f9fa;
--widget-text: #343a40;
--widget-border: #e9ecef;
--widget-header: #e2e6ea;
}
&.theme-dark {
--widget-bg: #343a40;
--widget-text: #f8f9fa;
--widget-border: #495057;
--widget-header: #212529;
}
}
.widget-container {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--widget-bg);
color: var(--widget-text);
border: 1px solid var(--widget-border);
border-radius: 4px;
overflow: hidden;
}
.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--widget-header);
border-bottom: 1px solid var(--widget-border);
h2 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.widget-controls {
display: flex;
gap: 4px;
}
}
.widget-content {
flex: 1;
padding: 12px;
overflow: auto;
}
.widget-footer {
padding: 8px 12px;
border-top: 1px solid var(--widget-border);
}
Storage Service
Create a storage service for your widget:
// my-new-widget-storage.service.ts
import { Injectable } from '@angular/core';
import { Observable, Subject, from, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { MyNewWidgetConfig } from './my-new-widget.component';
export interface MyNewWidgetStorageItem {
id: string;
config: MyNewWidgetConfig;
items: any[]; // Replace with your widget's specific item type
lastSaved: string;
}
@Injectable({
providedIn: 'root'
})
export class MyNewWidgetStorageService {
private readonly DATABASE_NAME = 'UxWidgetsDB';
private readonly DATABASE_VERSION = 1;
private readonly STORE_NAME = 'MyNewWidgetStore';
private readonly STORAGE_PREFIX = 'my-new-widget';
private db: IDBDatabase | null = null;
private configUpdatedSubject = new Subject<{item: MyNewWidgetStorageItem, source: string}>();
public configUpdated$ = this.configUpdatedSubject.asObservable();
constructor(private ngZone: NgZone) {
this.initializeDatabase().catch(error => {
console.error('Error initializing database', error);
});
}
// Database initialization
private initializeDatabase(): Promise<boolean> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DATABASE_NAME, this.DATABASE_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
}
};
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve(true);
};
request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
// Save widget configuration
saveWidgetConfig(
widgetId: string,
config: MyNewWidgetConfig,
items: any[],
source: string = 'unknown'
): Observable<boolean> {
return from(this.withDB(db => {
return new Promise<boolean>((resolve, reject) => {
try {
const storageItem: MyNewWidgetStorageItem = {
id: widgetId,
config: {
...config,
id: widgetId
},
items: items || [],
lastSaved: new Date().toISOString()
};
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.put(storageItem);
request.onsuccess = () => {
// Notify subscribers of the update
this.ngZone.run(() => {
this.configUpdatedSubject.next({
item: storageItem,
source
});
});
resolve(true);
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
} catch (error) {
reject(error);
}
});
})).pipe(
catchError(error => {
console.error('Error saving widget config', error);
this.saveToLocalStorage(widgetId, config, items);
return of(false);
})
);
}
// Load widget configuration
loadWidgetConfig(widgetId: string): Observable<MyNewWidgetStorageItem | null> {
return from(this.withDB(db => {
return new Promise<MyNewWidgetStorageItem | null>((resolve, reject) => {
try {
const transaction = db.transaction([this.STORE_NAME], 'readonly');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.get(widgetId);
request.onsuccess = () => {
const result = request.result as MyNewWidgetStorageItem;
resolve(result || null);
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
} catch (error) {
reject(error);
}
});
})).pipe(
catchError(error => {
console.error('Error loading widget config', error);
const fallback = this.loadFromLocalStorage(widgetId);
return of(fallback);
})
);
}
// Helper methods
private withDB<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (this.db) {
operation(this.db).then(resolve).catch(reject);
return;
}
this.initializeDatabase()
.then(() => operation(this.db!))
.then(resolve)
.catch(reject);
});
}
private saveToLocalStorage(id: string, config: MyNewWidgetConfig, items: any[]): void {
try {
const storageItem: MyNewWidgetStorageItem = {
id,
config: { ...config, id },
items: items || [],
lastSaved: new Date().toISOString()
};
localStorage.setItem(`${this.STORAGE_PREFIX}-${id}`, JSON.stringify(storageItem));
} catch (error) {
console.error('Error saving to localStorage', error);
}
}
private loadFromLocalStorage(id: string): MyNewWidgetStorageItem | null {
try {
const stored = localStorage.getItem(`${this.STORAGE_PREFIX}-${id}`);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('Error loading from localStorage', error);
}
return null;
}
}
Widget Integration Component
Create a component that integrates your widget with storage:
// my-new-widget-integration.component.ts
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil, filter } from 'rxjs/operators';
import { MyNewWidgetComponent, MyNewWidgetConfig } from './my-new-widget.component';
import { MyNewWidgetStorageService } from './my-new-widget-storage.service';
@Component({
selector: 'app-my-new-widget-integration',
standalone: true,
imports: [CommonModule, MyNewWidgetComponent],
template: `
<app-my-new-widget
[id]="id"
[config]="widgetConfig"
(configChanged)="onConfigChanged($event)">
</app-my-new-widget>
`,
styles: [`:host { display: block; }`]
})
export class MyNewWidgetIntegrationComponent implements OnInit, OnDestroy {
@Input() id: string = 'default-widget';
widgetConfig: MyNewWidgetConfig = {
id: '',
title: 'New Widget',
theme: 'light'
};
private items: any[] = [];
private lastSavedHash: string = '';
private destroy$ = new Subject<void>();
private readonly COMPONENT_ID = 'my-widget-integration';
constructor(private storageService: MyNewWidgetStorageService) {}
ngOnInit(): void {
// Load saved configuration
this.loadWidgetConfig();
// Subscribe to external updates
this.subscribeToStorageUpdates();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadWidgetConfig(): void {
this.storageService.loadWidgetConfig(this.id).subscribe(item => {
if (item) {
this.widgetConfig = { ...item.config };
this.items = [...item.items];
this.updateConfigHash();
} else {
// Initialize with defaults
this.widgetConfig = {
id: this.id,
title: 'New Widget',
theme: 'light'
};
this.saveWidgetConfig();
}
});
}
private subscribeToStorageUpdates(): void {
this.storageService.configUpdated$
.pipe(
// Only process updates for this widget
filter(update => update.item.id === this.id),
// Skip self-triggered updates
filter(update => update.source !== this.COMPONENT_ID),
takeUntil(this.destroy$)
)
.subscribe(update => {
// Update local state
this.widgetConfig = { ...update.item.config };
this.items = [...update.item.items];
this.updateConfigHash();
});
}
onConfigChanged(config: MyNewWidgetConfig): void {
this.widgetConfig = { ...config };
this.saveWidgetConfig();
}
private saveWidgetConfig(): void {
// Generate hash to check if config has changed
const configString = JSON.stringify({
config: this.widgetConfig,
items: this.items
});
const currentHash = this.hashString(configString);
// Skip saving if unchanged
if (currentHash === this.lastSavedHash) {
return;
}
// Save and update hash
this.storageService.saveWidgetConfig(
this.id,
this.widgetConfig,
this.items,
this.COMPONENT_ID
).subscribe();
this.lastSavedHash = currentHash;
}
private updateConfigHash(): void {
const configString = JSON.stringify({
config: this.widgetConfig,
items: this.items
});
this.lastSavedHash = this.hashString(configString);
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash.toString();
}
}
Widget Communication
Widgets can communicate with each other through various mechanisms:
1. Event Service
Create a central event bus for widget communication:
// widget-event.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
export interface WidgetEvent {
type: string;
source: string;
payload?: any;
}
@Injectable({
providedIn: 'root'
})
export class WidgetEventService {
private eventBus = new Subject<WidgetEvent>();
// Publish an event
publishEvent(event: WidgetEvent): void {
this.eventBus.next(event);
}
// Subscribe to specific event types
onEvent(type: string): Observable<WidgetEvent> {
return this.eventBus.pipe(
filter(event => event.type === type)
);
}
// Subscribe to events from a specific source
onEventFromSource(source: string): Observable<WidgetEvent> {
return this.eventBus.pipe(
filter(event => event.source === source)
);
}
// Subscribe to specific event type from specific source
onEventOfTypeFromSource(type: string, source: string): Observable<WidgetEvent> {
return this.eventBus.pipe(
filter(event => event.type === type && event.source === source)
);
}
}
2. Direct Service Injection
For widgets that need to communicate more directly:
// widget-coordination.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class WidgetCoordinationService {
// Shared state
private selectedItemSubject = new BehaviorSubject<string | null>(null);
public selectedItem$ = this.selectedItemSubject.asObservable();
// Shared actions
selectItem(itemId: string): void {
this.selectedItemSubject.next(itemId);
}
clearSelection(): void {
this.selectedItemSubject.next(null);
}
}
Storage Integration
To ensure your widget properly integrates with the UX Widgets storage system, follow these patterns:
1. Follow the Storage Contract
Your storage service should implement these core methods:
interface WidgetStorageService<T, I> {
// Save configuration and items
saveConfig(id: string, config: T, items: I[], source?: string): Observable<boolean>;
// Load configuration and items
loadConfig(id: string): Observable<{id: string, config: T, items: I[], lastSaved: string} | null>;
// Delete configuration
deleteConfig(id: string): Observable<boolean>;
// Get all configurations
getAllConfigs(): Observable<Array<{id: string, config: T, items: I[], lastSaved: string}>>;
// Observable to listen for changes
configUpdated$: Observable<{
item: {id: string, config: T, items: I[], lastSaved: string},
source: string
}>;
}
2. Use the Database Version Management Pattern
When modifying database schema, follow this pattern:
private readonly DATABASE_VERSION = 2; // Increase this when changing schema
private initializeDatabase(): Promise<boolean> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DATABASE_NAME, this.DATABASE_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
// Create initial schema
const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
}
if (oldVersion < 2) {
// Migrate to v2 schema
const transaction = (event.target as IDBOpenDBRequest).transaction;
const store = transaction.objectStore(this.STORE_NAME);
// Add new index
if (!store.indexNames.contains('lastSaved')) {
store.createIndex('lastSaved', 'lastSaved', { unique: false });
}
}
};
// Rest of the initialization code
});
}
Theme Support
To ensure your widget supports themes properly:
1. Use CSS Variables
Define and use CSS variables for all themeable properties:
:host {
&.theme-light {
--widget-bg: #f8f9fa;
--widget-text: #343a40;
--widget-border: #e9ecef;
--widget-accent: #0d6efd;
--widget-success: #198754;
--widget-warning: #ffc107;
--widget-error: #dc3545;
}
&.theme-dark {
--widget-bg: #212529;
--widget-text: #f8f9fa;
--widget-border: #495057;
--widget-accent: #0d6efd;
--widget-success: #20c997;
--widget-warning: #ffc107;
--widget-error: #ff6b6b;
}
}
.widget-element {
background-color: var(--widget-bg);
color: var(--widget-text);
border: 1px solid var(--widget-border);
}
2. Add Theme Toggle
Provide a way for users to change themes:
// In your component
public toggleTheme(): void {
const currentTheme = this.widgetConfig.theme;
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
this.updateConfig({ theme: newTheme });
}
3. Support Global Theme Sync
Allow widgets to sync with a global theme:
// In your component
private syncWithGlobalTheme(): void {
this.themeService.globalTheme$
.pipe(takeUntil(this.destroy$))
.subscribe(theme => {
if (this.widgetConfig.syncWithGlobalTheme) {
this.updateConfig({ theme });
}
});
}
Testing Widgets
1. Component Testing
// my-new-widget.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyNewWidgetComponent } from './my-new-widget.component';
describe('MyNewWidgetComponent', () => {
let component: MyNewWidgetComponent;
let fixture: ComponentFixture<MyNewWidgetComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyNewWidgetComponent]
}).compileComponents();
fixture = TestBed.createComponent(MyNewWidgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should apply theme class to host element', () => {
component.config = { ...component.config, theme: 'dark' };
fixture.detectChanges();
const hostElement = fixture.nativeElement;
expect(hostElement.classList.contains('theme-dark')).toBeTruthy();
});
it('should emit configChanged event when updateConfig is called', () => {
const spy = jest.spyOn(component.configChanged, 'emit');
const newConfig = { title: 'Updated Title' };
component.updateConfig(newConfig);
expect(spy).toHaveBeenCalled();
expect(component.config.title).toEqual('Updated Title');
});
});
2. Storage Service Testing
// my-new-widget-storage.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { MyNewWidgetStorageService } from './my-new-widget-storage.service';
describe('MyNewWidgetStorageService', () => {
let service: MyNewWidgetStorageService;
// Mock IndexedDB
const indexedDBMock = {
open: jest.fn().mockReturnValue({
result: {
transaction: jest.fn().mockReturnValue({
objectStore: jest.fn().mockReturnValue({
put: jest.fn().mockReturnValue({
onsuccess: null
}),
get: jest.fn().mockReturnValue({
onsuccess: null
})
})
}),
createObjectStore: jest.fn().mockReturnValue({
createIndex: jest.fn()
}),
objectStoreNames: {
contains: jest.fn().mockReturnValue(false)
}
},
onupgradeneeded: null,
onsuccess: null,
onerror: null
})
};
beforeEach(() => {
// Setup IndexedDB mock
global.indexedDB = indexedDBMock as any;
TestBed.configureTestingModule({});
service = TestBed.inject(MyNewWidgetStorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
// Add more specific tests for your service methods
});
3. Integration Testing
// my-new-widget-integration.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { MyNewWidgetIntegrationComponent } from './my-new-widget-integration.component';
import { MyNewWidgetStorageService } from './my-new-widget-storage.service';
describe('MyNewWidgetIntegrationComponent', () => {
let component: MyNewWidgetIntegrationComponent;
let fixture: ComponentFixture<MyNewWidgetIntegrationComponent>;
let storageServiceMock: any;
beforeEach(async () => {
// Create storage service mock
storageServiceMock = {
loadWidgetConfig: jest.fn().mockReturnValue(of(null)),
saveWidgetConfig: jest.fn().mockReturnValue(of(true)),
configUpdated$: of({
item: {
id: 'test-widget',
config: {
id: 'test-widget',
title: 'Test Widget',
theme: 'light'
},
items: [],
lastSaved: new Date().toISOString()
},
source: 'test'
})
};
await TestBed.configureTestingModule({
imports: [MyNewWidgetIntegrationComponent],
providers: [
{ provide: MyNewWidgetStorageService, useValue: storageServiceMock }
]
}).compileComponents();
fixture = TestBed.createComponent(MyNewWidgetIntegrationComponent);
component = fixture.componentInstance;
component.id = 'test-widget';
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load configuration on init', () => {
expect(storageServiceMock.loadWidgetConfig).toHaveBeenCalledWith('test-widget');
});
it('should save configuration when changed', () => {
const widget = fixture.nativeElement.querySelector('app-my-new-widget');
const configChangedEvent = new CustomEvent('configChanged', {
detail: {
id: 'test-widget',
title: 'Updated Title',
theme: 'dark'
}
});
widget.dispatchEvent(configChangedEvent);
fixture.detectChanges();
expect(storageServiceMock.saveWidgetConfig).toHaveBeenCalled();
});
});
Best Practices
1. Component API Design
- Minimal Required Inputs: Only require essential inputs like
id - Default Values: Provide sensible defaults for all configuration options
- Use Partial Types: Accept partial configurations to allow incremental updates
- Strong Typing: Define explicit interfaces for all configurations and events
- Event-Based Communication: Use events for loose coupling between components
// Good
@Input() id: string = '';
@Input() config: Partial<WidgetConfig> = {};
@Output() configChanged = new EventEmitter<WidgetConfig>();
// Avoid
@Input() title: string = '';
@Input() theme: string = '';
@Input() items: any[] = [];
@Output() titleChanged = new EventEmitter<string>();
@Output() themeChanged = new EventEmitter<string>();
2. Storage Considerations
- Unique Store Names: Use unique store names for each widget type
- Version Management: Increment database version when changing schema
- Graceful Degradation: Fall back to localStorage when IndexedDB isn't available
- Error Handling: Catch and handle all storage-related errors
- Transaction Management: Keep transactions short and focused
3. Performance Optimizations
- Debounce Saves: Debounce rapid configuration changes before saving
- Hash-Based Change Detection: Only save when configuration actually changes
- Efficient Updates: Update only changed properties, not entire objects
- Lazy Loading: Load configurations only when needed
- OnPush Change Detection: Use OnPush change detection strategy for better performance
4. Theme Implementation
- CSS Variables: Use CSS variables for all themeable properties
- Host Binding: Apply theme classes to the host element
- Theme Isolation: Avoid leaking theme styles outside the component
- Support Dark/Light Modes: Always support at least light and dark themes
- Theme Synchronization: Allow sync with global themes while supporting local overrides
5. Scalability
- Modular Design: Keep widgets independent and self-contained
- Shared Interfaces: Create shared interfaces for common patterns
- Composition Over Inheritance: Compose widgets from smaller components
- Service Independence: Avoid tight coupling between services
- Feature Toggles: Use feature flags to enable/disable advanced features
6. Documentation
- Clear Comments: Add JSDoc comments to public methods and properties
- Usage Examples: Include example code in component documentation
- Parameter Descriptions: Document all input and output parameters
- Version History: Track changes in a change log
- API Documentation: Generate API documentation with TypeDoc
Conclusion
By following these patterns and best practices, you can extend the UX Widgets system with new components that are robust, maintainable, and integrate seamlessly with the existing ecosystem. The modular nature of the system allows for great flexibility while maintaining consistency across the application.