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

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

// 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

3. Performance Optimizations

4. Theme Implementation

5. Scalability

6. Documentation

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.