Skip to content

Widget Development Guide

Best practices, patterns, and advanced techniques for developing Barcoding widgets.

Widget Development Workflow

1. Define Widget Purpose

Before writing code, clearly define:

  • What data does the widget display? (NCR info, comments, charts, etc.)
  • Who will use it? (Operators, managers, admins)
  • Where will it be used? (Backend, Front, Dashboard instances)
  • What actions can users perform? (View only, edit, create, delete)
  • What category does it belong to? (Info, Universal, Chart, List)

2. Design Widget Interface

Sketch out the widget UI:

  • Layout: How is information organized?
  • Grid Size: Default cols/rows dimensions
  • Responsive Behavior: How does it adapt to different sizes?
  • User Interactions: Buttons, forms, filters, etc.

3. Implement Widget Component

Follow the patterns described in this guide.

4. Test Widget

  • Test in different grid sizes
  • Test with different configurations
  • Test error scenarios
  • Test across instances (if shared widget)

Core Implementation Patterns

Basic Widget Structure

Minimal widget implementation:

typescript
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { Store } from '@ngxs/store';
import { WidgetInterface, WidgetParams } from '@barcoding/gridster-core';
import { WidgetConfigWithRelations } from '@barcoding/sdk';

@Component({
  selector: 'widget-example',
  templateUrl: './widget-example.component.html',
  styleUrls: ['./widget-example.component.scss']
})
export class WidgetExampleComponent implements OnInit, OnDestroy, WidgetInterface {
  // Required by WidgetInterface
  public item: WidgetConfigWithRelations;
  public params: WidgetParams;
  public state: Observable<any>;

  // Component state
  public data: any[] = [];
  public loading = false;

  // Subscriptions for cleanup
  private subscriptions: Subscription[] = [];

  constructor(private store: Store) {}

  ngOnInit() {
    this.loadData();
  }

  ngOnDestroy() {
    // Clean up subscriptions
    this.subscriptions.forEach(sub => sub.unsubscribe());
  }

  private loadData() {
    // Widget logic here
  }
}

Info Widget Pattern

Display information about the current object:

typescript
@Component({
  selector: 'widget-ncr-info',
  templateUrl: './widget-ncr-info.component.html'
})
export class WidgetNcrInfoComponent implements OnInit, OnDestroy, WidgetInterface {
  public item: WidgetConfigWithRelations;
  public params: WidgetParams;
  public state: Observable<NcrWithRelations>;

  // Component state
  public object: NcrWithRelations = null;
  private objectSubscription: Subscription;

  constructor(
    private store: Store,
    private ncrApi: NcrControllerService
  ) {}

  ngOnInit() {
    // Subscribe to state changes
    this.objectSubscription = this.state.subscribe(ncr => {
      this.object = ncr;
      this.handleDataUpdate(ncr);
    });
  }

  ngOnDestroy() {
    if (this.objectSubscription) {
      this.objectSubscription.unsubscribe();
    }
  }

  private handleDataUpdate(ncr: NcrWithRelations) {
    // Process updated NCR data
    // Trigger additional data loads if needed
  }

  // User actions
  close(ncr: NcrWithRelations) {
    if (confirm('Close this NCR?')) {
      this.store.dispatch(new CloseNcr(ncr.id));
    }
  }

  open(ncr: NcrWithRelations) {
    if (confirm('Reopen this NCR?')) {
      this.store.dispatch(new OpenNcr(ncr.id));
    }
  }
}

Universal Widget Pattern

Works across multiple modules using module_id and object_id:

typescript
@Component({
  selector: 'widget-comments',
  templateUrl: './widget-comments.component.html'
})
export class WidgetCommentsComponent implements OnInit, OnDestroy, WidgetInterface {
  public item: WidgetConfigWithRelations;
  public params: WidgetParams;
  public state: Observable<any>;

  @Select(CommentState.comments) comments$: Observable<CommentWithRelations[]>;

  private objectSubscription: Subscription;

  constructor(private store: Store) {}

  ngOnInit() {
    this.objectSubscription = this.state.subscribe(object => {
      this.params.object = object;

      // Get current module context
      const currentModuleId = this.store.selectSnapshot(ModuleState.module).id;

      // Load comments using universal module_id/object_id approach
      this.store.dispatch(new FindComments({
        where: {
          module_id: currentModuleId,
          object_id: object.id
        }
      }));
    });
  }

  ngOnDestroy() {
    if (this.objectSubscription) {
      this.objectSubscription.unsubscribe();
    }
    this.store.dispatch(new CommentClearStore());
  }

  // Universal actions
  addComment(message: string) {
    const currentModuleId = this.store.selectSnapshot(ModuleState.module).id;
    const currentUserId = this.store.selectSnapshot(AuthState.user).id;

    this.store.dispatch(new CreateComment(this.params.model, this.params.object.id, {
      object_id: this.params.object.id,
      module_id: currentModuleId,
      comment: message,
      user_id: currentUserId
    }));
  }

  deleteComment(commentId: number) {
    if (confirm('Delete this comment?')) {
      this.store.dispatch(new DeleteComment(commentId, this.params.object.id));
    }
  }
}

Chart Widget Pattern

Display data visualizations:

typescript
@Component({
  selector: 'widget-production-chart',
  templateUrl: './widget-production-chart.component.html'
})
export class WidgetProductionChartComponent implements OnInit, OnDestroy, WidgetInterface {
  public item: WidgetConfigWithRelations;
  public params: WidgetParams;

  // Chart configuration
  public chartData: any[] = [];
  public chartOptions: any = {
    title: { text: 'Production Overview' },
    legend: { position: 'bottom' },
    series: [{
      type: 'column',
      data: []
    }]
  };

  private refreshInterval: any;

  constructor(
    private productionApi: ProductionControllerService
  ) {}

  ngOnInit() {
    this.loadChartData();

    // Auto-refresh if configured
    if (this.item.config?.refreshInterval) {
      this.refreshInterval = setInterval(() => {
        this.loadChartData();
      }, this.item.config.refreshInterval * 1000);
    }
  }

  ngOnDestroy() {
    if (this.refreshInterval) {
      clearInterval(this.refreshInterval);
    }
  }

  private loadChartData() {
    const startDate = this.item.config?.startDate || new Date();
    const endDate = this.item.config?.endDate || new Date();

    this.productionApi.getProductionData({
      startDate,
      endDate
    }).subscribe(
      data => {
        this.chartData = data;
        this.updateChartSeries(data);
      },
      error => {
        console.error('Failed to load chart data:', error);
      }
    );
  }

  private updateChartSeries(data: any[]) {
    this.chartOptions.series[0].data = data.map(item => ({
      category: item.date,
      value: item.production_count
    }));
  }
}

List Widget Pattern

Display and manage lists of data:

typescript
@Component({
  selector: 'widget-employee-assignments',
  templateUrl: './widget-employee-assignments.component.html'
})
export class WidgetEmployeeAssignmentsComponent implements OnInit, OnDestroy, WidgetInterface {
  public item: WidgetConfigWithRelations;
  public params: WidgetParams;

  // Grid configuration
  public gridData: GridDataResult;
  public pageSize = 10;
  public skip = 0;
  public sort: SortDescriptor[] = [];
  public filter: CompositeFilterDescriptor;

  constructor(
    private assignmentApi: AssignmentControllerService,
    private dialog: MatDialog
  ) {}

  ngOnInit() {
    this.loadData();
  }

  ngOnDestroy() {}

  private loadData() {
    const where: any = {};

    // Apply filters from config
    if (this.item.config?.departmentId) {
      where.department_id = this.item.config.departmentId;
    }

    this.assignmentApi.find({
      filter: JSON.stringify({
        where,
        limit: this.pageSize,
        skip: this.skip,
        order: this.sort.map(s => `${s.field} ${s.dir}`).join(',')
      })
    }).subscribe(
      data => {
        this.gridData = {
          data: data,
          total: data.length
        };
      },
      error => {
        console.error('Failed to load assignments:', error);
      }
    );
  }

  // Grid events
  onPageChange(event: PageChangeEvent) {
    this.skip = event.skip;
    this.pageSize = event.take;
    this.loadData();
  }

  onSortChange(sort: SortDescriptor[]) {
    this.sort = sort;
    this.loadData();
  }

  onFilterChange(filter: CompositeFilterDescriptor) {
    this.filter = filter;
    this.loadData();
  }

  // CRUD actions
  addAssignment() {
    const dialogRef = this.dialog.open(AssignmentFormComponent, {
      width: '600px',
      data: { mode: 'create' }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.loadData();
      }
    });
  }

  editAssignment(assignment: any) {
    const dialogRef = this.dialog.open(AssignmentFormComponent, {
      width: '600px',
      data: { mode: 'edit', assignment }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.loadData();
      }
    });
  }

  deleteAssignment(assignment: any) {
    if (confirm('Delete this assignment?')) {
      this.assignmentApi.deleteById({ id: assignment.id }).subscribe(
        () => {
          this.loadData();
        },
        error => {
          console.error('Failed to delete assignment:', error);
        }
      );
    }
  }
}

Widget Configuration

Implementing Configurable Widgets

Widgets can have user-configurable settings. Extend ConfigInterfaceDashboard:

typescript
import { ConfigInterfaceDashboard } from '@barcoding/gridster-core';
import { MatDialog } from '@angular/material/dialog';
import { Subject } from 'rxjs';

@Component({
  selector: 'widget-comments',
  templateUrl: './widget-comments.component.html'
})
export class WidgetCommentsComponent
  extends ConfigInterfaceDashboard
  implements OnInit, OnDestroy, WidgetInterface {

  public item: WidgetConfigWithRelations;
  public params: WidgetParams;
  public configChanged$: Subject<any> = new Subject<any>();

  constructor(
    public dialog: MatDialog,
    public store: Store
  ) {
    // Pass config dialog component to parent
    super(dialog, CommentsConfigDialog, store);
  }

  ngOnInit() {
    // Subscribe to config changes
    this.configChanged$.subscribe(newConfig => {
      console.log('Config updated:', newConfig);
      this.applyConfig(newConfig);
    });
  }

  ngOnDestroy() {
    super.destroy(); // Clean up config subscriptions
  }

  private applyConfig(config: any) {
    // Apply new configuration
    if (config.showTimestamps !== undefined) {
      this.showTimestamps = config.showTimestamps;
    }
  }
}

Creating Configuration Dialog

Configuration dialog component:

typescript
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Component, Inject, OnInit } from '@angular/core';

export interface CommentsConfig {
  showTimestamps?: boolean;
  showAvatars?: boolean;
  enableNotifications?: boolean;
  [prop: string]: any;
}

@Component({
  selector: 'comments-config',
  template: `
    <h2 mat-dialog-title>Comments Widget Configuration</h2>
    <mat-dialog-content>
      <mat-checkbox [(ngModel)]="config.showTimestamps">
        Show Timestamps
      </mat-checkbox>
      <mat-checkbox [(ngModel)]="config.showAvatars">
        Show User Avatars
      </mat-checkbox>
      <mat-checkbox [(ngModel)]="config.enableNotifications">
        Enable Notifications
      </mat-checkbox>
    </mat-dialog-content>
    <mat-dialog-actions>
      <button mat-button (click)="onNoClick()">Cancel</button>
      <button mat-button color="primary" (click)="save(config)">Save</button>
    </mat-dialog-actions>
  `
})
export class CommentsConfigDialog implements OnInit {
  config: CommentsConfig = {};

  constructor(
    public dialogRef: MatDialogRef<CommentsConfigDialog>,
    @Inject(MAT_DIALOG_DATA) public data: any
  ) {
    // Load current config
    this.config = Object.assign({}, data.currentConfig);
  }

  ngOnInit() {}

  save(config: CommentsConfig) {
    this.dialogRef.close(config);
  }

  onNoClick() {
    this.dialogRef.close();
  }
}

State Management Integration

Using NGXS Stores

Widgets can dispatch actions and select state from NGXS stores:

typescript
import { Select, Store } from '@ngxs/store';
import { Observable } from 'rxjs';

@Component({
  selector: 'widget-example',
  templateUrl: './widget-example.component.html'
})
export class WidgetExampleComponent implements OnInit, WidgetInterface {
  // Select state slices
  @Select(AuthState.user) user$: Observable<UserWithRelations>;
  @Select(ModuleState.module) module$: Observable<ModuleWithRelations>;
  @Select(MyFeatureState.data) data$: Observable<any[]>;

  constructor(private store: Store) {}

  ngOnInit() {
    // Dispatch actions
    this.store.dispatch(new LoadData());

    // Select snapshot (synchronous)
    const currentUser = this.store.selectSnapshot(AuthState.user);
    console.log('Current user:', currentUser);
  }

  // Action handlers
  refreshData() {
    this.store.dispatch(new RefreshData());
  }

  createItem(item: any) {
    this.store.dispatch(new CreateItem(item));
  }
}

Creating Widget-Specific Store

For complex widgets, create a dedicated NGXS store:

typescript
// widget-example.state.ts
import { State, Action, StateContext, Selector } from '@ngxs/store';
import { Injectable } from '@angular/core';

export class LoadWidgetData {
  static readonly type = '[WidgetExample] Load Data';
  constructor(public params: any) {}
}

export class UpdateWidgetData {
  static readonly type = '[WidgetExample] Update Data';
  constructor(public data: any) {}
}

export interface WidgetExampleStateModel {
  data: any[];
  loading: boolean;
  error: string | null;
}

@State<WidgetExampleStateModel>({
  name: 'widgetExample',
  defaults: {
    data: [],
    loading: false,
    error: null
  }
})
@Injectable()
export class WidgetExampleState {
  @Selector()
  static data(state: WidgetExampleStateModel) {
    return state.data;
  }

  @Selector()
  static loading(state: WidgetExampleStateModel) {
    return state.loading;
  }

  @Action(LoadWidgetData)
  loadData(ctx: StateContext<WidgetExampleStateModel>, action: LoadWidgetData) {
    ctx.patchState({ loading: true });

    // API call logic here
    // ctx.patchState({ data: result, loading: false });
  }

  @Action(UpdateWidgetData)
  updateData(ctx: StateContext<WidgetExampleStateModel>, action: UpdateWidgetData) {
    ctx.patchState({ data: action.data });
  }
}

API Integration Patterns

Standard API Call Pattern

typescript
constructor(private myApiService: MyApiControllerService) {}

ngOnInit() {
  this.loading = true;

  this.myApiService.apiMyControllerGet({
    filter: JSON.stringify({
      where: { status: 'active' }
    })
  }).subscribe(
    response => {
      this.data = response;
      this.loading = false;
    },
    error => {
      console.error('API call failed:', error);
      this.loading = false;
      this.error = 'Failed to load data';
    }
  );
}

Using $Response Methods for Full Response

typescript
this.myApiService.apiMyControllerGet$Response({
  filter: JSON.stringify({ where: { id: 123 } })
}).subscribe(
  response => {
    console.log('Status:', response.status);
    console.log('Headers:', response.headers);
    console.log('Body:', response.body);
    this.data = response.body;
  },
  error => {
    console.error('API error:', error);
  }
);

Using firstValueFrom for Async/Await Pattern

typescript
import { firstValueFrom } from 'rxjs';

async loadData() {
  this.loading = true;

  try {
    const data = await firstValueFrom(
      this.myApiService.apiMyControllerGet({
        filter: JSON.stringify({ where: { status: 'active' } })
      })
    );

    this.data = data;
    this.loading = false;
  } catch (error) {
    console.error('Failed to load data:', error);
    this.loading = false;
    this.error = 'Failed to load data';
  }
}

Performance Optimization

Debouncing Rapid Updates

Use RxJS operators to debounce rapid state changes:

typescript
import { Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

private debouncer: Subject<any> = new Subject();
private debouncerSubscription: Subscription;

constructor(private store: Store) {
  this.debouncerSubscription = this.debouncer.pipe(
    debounceTime(300)
  ).subscribe(event => {
    this.loadData(event);
  });
}

ngOnInit() {
  this.state.subscribe(object => {
    // Debounce rapid state changes
    this.debouncer.next(object);
  });
}

ngOnDestroy() {
  if (this.debouncerSubscription) {
    this.debouncerSubscription.unsubscribe();
  }
}

OnPush Change Detection

Use OnPush strategy for better performance:

typescript
import { ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';

@Component({
  selector: 'widget-example',
  templateUrl: './widget-example.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetExampleComponent implements OnInit, WidgetInterface {
  constructor(private cdr: ChangeDetectorRef) {}

  ngOnInit() {
    this.state.subscribe(object => {
      this.object = object;
      this.cdr.markForCheck(); // Manually trigger change detection
    });
  }
}

TrackBy Functions for Lists

Optimize *ngFor rendering:

typescript
public trackByFn(index: number, item: any) {
  return item.id; // Use unique identifier
}
html
<div *ngFor="let item of items; trackBy: trackByFn">
  {{ item.name }}
</div>

Error Handling

User-Friendly Error Messages

typescript
private loadData() {
  this.loading = true;
  this.error = null;

  this.myApiService.getData().subscribe(
    data => {
      this.data = data;
      this.loading = false;
    },
    error => {
      this.loading = false;

      // Provide user-friendly error messages
      if (error.status === 404) {
        this.error = 'Data not found';
      } else if (error.status === 403) {
        this.error = 'You do not have permission to view this data';
      } else if (error.status === 500) {
        this.error = 'Server error occurred. Please try again later.';
      } else {
        this.error = 'An unexpected error occurred';
      }

      console.error('API error:', error);
    }
  );
}

Template Error Display

html
<div *ngIf="loading" class="loading-spinner">
  <kendo-loader></kendo-loader>
</div>

<div *ngIf="error" class="error-message">
  <mat-icon>error</mat-icon>
  <p>{{ error }}</p>
  <button mat-button (click)="loadData()">Retry</button>
</div>

<div *ngIf="!loading && !error">
  <!-- Widget content -->
</div>

Internationalization (i18n)

Using TranslateService

typescript
import { TranslateService } from '@ngx-translate/core';

constructor(private translateService: TranslateService) {}

ngOnInit() {
  this.translateService.get('$trl.widget_title').subscribe(translation => {
    this.title = translation;
  });
}

Async Translation in Template

html
<h3>{{ '$trl.widget_title' | translate }}</h3>
<p>{{ '$trl.widget_description' | translate }}</p>

Security Considerations

Access Control

Check user permissions before showing sensitive data or actions:

typescript
import { AuthState } from '@barcoding/auth-core';
import { ObjectAccessService } from '@barcoding/backend-core';

constructor(
  private store: Store,
  private objectAccessService: ObjectAccessService
) {}

ngOnInit() {
  // Check module-level permissions
  const currentModule = this.store.selectSnapshot(ModuleState.module);
  const currentUser = this.store.selectSnapshot(AuthState.user);

  this.canEdit = currentModule.user_level === 1; // Admin level
  this.canDelete = this.objectAccessService.canDelete(currentUser, this.params.object);
}

Template Permission Guards

html
<button *ngIf="canEdit" (click)="edit()">Edit</button>
<button *ngIf="canDelete" (click)="delete()">Delete</button>

Syneo/Barcoding Documentation