# Oblique Framework
Oblique is an Angular front-end framework developed by the Swiss Federal Office of Information Technology, Systems and Telecommunication (FOITT). It provides a standardized corporate design system, ready-to-use Angular components, and a fully customizable master layout specifically tailored for Swiss branded business web applications. The framework ensures consistency across federal applications while offering developers the flexibility to focus on content rather than structural concerns.
The project consists of multiple packages including a core component library (@oblique/oblique), a CLI tool (@oblique/cli), a design system (@oblique/design-system), and supporting utilities. Built on Angular 19.x and Angular Material, Oblique offers comprehensive features such as OAuth2/OIDC authentication, schema validation, internationalization support, HTTP interceptors, and a rich collection of UI components. The framework follows modern Angular practices with standalone components, reactive patterns using RxJS, and WCAG accessibility compliance.
---
## CLI - Project Creation
Create new Oblique applications with automated setup.
```bash
# Install CLI globally
npm install -g @oblique/cli
# Create new project with Angular, Material, and Oblique
ob new my-app
# This command will:
# 1. Create Angular workspace with ng new
# 2. Add Angular Material with ng add @angular/material
# 3. Add Oblique with ng add @oblique/oblique
# 4. Configure dependencies and format code
# Check CLI version
ob -v
# Get help
ob -h
```
---
## CLI - Project Update
Update Oblique and dependencies in existing projects.
```bash
# Update Oblique and all dependencies
ob update
# This command executes:
# 1. Checks for required dependencies
# 2. Adds @schematics/angular if missing
# 3. Runs ng update on all packages
# 4. Runs npm dedupe to optimize dependencies
# 5. Runs npm prune to remove unused packages
# 6. Displays outdated dependencies report
# Output includes:
# - Angular version updates
# - Oblique version updates
# - Material version updates
# - List of outdated packages with current and latest versions
```
---
## Application Configuration
Bootstrap an Oblique application with comprehensive configuration.
```typescript
import {bootstrapApplication} from '@angular/platform-browser';
import {provideRouter} from '@angular/router';
import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
import {provideAnimations} from '@angular/platform-browser/animations';
import {provideObliqueConfiguration} from '@oblique/oblique';
import {AppComponent} from './app/app.component';
import {routes} from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
provideAnimations(),
provideObliqueConfiguration({
accessibilityStatement: {
applicationName: 'Federal Data Portal',
applicationOperator: 'Federal Statistical Office',
createdOn: new Date('2024-01-15'),
conformity: 'full',
contact: [{
email: 'accessibility@admin.ch',
phone: '+41 58 123 45 67'
}],
standards: ['WCAG 2.1 Level AA']
},
icon: {
registerObliqueIcons: true,
additionalIcons: ['./assets/icons/custom-icons.svg']
},
translate: {
flatten: true,
additionalFiles: [
{prefix: './assets/i18n/', suffix: '.json'}
],
config: {
defaultLanguage: 'de',
useDefaultLang: true
}
},
hasLanguageInUrl: false,
material: {
MAT_FORM_FIELD_DEFAULT_OPTIONS: {
appearance: 'outline',
floatLabel: 'auto'
},
MAT_CHECKBOX_DEFAULT_OPTIONS: {
color: 'primary'
},
MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS: {
hideSingleSelectionIndicator: true
}
}
})
]
}).catch(err => console.error(err));
// Result: Application starts with:
// - Accessibility statement configured
// - Oblique icons registered
// - Multi-language support enabled (de, fr, it, en)
// - Material components styled with outline form fields
// - HTTP interceptors and animations ready
```
---
## Master Layout Component
Create the main application structure with header, navigation, content, and footer.
```typescript
// app.component.ts
import {Component, inject, OnInit} from '@angular/core';
import {ObMasterLayoutModule, ObMasterLayoutService, ObEScrollMode} from '@oblique/oblique';
import {RouterOutlet} from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [ObMasterLayoutModule, RouterOutlet],
template: `
account_balance
Federal Data Portal
dashboard
Dashboard
storage
Datasets
assessment
Reports
settings
Settings
`
})
export class AppComponent implements OnInit {
private readonly masterLayout = inject(ObMasterLayoutService);
ngOnInit(): void {
// Configure sticky header
this.masterLayout.header.isSticky = true;
this.masterLayout.header.isSmall = false;
// Configure navigation scrolling
this.masterLayout.navigation.scrollMode = ObEScrollMode.AUTO;
this.masterLayout.navigation.scrollDelta = 95;
this.masterLayout.navigation.isFullWidth = true;
// Configure footer
this.masterLayout.footer.isSticky = false;
// Configure layout
this.masterLayout.layout.hasMaxWidth = true;
this.masterLayout.layout.hasCover = false;
// Configure home page route
this.masterLayout.homePageRoute = '/dashboard';
// Subscribe to home page route changes
this.masterLayout.homePageRouteChange$.subscribe(route => {
console.log('Home page route changed to:', route);
});
}
}
// Result:
// - Sticky header with application branding
// - Side navigation with icons and active state highlighting
// - Automatic scroll management for navigation
// - Footer with legal links
// - Responsive layout with max-width constraint
```
---
## Service Navigation Header
Add user profile, applications, notifications, and language switcher to the header.
```typescript
// app.component.ts
import {Component} from '@angular/core';
import {
ObMasterLayoutModule,
ObServiceNavigationComponent,
ObIServiceNavigationLink,
ObIServiceNavigationContact,
ObEPamsEnvironment,
ObLoginState
} from '@oblique/oblique';
@Component({
selector: 'app-root',
standalone: true,
imports: [ObMasterLayoutModule],
template: `
Federal Portal
`
})
export class AppComponent {
environment = ObEPamsEnvironment.PROD;
profileLinks: ObIServiceNavigationLink[] = [
{
url: '/profile',
label: 'My Profile',
icon: 'person'
},
{
url: '/settings',
label: 'Settings',
icon: 'settings'
},
{
url: '/help',
label: 'Help & Support',
icon: 'help'
}
];
infoLinks: ObIServiceNavigationLink[] = [
{
url: 'https://www.admin.ch',
label: 'Federal Administration',
isExternal: true
},
{
url: 'https://www.eportal.admin.ch',
label: 'ePortal',
isExternal: true
}
];
infoContact: ObIServiceNavigationContact = {
email: 'support@admin.ch',
phone: '+41 58 462 05 66',
address: 'Bundesgasse 3, 3003 Bern'
};
onLoginStateChange(state: ObLoginState): void {
console.log('Login state:', state);
if (state === ObLoginState.LOGGED_IN) {
console.log('User is authenticated');
} else if (state === ObLoginState.LOGGED_OUT) {
console.log('User is not authenticated');
}
}
handleLogout(): void {
console.log('Logout triggered - clearing session');
// Perform logout logic
sessionStorage.clear();
localStorage.clear();
}
}
// Result:
// - User profile dropdown with custom links
// - Application switcher with favorites
// - Info panel with external links and contact information
// - Language selector (de, fr, it, en)
// - Message/notification center
// - Authentication status display
// - Automatic logout handling with event callback
```
---
## Notification Service
Display toast notifications for user feedback.
```typescript
import {Component, inject} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {
ObNotificationService,
ObNotificationModule,
ObENotificationType,
ObENotificationPlacement,
ObINotification
} from '@oblique/oblique';
import {catchError, finalize} from 'rxjs/operators';
import {of} from 'rxjs';
@Component({
selector: 'app-data-manager',
standalone: true,
imports: [ObNotificationModule],
template: `
`
})
export class DataManagerComponent {
private readonly notification = inject(ObNotificationService);
private readonly http = inject(HttpClient);
constructor() {
// Configure global notification settings
this.notification.placement = ObENotificationPlacement.TOP_RIGHT;
this.notification.clearAllOnNavigate = true;
}
saveData(): void {
this.http.post('/api/data', {name: 'Test', value: 123})
.pipe(
catchError(error => {
this.notification.error({
message: 'Failed to save data: ' + error.message,
title: 'Save Error',
sticky: true,
timeout: 0
});
return of(null);
})
)
.subscribe(response => {
if (response) {
this.notification.success({
message: 'Data saved successfully to the database',
title: 'Success',
sticky: false,
timeout: 3000
});
}
});
}
deleteData(): void {
this.notification.warning({
message: 'This action cannot be undone. Please confirm deletion.',
title: 'Warning: Permanent Action',
sticky: true,
idPrefix: 'delete-warning'
});
}
exportData(): void {
this.notification.info({
message: 'Preparing export file. This may take a few moments.',
title: 'Export in Progress',
sticky: false,
timeout: 5000
});
// Simulate export process
setTimeout(() => {
this.notification.success('Export completed. File downloaded to your browser.');
}, 5000);
}
showCustomNotification(): void {
const config: ObINotification = {
message: 'This is a custom notification with all options configured',
title: 'Custom Notification',
type: ObENotificationType.INFO,
sticky: false,
timeout: 7000,
channel: 'custom-channel',
idPrefix: 'custom-notif',
groupSimilar: true,
messageParams: {count: 5, type: 'records'}
};
this.notification.send(config);
}
clearNotifications(): void {
// Clear all notifications
this.notification.clearAll();
// Or clear specific channel
// this.notification.clear('custom-channel');
}
}
// Result:
// - Success notification: green, auto-dismisses after 3s
// - Error notification: red, sticky (manual dismiss)
// - Warning notification: yellow, sticky
// - Info notification: blue, auto-dismisses after 5s
// - Custom notification: configurable type, timeout, and grouping
// - All notifications appear in top-right corner
// - Notifications clear automatically on route navigation
```
---
## Form Validation with Schema
Validate forms using JSON Schema with automatic error messages.
```typescript
// user-form.component.ts
import {Component, inject} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatButtonModule} from '@angular/material/button';
import {MatSelectModule} from '@angular/material/select';
import {
ObSchemaValidationModule,
ObSchemaValidationDirective,
ObErrorMessagesModule,
ObErrorMessagesDirective,
ObNotificationService
} from '@oblique/oblique';
interface User {
email: string;
firstName: string;
lastName: string;
age: number;
department: string;
salary: number;
}
@Component({
selector: 'app-user-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatSelectModule,
ObSchemaValidationModule,
ObErrorMessagesModule
],
template: `
`
})
export class UserFormComponent {
private readonly fb = inject(FormBuilder);
private readonly notification = inject(ObNotificationService);
userForm: FormGroup;
// JSON Schema definition with comprehensive validation rules
userSchema = {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'
},
firstName: {
type: 'string',
minLength: 2,
maxLength: 50
},
lastName: {
type: 'string',
minLength: 2,
maxLength: 50
},
age: {
type: 'integer',
minimum: 18,
maximum: 70
},
department: {
type: 'string',
enum: ['engineering', 'hr', 'finance', 'marketing']
},
salary: {
type: 'number',
minimum: 40000,
multipleOf: 1000
}
},
required: ['email', 'firstName', 'lastName', 'age', 'department', 'salary']
};
constructor() {
this.userForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
firstName: ['', [Validators.required, Validators.minLength(2)]],
lastName: ['', [Validators.required, Validators.minLength(2)]],
age: [null, [Validators.required, Validators.min(18), Validators.max(70)]],
department: ['', Validators.required],
salary: [null, [Validators.required, Validators.min(40000)]]
});
}
onSubmit(): void {
if (this.userForm.valid) {
const userData: User = this.userForm.value;
console.log('Submitting user data:', userData);
this.notification.success({
title: 'Employee Registered',
message: `${userData.firstName} ${userData.lastName} has been successfully registered.`,
timeout: 4000
});
this.resetForm();
} else {
this.notification.warning({
title: 'Form Validation Failed',
message: 'Please correct all errors before submitting the form.',
sticky: true
});
}
}
resetForm(): void {
this.userForm.reset();
}
}
// Result:
// - JSON Schema validation integrated with Angular form validators
// - Automatic error message display for each field
// - Real-time validation feedback as user types
// - Required field indicators based on schema
// - Success notification on valid submission
// - Warning notification on invalid submission attempt
// - Form validation includes: email format, string length, numeric ranges, enum values, multiple-of constraints
```
---
## HTTP API Interceptor
Automatically handle HTTP requests with spinner activation and error notifications.
```typescript
// app.config.ts
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
import {provideAnimations} from '@angular/platform-browser/animations';
import {
ObHttpApiInterceptor,
ObHttpApiInterceptorConfig,
ObENotificationType,
provideObliqueConfiguration
} from '@oblique/oblique';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideAnimations(),
provideObliqueConfiguration({
accessibilityStatement: {
applicationName: 'API Client Demo',
applicationOperator: 'Federal IT',
createdOn: new Date('2024-01-01'),
conformity: 'full',
contact: [{email: 'dev@admin.ch'}]
}
}),
{
provide: HTTP_INTERCEPTORS,
useClass: ObHttpApiInterceptor,
multi: true
},
{
provide: ObHttpApiInterceptorConfig,
useValue: {
api: {
url: '/api',
notification: {
active: true,
severity: ObENotificationType.ERROR,
sticky: true,
title: 'API Error'
},
spinner: true
},
timeout: 30000,
timeoutNotification: {
active: true,
severity: ObENotificationType.WARNING,
sticky: false,
timeout: 5000,
title: 'Request Timeout Warning',
text: 'The server is taking longer than expected to respond.'
}
}
}
]
};
// data.service.ts
import {Injectable, inject} from '@angular/core';
import {HttpClient, HttpErrorResponse} from '@angular/common/http';
import {Observable, throwError} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {ObNotificationService} from '@oblique/oblique';
export interface Dataset {
id: number;
name: string;
records: number;
lastUpdated: Date;
}
@Injectable({providedIn: 'root'})
export class DataService {
private readonly http = inject(HttpClient);
private readonly notification = inject(ObNotificationService);
private readonly apiUrl = '/api/datasets';
// GET request - spinner activates automatically
getDatasets(): Observable {
return this.http.get(this.apiUrl).pipe(
map(datasets => datasets.map(ds => ({
...ds,
lastUpdated: new Date(ds.lastUpdated)
}))),
catchError(this.handleError.bind(this))
);
}
// GET by ID - spinner activates automatically
getDataset(id: number): Observable {
return this.http.get(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError.bind(this))
);
}
// POST request - spinner activates, success notification shown
createDataset(dataset: Omit): Observable {
return this.http.post(this.apiUrl, dataset).pipe(
map(created => {
this.notification.success({
title: 'Dataset Created',
message: `Dataset "${created.name}" has been successfully created.`,
timeout: 3000
});
return created;
}),
catchError(this.handleError.bind(this))
);
}
// PUT request - spinner activates
updateDataset(id: number, dataset: Partial): Observable {
return this.http.put(`${this.apiUrl}/${id}`, dataset).pipe(
map(updated => {
this.notification.success({
title: 'Dataset Updated',
message: `Dataset has been successfully updated.`,
timeout: 3000
});
return updated;
}),
catchError(this.handleError.bind(this))
);
}
// DELETE request - spinner activates
deleteDataset(id: number): Observable {
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
map(() => {
this.notification.success({
title: 'Dataset Deleted',
message: 'Dataset has been successfully removed.',
timeout: 3000
});
}),
catchError(this.handleError.bind(this))
);
}
// Long-running request - timeout warning will appear after 30s
generateReport(datasetId: number): Observable {
return this.http.get(`${this.apiUrl}/${datasetId}/report`, {
responseType: 'blob'
}).pipe(
map(blob => {
this.notification.success({
title: 'Report Generated',
message: 'Report is ready for download.',
timeout: 3000
});
return blob;
}),
catchError(this.handleError.bind(this))
);
}
private handleError(error: HttpErrorResponse): Observable {
let errorMessage = 'An unknown error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Client Error: ${error.error.message}`;
} else {
// Server-side error
switch (error.status) {
case 400:
errorMessage = 'Bad Request: Invalid data provided';
break;
case 401:
errorMessage = 'Unauthorized: Please login again';
break;
case 403:
errorMessage = 'Forbidden: You do not have permission';
break;
case 404:
errorMessage = 'Not Found: Resource does not exist';
break;
case 500:
errorMessage = 'Server Error: Please try again later';
break;
default:
errorMessage = `Server Error ${error.status}: ${error.message}`;
}
}
// Error notification is shown automatically by interceptor
// Additional custom handling can be done here
console.error('HTTP Error:', errorMessage, error);
return throwError(() => new Error(errorMessage));
}
}
// Result of using DataService:
// 1. All HTTP requests to /api/* trigger spinner automatically
// 2. Spinner shows during request and hides on completion/error
// 3. If request takes >30s, warning notification appears
// 4. On HTTP error, error notification displays automatically (sticky)
// 5. On success, custom success notifications appear (3s timeout)
// 6. 401 errors trigger session expiration handling
// 7. Network errors show appropriate error messages
// 8. All error details logged to console for debugging
```
---
## Autocomplete Component
Enhanced Material autocomplete with filtering and highlighting.
```typescript
// search.component.ts
import {Component, inject} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {MatFormFieldModule} from '@angular/material/form-field';
import {
ObAutocompleteModule,
ObIAutocompleteInputOption,
ObIAutocompleteInputOptionGroup,
ObNotificationService
} from '@oblique/oblique';
interface Employee {
id: string;
name: string;
department: string;
email: string;
}
@Component({
selector: 'app-employee-search',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
ObAutocompleteModule
],
template: `
`,
styles: [`
.highlight-match {
background-color: #ffeb3b;
font-weight: bold;
}
.selection-info {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 4px;
}
`]
})
export class EmployeeSearchComponent {
private readonly fb = inject(FormBuilder);
private readonly notification = inject(ObNotificationService);
searchForm: FormGroup;
selectedEmployee: ObIAutocompleteInputOption | null = null;
employees: Employee[] = [
{id: '1', name: 'Anna Müller', department: 'Engineering', email: 'anna.mueller@admin.ch'},
{id: '2', name: 'Beat Schmidt', department: 'Engineering', email: 'beat.schmidt@admin.ch'},
{id: '3', name: 'Claudia Weber', department: 'Human Resources', email: 'claudia.weber@admin.ch'},
{id: '4', name: 'Daniel Meier', department: 'Finance', email: 'daniel.meier@admin.ch'},
{id: '5', name: 'Eva Fischer', department: 'Engineering', email: 'eva.fischer@admin.ch'},
{id: '6', name: 'Franz Keller', department: 'Marketing', email: 'franz.keller@admin.ch'},
{id: '7', name: 'Greta Zimmermann', department: 'Human Resources', email: 'greta.zimmermann@admin.ch'},
{id: '8', name: 'Hans Berger', department: 'Finance', email: 'hans.berger@admin.ch'},
{id: '9', name: 'Irene Hofmann', department: 'Marketing', email: 'irene.hofmann@admin.ch'},
{id: '10', name: 'Jakob Steiner', department: 'Engineering', email: 'jakob.steiner@admin.ch'}
];
// Flat list of options
employeeOptions: ObIAutocompleteInputOption[] = this.employees.map(emp => ({
label: `${emp.name} (${emp.department})`,
value: emp.id,
icon: 'person',
description: emp.email
}));
// Grouped options by department
groupedEmployeeOptions: ObIAutocompleteInputOptionGroup[] = [
{
label: 'Engineering',
options: this.employees
.filter(emp => emp.department === 'Engineering')
.map(emp => ({
label: emp.name,
value: emp.id,
icon: 'engineering',
description: emp.email
}))
},
{
label: 'Human Resources',
options: this.employees
.filter(emp => emp.department === 'Human Resources')
.map(emp => ({
label: emp.name,
value: emp.id,
icon: 'people',
description: emp.email
}))
},
{
label: 'Finance',
options: this.employees
.filter(emp => emp.department === 'Finance')
.map(emp => ({
label: emp.name,
value: emp.id,
icon: 'account_balance',
description: emp.email
}))
},
{
label: 'Marketing',
options: this.employees
.filter(emp => emp.department === 'Marketing')
.map(emp => ({
label: emp.name,
value: emp.id,
icon: 'campaign',
description: emp.email
}))
}
];
constructor() {
this.searchForm = this.fb.group({
selectedEmployee: [''],
selectedByDepartment: ['']
});
}
onEmployeeSelected(option: ObIAutocompleteInputOption): void {
this.selectedEmployee = option;
const employee = this.getEmployeeById(option.value);
if (employee) {
this.notification.info({
title: 'Employee Selected',
message: `${employee.name} from ${employee.department}`,
timeout: 3000
});
console.log('Selected employee:', employee);
}
}
onGroupedSelection(option: ObIAutocompleteInputOption): void {
const employee = this.getEmployeeById(option.value);
if (employee) {
console.log('Selected from grouped list:', employee);
this.notification.success({
title: 'Selection Made',
message: `${employee.name} selected from ${employee.department} department`,
timeout: 3000
});
}
}
getEmployeeById(id: string): Employee | undefined {
return this.employees.find(emp => emp.id === id);
}
}
// Result:
// - Type-ahead search with real-time filtering
// - Matching text highlighted in yellow (configurable)
// - Icons displayed at start of each option
// - Two autocomplete variants: flat list and grouped by department
// - Email shown as description under each name
// - Case-insensitive search with regex flag 'gi'
// - "No results" message when no matches found
// - Selected value displayed in info panel below
// - Notification shown on selection
// - Integrates with Angular reactive forms
// - Full keyboard navigation support (arrows, enter, escape)
```
---
## Spinner Service
Control loading indicators across the application.
```typescript
// data-loader.component.ts
import {Component, inject, OnInit} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {CommonModule} from '@angular/common';
import {
ObSpinnerModule,
ObSpinnerService,
ObISpinnerEvent
} from '@oblique/oblique';
import {finalize, delay} from 'rxjs/operators';
@Component({
selector: 'app-data-loader',
standalone: true,
imports: [CommonModule, ObSpinnerModule],
template: `
Product Data
Multiple Operations
Spinner Events:
Channel: {{ event.channel }} - Active: {{ event.active }}
`,
styles: [`
.section {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
position: relative;
}
button {
margin: 5px;
}
`]
})
export class DataLoaderComponent implements OnInit {
private readonly spinner = inject(ObSpinnerService);
private readonly http = inject(HttpClient);
users: any[] = [];
products: any[] = [];
spinnerEvents: ObISpinnerEvent[] = [];
ngOnInit(): void {
// Subscribe to spinner events for debugging/monitoring
this.spinner.events$.subscribe(event => {
this.spinnerEvents.unshift(event);
if (this.spinnerEvents.length > 5) {
this.spinnerEvents.pop();
}
console.log('Spinner event:', event);
});
}
loadUsers(): void {
// Activate spinner on 'users' channel
this.spinner.activate('users');
this.http.get('/api/users')
.pipe(
delay(2000), // Simulate network delay
finalize(() => {
// Always deactivate spinner in finalize
this.spinner.deactivate('users');
})
)
.subscribe({
next: (data) => {
this.users = data;
console.log('Users loaded:', data.length);
},
error: (error) => {
console.error('Failed to load users:', error);
// Spinner automatically deactivated by finalize
}
});
}
loadProducts(): void {
this.spinner.activate('products');
this.http.get('/api/products')
.pipe(
delay(3000),
finalize(() => this.spinner.deactivate('products'))
)
.subscribe({
next: (data) => {
this.products = data;
console.log('Products loaded:', data.length);
},
error: (error) => {
console.error('Failed to load products:', error);
}
});
}
loadMultiple(): void {
// Activate default channel for global operations
this.spinner.activate();
// Also activate specific channels
this.spinner.activate('users');
this.spinner.activate('products');
const users$ = this.http.get('/api/users').pipe(
delay(2000),
finalize(() => this.spinner.deactivate('users'))
);
const products$ = this.http.get('/api/products').pipe(
delay(3000),
finalize(() => this.spinner.deactivate('products'))
);
// Both requests run in parallel
Promise.all([
users$.toPromise(),
products$.toPromise()
]).then(([users, products]) => {
this.users = users || [];
this.products = products || [];
console.log('All data loaded');
}).catch(error => {
console.error('Failed to load data:', error);
}).finally(() => {
// Deactivate global spinner
this.spinner.deactivate();
});
}
forceStopAll(): void {
// Force deactivate all spinners regardless of activation count
this.spinner.forceDeactivate(); // Default channel
this.spinner.forceDeactivate('users');
this.spinner.forceDeactivate('products');
console.log('All spinners force stopped');
}
// Example: Nested spinner activations
nestedOperations(): void {
// First activation
this.spinner.activate('nested');
// Second activation (ref count = 2)
this.spinner.activate('nested');
// First deactivation (ref count = 1, spinner still visible)
this.spinner.deactivate('nested');
// Second deactivation (ref count = 0, spinner hidden)
this.spinner.deactivate('nested');
// Alternative: Force deactivate bypasses ref count
this.spinner.activate('nested');
this.spinner.activate('nested');
this.spinner.forceDeactivate('nested'); // Immediately hidden
}
}
// Result:
// - Global spinner overlay covers entire screen when active
// - Channel-specific spinners show in their container sections
// - Multiple activations use reference counting (activate twice = deactivate twice)
// - forceDeactivate() immediately hides spinner regardless of ref count
// - Spinners automatically handle nested async operations
// - Observable events$ emits activation/deactivation events for monitoring
// - HTTP interceptor can automatically manage spinner without manual calls
// - Channels allow independent spinners for different UI sections
// - Proper cleanup with finalize() ensures spinner always deactivates
```
---
## Authentication Service
OAuth2/OIDC authentication wrapper.
```typescript
// auth.config.ts
import {ApplicationConfig} from '@angular/core';
import {provideRouter} from '@angular/router';
import {provideHttpClient} from '@angular/common/http';
import {provideOAuthClient, AuthConfig} from 'angular-oauth2-oidc';
import {provideObliqueConfiguration} from '@oblique/oblique';
export const authConfig: AuthConfig = {
issuer: 'https://auth.admin.ch/realms/federal',
redirectUri: window.location.origin + '/callback',
clientId: 'federal-portal-app',
responseType: 'code',
scope: 'openid profile email',
showDebugInformation: false,
sessionChecksEnabled: true,
timeoutFactor: 0.75,
clearHashAfterLogin: true,
nonceStateSeparator: 'semicolon',
requireHttps: true
};
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([]),
provideHttpClient(),
provideOAuthClient(),
provideObliqueConfiguration({
accessibilityStatement: {
applicationName: 'Secure Portal',
applicationOperator: 'Federal IT',
createdOn: new Date('2024-01-01'),
conformity: 'full',
contact: [{email: 'security@admin.ch'}]
}
})
]
};
// auth.service.ts
import {Injectable, inject} from '@angular/core';
import {Router} from '@angular/router';
import {OAuthService, AuthConfig} from 'angular-oauth2-oidc';
import {ObAuthenticationService, ObNotificationService} from '@oblique/oblique';
import {BehaviorSubject, Observable} from 'rxjs';
import {filter, map} from 'rxjs/operators';
import {authConfig} from './auth.config';
export interface UserProfile {
sub: string;
name: string;
email: string;
given_name: string;
family_name: string;
preferred_username: string;
roles: string[];
}
@Injectable({providedIn: 'root'})
export class AuthService {
private readonly obAuth = inject(ObAuthenticationService);
private readonly router = inject(Router);
private readonly notification = inject(ObNotificationService);
private readonly isAuthenticatedSubject = new BehaviorSubject(false);
public readonly isAuthenticated$: Observable = this.isAuthenticatedSubject.asObservable();
private readonly userProfileSubject = new BehaviorSubject(null);
public readonly userProfile$: Observable = this.userProfileSubject.asObservable();
constructor() {
// Configure OAuth
this.obAuth.oAuthService.configure(authConfig);
// Load discovery document
this.obAuth.oAuthService.loadDiscoveryDocumentAndTryLogin().then(() => {
if (this.obAuth.oAuthService.hasValidAccessToken()) {
this.onLoginSuccess();
}
});
// Monitor token events
this.obAuth.oAuthService.events
.pipe(filter(e => e.type === 'token_received'))
.subscribe(() => this.onLoginSuccess());
this.obAuth.oAuthService.events
.pipe(filter(e => e.type === 'token_expires'))
.subscribe(() => this.onTokenExpiring());
this.obAuth.oAuthService.events
.pipe(filter(e => e.type === 'session_terminated'))
.subscribe(() => this.onSessionTerminated());
}
login(returnUrl?: string): void {
// Store return URL in session storage
if (returnUrl) {
sessionStorage.setItem('auth_return_url', returnUrl);
}
// Initiate OAuth2 login flow
this.obAuth.performLogin(returnUrl);
}
logout(): void {
// Clear local data
this.isAuthenticatedSubject.next(false);
this.userProfileSubject.next(null);
sessionStorage.clear();
localStorage.removeItem('user_preferences');
// Perform OAuth logout
this.obAuth.performLogout();
this.notification.info({
title: 'Logged Out',
message: 'You have been successfully logged out.',
timeout: 3000
});
// Redirect to home
this.router.navigate(['/']);
}
refreshToken(): Promise {
return this.obAuth.oAuthService.refreshToken()
.then(() => {
console.log('Token refreshed successfully');
this.notification.success({
title: 'Session Refreshed',
message: 'Your session has been extended.',
timeout: 2000
});
})
.catch(error => {
console.error('Token refresh failed:', error);
this.notification.error({
title: 'Session Refresh Failed',
message: 'Please login again.',
sticky: true
});
this.logout();
});
}
getAccessToken(): string {
return this.obAuth.oAuthService.getAccessToken();
}
getIdToken(): string {
return this.obAuth.oAuthService.getIdToken();
}
getUserProfile(): UserProfile | null {
return this.userProfileSubject.value;
}
hasRole(role: string): boolean {
const profile = this.userProfileSubject.value;
return profile?.roles?.includes(role) ?? false;
}
hasAnyRole(roles: string[]): boolean {
return roles.some(role => this.hasRole(role));
}
isTokenExpired(): boolean {
return !this.obAuth.oAuthService.hasValidAccessToken();
}
private onLoginSuccess(): void {
const claims = this.obAuth.oAuthService.getIdentityClaims() as any;
if (claims) {
const profile: UserProfile = {
sub: claims.sub,
name: claims.name || '',
email: claims.email || '',
given_name: claims.given_name || '',
family_name: claims.family_name || '',
preferred_username: claims.preferred_username || '',
roles: claims.roles || []
};
this.userProfileSubject.next(profile);
this.isAuthenticatedSubject.next(true);
console.log('User authenticated:', profile);
this.notification.success({
title: 'Login Successful',
message: `Welcome back, ${profile.given_name}!`,
timeout: 3000
});
// Navigate to return URL or dashboard
const returnUrl = sessionStorage.getItem('auth_return_url') || '/dashboard';
sessionStorage.removeItem('auth_return_url');
this.router.navigate([returnUrl]);
}
}
private onTokenExpiring(): void {
this.notification.warning({
title: 'Session Expiring',
message: 'Your session will expire soon. Attempting to refresh...',
timeout: 5000
});
this.refreshToken();
}
private onSessionTerminated(): void {
this.notification.error({
title: 'Session Terminated',
message: 'Your session has ended. Please login again.',
sticky: true
});
this.isAuthenticatedSubject.next(false);
this.userProfileSubject.next(null);
this.router.navigate(['/login']);
}
}
// auth.guard.ts
import {inject} from '@angular/core';
import {Router, CanActivateFn, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router';
import {ObNotificationService} from '@oblique/oblique';
import {AuthService} from './auth.service';
export const authGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) => {
const authService = inject(AuthService);
const router = inject(Router);
const notification = inject(ObNotificationService);
if (authService.isTokenExpired()) {
notification.warning({
title: 'Authentication Required',
message: 'Please login to access this page.',
sticky: true
});
authService.login(state.url);
return false;
}
// Check role requirements
const requiredRoles = route.data['roles'] as string[];
if (requiredRoles && !authService.hasAnyRole(requiredRoles)) {
notification.error({
title: 'Access Denied',
message: 'You do not have permission to access this page.',
sticky: true
});
router.navigate(['/forbidden']);
return false;
}
return true;
};
// app.routes.ts
import {Routes} from '@angular/router';
import {authGuard} from './auth/auth.guard';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard]
},
{
path: 'admin',
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent),
canActivate: [authGuard],
data: {roles: ['admin', 'super_admin']}
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent),
canActivate: [authGuard],
data: {roles: ['analyst', 'admin', 'viewer']}
}
];
// Result:
// - OAuth2/OIDC authentication with authorization code flow
// - Automatic token refresh before expiration
// - User profile extracted from ID token claims
// - Role-based access control with route guards
// - Session monitoring with expiration warnings
// - Automatic redirect to login for protected routes
// - Return URL preserved through authentication flow
// - Token events monitored (received, expires, terminated)
// - Notifications for login/logout/refresh/errors
// - Secure storage of tokens in OAuth service
// - Support for multiple roles per user
// - Discovery document auto-loaded from issuer
```
---
## Global Events Service
Subscribe to centralized DOM events across the application.
```typescript
// click-tracker.component.ts
import {Component, inject, OnInit, OnDestroy} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ObGlobalEventsService} from '@oblique/oblique';
import {Subject} from 'rxjs';
import {takeUntil, throttleTime, debounceTime, filter} from 'rxjs/operators';
interface ClickEvent {
timestamp: Date;
x: number;
y: number;
target: string;
}
interface KeyEvent {
timestamp: Date;
key: string;
code: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
}
@Component({
selector: 'app-click-tracker',
standalone: true,
imports: [CommonModule],
template: `
Global Event Monitor
Total Clicks:
{{ clickCount }}
Mouse Position:
{{ mousePosition.x }}, {{ mousePosition.y }}
Scroll Position:
{{ scrollPosition }}px
Window Size:
{{ windowSize.width }} x {{ windowSize.height }}
Recent Click Events
-
{{ event.timestamp | date:'HH:mm:ss' }} -
({{ event.x }}, {{ event.y }}) -
{{ event.target }}
Recent Key Events
-
{{ event.timestamp | date:'HH:mm:ss' }} -
{{ event.key }}
+ Ctrl
+ Alt
+ Shift
Active Keyboard Shortcuts:
Ctrl+S: Save ({{ saveCount }} times)
Ctrl+K: Search ({{ searchCount }} times)
Escape: Clear ({{ escapeCount }} times)
`,
styles: [`
.event-tracker {
padding: 20px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat {
padding: 15px;
background: #f5f5f5;
border-radius: 4px;
border-left: 4px solid #2196f3;
}
.events-section {
margin: 20px 0;
}
.event-list {
max-height: 200px;
overflow-y: auto;
list-style: none;
padding: 0;
background: #fafafa;
border: 1px solid #ddd;
border-radius: 4px;
}
.event-list li {
padding: 8px 12px;
border-bottom: 1px solid #eee;
font-family: monospace;
font-size: 12px;
}
.shortcuts-info {
margin-top: 20px;
padding: 15px;
background: #e3f2fd;
border-radius: 4px;
}
`]
})
export class ClickTrackerComponent implements OnInit, OnDestroy {
private readonly globalEvents = inject(ObGlobalEventsService);
private readonly destroy$ = new Subject();
clickCount = 0;
saveCount = 0;
searchCount = 0;
escapeCount = 0;
mousePosition = {x: 0, y: 0};
scrollPosition = 0;
windowSize = {width: 0, height: 0};
recentClicks: ClickEvent[] = [];
recentKeys: KeyEvent[] = [];
ngOnInit(): void {
// Track all click events
this.globalEvents.click$
.pipe(takeUntil(this.destroy$))
.subscribe(event => {
this.clickCount++;
const clickEvent: ClickEvent = {
timestamp: new Date(),
x: event.clientX,
y: event.clientY,
target: (event.target as HTMLElement)?.tagName || 'unknown'
};
this.recentClicks.unshift(clickEvent);
if (this.recentClicks.length > 10) {
this.recentClicks.pop();
}
console.log('Click detected:', clickEvent);
});
// Track mouse movement (throttled to avoid performance issues)
this.globalEvents.mouseMove$
.pipe(
throttleTime(100), // Update max every 100ms
takeUntil(this.destroy$)
)
.subscribe(event => {
this.mousePosition = {
x: event.clientX,
y: event.clientY
};
});
// Track scroll position (throttled)
this.globalEvents.scroll$
.pipe(
throttleTime(100),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.scrollPosition = window.scrollY;
});
// Track window resize (debounced)
this.globalEvents.resize$
.pipe(
debounceTime(250), // Wait for resize to finish
takeUntil(this.destroy$)
)
.subscribe(() => {
this.windowSize = {
width: window.innerWidth,
height: window.innerHeight
};
});
// Initialize window size
this.windowSize = {
width: window.innerWidth,
height: window.innerHeight
};
// Track keyboard events
this.globalEvents.keyDown$
.pipe(takeUntil(this.destroy$))
.subscribe(event => {
const keyEvent: KeyEvent = {
timestamp: new Date(),
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey
};
this.recentKeys.unshift(keyEvent);
if (this.recentKeys.length > 10) {
this.recentKeys.pop();
}
});
// Keyboard shortcut: Ctrl+S (Save)
this.globalEvents.keyDown$
.pipe(
filter(event => event.ctrlKey && event.key === 's'),
takeUntil(this.destroy$)
)
.subscribe(event => {
event.preventDefault();
this.saveCount++;
console.log('Save shortcut triggered');
});
// Keyboard shortcut: Ctrl+K (Search)
this.globalEvents.keyDown$
.pipe(
filter(event => event.ctrlKey && event.key === 'k'),
takeUntil(this.destroy$)
)
.subscribe(event => {
event.preventDefault();
this.searchCount++;
console.log('Search shortcut triggered');
});
// Keyboard shortcut: Escape (Clear)
this.globalEvents.keyDown$
.pipe(
filter(event => event.key === 'Escape'),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.escapeCount++;
this.recentClicks = [];
this.recentKeys = [];
console.log('Clear shortcut triggered - events cleared');
});
// Track mousedown for drag detection
this.globalEvents.mouseDown$
.pipe(takeUntil(this.destroy$))
.subscribe(event => {
console.log('Mouse down at:', event.clientX, event.clientY);
});
// Track mouse wheel/scroll wheel
this.globalEvents.wheel$
.pipe(
throttleTime(100),
takeUntil(this.destroy$)
)
.subscribe(event => {
console.log('Wheel event detected');
});
// Track beforeUnload for unsaved changes warning
this.globalEvents.beforeUnload$
.pipe(takeUntil(this.destroy$))
.subscribe(event => {
console.log('Page about to unload - prompt user if needed');
// Uncomment to show browser warning:
// event.preventDefault();
// event.returnValue = '';
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
// Result:
// - All click events captured globally with coordinates and target element
// - Mouse position tracked continuously (throttled to 100ms)
// - Scroll position monitored (throttled to 100ms)
// - Window resize events captured (debounced to 250ms)
// - Recent 10 clicks and key presses displayed in lists
// - Global keyboard shortcuts: Ctrl+S (save), Ctrl+K (search), Escape (clear)
// - Keyboard modifiers tracked (Ctrl, Alt, Shift)
// - BeforeUnload event for unsaved changes warning
// - All events properly cleaned up on component destroy
// - Performance optimized with throttle/debounce operators
// - Observable streams available for: click$, mouseDown$, mouseMove$, keyDown$, keyUp$, scroll$, wheel$, resize$, beforeUnload$
```
---
## Unsaved Changes Guard
Prevent navigation away from forms with unsaved changes.
```typescript
// unsaved-changes.guard.ts
import {inject} from '@angular/core';
import {CanDeactivateFn} from '@angular/router';
import {ObUnsavedChangesService, ObIUnsavedChangesComponent} from '@oblique/oblique';
export const unsavedChangesGuard: CanDeactivateFn = (component) => {
const unsavedChangesService = inject(ObUnsavedChangesService);
// Check if component has unsaved changes
if (component.hasUnsavedChanges && component.hasUnsavedChanges()) {
// Service will show confirmation dialog
return unsavedChangesService.canDeactivate();
}
return true;
};
// employee-form.component.ts
import {Component, inject} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {Router} from '@angular/router';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatButtonModule} from '@angular/material/button';
import {
ObIUnsavedChangesComponent,
ObNotificationService,
ObUnsavedChangesModule
} from '@oblique/oblique';
@Component({
selector: 'app-employee-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
ObUnsavedChangesModule
],
template: `
`,
styles: [`
form {
max-width: 600px;
padding: 20px;
}
mat-form-field {
width: 100%;
margin-bottom: 15px;
}
.unsaved-warning {
padding: 10px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
margin-bottom: 20px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 10px;
}
.debug-info {
margin-top: 30px;
padding: 15px;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
}
`]
})
export class EmployeeFormComponent implements ObIUnsavedChangesComponent {
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly notification = inject(ObNotificationService);
employeeForm: FormGroup;
private initialFormValue: any;
constructor() {
this.employeeForm = this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
department: ['', Validators.required]
});
// Store initial form value
this.initialFormValue = this.employeeForm.value;
// Track form changes
this.employeeForm.valueChanges.subscribe(value => {
console.log('Form value changed:', value);
console.log('Has unsaved changes:', this.hasUnsavedChanges());
});
}
// Implementation of ObIUnsavedChangesComponent interface
hasUnsavedChanges(): boolean {
// Check if form is dirty and values have actually changed
if (!this.employeeForm.dirty) {
return false;
}
// Compare current values with initial values
const currentValue = this.employeeForm.value;
return JSON.stringify(currentValue) !== JSON.stringify(this.initialFormValue);
}
onSubmit(): void {
if (this.employeeForm.valid) {
const employeeData = this.employeeForm.value;
console.log('Saving employee:', employeeData);
// Simulate save operation
this.notification.success({
title: 'Employee Saved',
message: `${employeeData.firstName} ${employeeData.lastName} has been saved successfully.`,
timeout: 3000
});
// Update initial value after save
this.initialFormValue = this.employeeForm.value;
this.employeeForm.markAsPristine();
// Navigate away after save
setTimeout(() => {
this.router.navigate(['/employees']);
}, 1000);
}
}
reset(): void {
this.employeeForm.reset(this.initialFormValue);
this.employeeForm.markAsPristine();
this.notification.info({
title: 'Form Reset',
message: 'Form has been reset to initial values.',
timeout: 2000
});
}
navigateAway(): void {
// This will trigger the guard if there are unsaved changes
this.router.navigate(['/employees']);
}
}
// app.routes.ts
import {Routes} from '@angular/router';
import {unsavedChangesGuard} from './guards/unsaved-changes.guard';
import {EmployeeFormComponent} from './employee-form/employee-form.component';
export const routes: Routes = [
{
path: 'employee/new',
component: EmployeeFormComponent,
canDeactivate: [unsavedChangesGuard]
},
{
path: 'employee/:id/edit',
component: EmployeeFormComponent,
canDeactivate: [unsavedChangesGuard]
},
{
path: 'employees',
loadComponent: () => import('./employee-list/employee-list.component').then(m => m.EmployeeListComponent)
}
];
// Result:
// - Guard activated when user attempts to navigate away from form
// - Browser confirmation dialog appears: "You have unsaved changes. Do you really want to leave this page?"
// - User can choose "Leave" (discard changes) or "Stay" (continue editing)
// - hasUnsavedChanges() checks if form values differ from initial state
// - Guard respects pristine state (no warning for untouched forms)
// - Save button disabled when no changes or form invalid
// - After successful save, form marked as pristine and navigation proceeds
// - Works with browser back button, route links, and programmatic navigation
// - Visual warning displayed in form when unsaved changes present
// - Reset button restores initial values without triggering warning
// - Debug info shows form states for development
```
---
## Summary
Oblique is a comprehensive Angular framework that accelerates the development of Swiss federal web applications through standardized design patterns, pre-built components, and automated tooling. The framework eliminates the need for developers to build common infrastructure from scratch, providing enterprise-ready solutions for authentication, form validation, HTTP communication, internationalization, and responsive layouts. Its CLI tools streamline project creation and maintenance, while the extensive component library ensures visual consistency across all federal applications.
The framework excels in real-world scenarios requiring robust user interfaces with complex forms, multi-language support, role-based access control, and strict accessibility compliance. Integration patterns follow modern Angular best practices with standalone components, reactive programming using RxJS observables, and configuration via Angular's dependency injection system. The master layout component provides instant application structure, while specialized services like notification, spinner, and HTTP interceptor handle cross-cutting concerns automatically. With comprehensive TypeScript support, detailed schemas for JSON validation, and extensive customization options, Oblique enables development teams to focus on business logic rather than boilerplate infrastructure, significantly reducing time-to-market for federal web applications.