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:
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:
@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:
@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:
@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:
@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:
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:
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:
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:
// 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
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
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
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:
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:
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:
public trackByFn(index: number, item: any) {
return item.id; // Use unique identifier
}<div *ngFor="let item of items; trackBy: trackByFn">
{{ item.name }}
</div>Error Handling
User-Friendly Error Messages
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
<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
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
<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:
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
<button *ngIf="canEdit" (click)="edit()">Edit</button>
<button *ngIf="canDelete" (click)="delete()">Delete</button>