# 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: `

Employee Registration

Email Address Email address is required Please enter a valid email address Email must match format: name@domain.com First Name First name is required First name must be at least 2 characters Last Name Last name is required Last name must be at least 2 characters Age Age is required Employee must be at least 18 years old Age cannot exceed 70 years Department Engineering Human Resources Finance Marketing Department selection is required Please select a valid department Annual Salary (CHF) Salary is required Salary must be at least CHF 40,000 Salary must be in increments of CHF 1,000
` }) 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: `

Employee Search

Selected Employee

Name: {{ selectedEmployee.label }}

ID: {{ selectedEmployee.value }}

Department: {{ getEmployeeById(selectedEmployee.value)?.department }}

Email: {{ getEmployeeById(selectedEmployee.value)?.email }}

`, 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: `

User Data

  • {{ user.name }}

Product Data

  • {{ product.name }}

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: `

Employee Form

You have unsaved changes. Please save or discard before leaving.

First Name Last Name Email Department

Debug Information:

Form Valid: {{ employeeForm.valid }}

Form Dirty: {{ employeeForm.dirty }}

Form Touched: {{ employeeForm.touched }}

Has Unsaved Changes: {{ hasUnsavedChanges() }}

`, 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.