feat(core-connectivity): add network status service library

- Scaffold new core-connectivity library with Nx generator
- Migrate NetworkStatusService from isa-app to shared library
- Refactor service to use fromEvent/merge with shareReplay
- Add NetworkStatus type and injectNetworkStatus signal helper
- Update all consumers to use @isa/core/connectivity imports
- Add comprehensive unit tests (9 tests)
- Configure Vitest with JUnit/Cobertura reporters
- Update library-reference.md (74 → 75 libraries)
This commit is contained in:
Lorenz Hilpert
2025-12-02 17:26:25 +01:00
parent e5dd1e312d
commit 598a77b288
21 changed files with 381 additions and 40 deletions

View File

@@ -11,7 +11,7 @@ import { Config } from '@core/config';
import { ComponentPortal } from '@angular/cdk/portal';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from 'apps/isa-app/src/app/services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable()

View File

@@ -24,8 +24,7 @@ import { IsaLogProvider } from './providers';
import { EnvironmentService } from '@core/environment';
import { AuthService, LoginStrategy } from '@core/auth';
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
import { injectOnline$ } from './services/network-status.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectNetworkStatus } from '@isa/core/connectivity';
import { animate, style, transition, trigger } from '@angular/animations';
@Component({
@@ -50,7 +49,7 @@ import { animate, style, transition, trigger } from '@angular/animations';
export class AppComponent implements OnInit {
readonly injector = inject(Injector);
$online = toSignal(injectOnline$());
$networkStatus = injectNetworkStatus();
$offlineBannerVisible = signal(false);
@@ -59,7 +58,8 @@ export class AppComponent implements OnInit {
private onlineBannerDismissTimeout: any;
onlineEffects = effect(() => {
const online = this.$online();
const status = this.$networkStatus();
const online = status === 'online';
const offlineBannerVisible = this.$offlineBannerVisible();
untracked(() => {

View File

@@ -67,7 +67,7 @@ import {
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { NetworkStatusService } from '@isa/core/connectivity';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
@@ -106,7 +106,8 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
const status = await firstValueFrom(networkStatus.status$);
online = status === 'online';
if (!online) {
logger.warn('Waiting for network connection');

View File

@@ -5,7 +5,7 @@ import { ScanAdapterService } from '@adapter/scan';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { UiConfirmModalComponent, UiErrorModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })

View File

@@ -9,7 +9,7 @@ import {
import { from, NEVER, Observable, throwError } from 'rxjs';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { AuthService, LoginStrategy } from '@core/auth';
import { injectOnline$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { logger } from '@isa/core/logging';
@Injectable()
@@ -17,7 +17,7 @@ export class HttpErrorInterceptor implements HttpInterceptor {
#logger = logger(() => ({
'http-interceptor': 'HttpErrorInterceptor',
}));
#offline$ = injectOnline$().pipe(filter((online) => !online));
#offline$ = injectNetworkStatus$().pipe(filter((status) => status === 'offline'));
#injector = inject(Injector);
#auth = inject(AuthService);

View File

@@ -1 +0,0 @@
export * from './network-status.service';

View File

@@ -1,25 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
online$ = new Observable<boolean>((subscriber) => {
const handler = () => subscriber.next(navigator.onLine);
window.addEventListener('online', handler);
window.addEventListener('offline', handler);
handler();
return () => {
window.removeEventListener('online', handler);
window.removeEventListener('offline', handler);
};
});
status$ = this.online$.pipe(map((online) => (online ? 'online' : 'offline')));
}
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
export const injectOnline$ = () => inject(NetworkStatusService).online$;

View File

@@ -3,7 +3,7 @@ import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { EnvironmentService } from '@core/environment';
import { UiConfirmModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { injectNetworkStatus$ } from '../../app/services';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { AuthService } from './auth.service';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { firstValueFrom, lastValueFrom } from 'rxjs';

View File

@@ -3,9 +3,9 @@
> **Last Updated:** 2025-11-28
> **Angular Version:** 20.3.6
> **Nx Version:** 21.3.2
> **Total Libraries:** 74
> **Total Libraries:** 75
All 74 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 75 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -85,13 +85,18 @@ A comprehensive print management library for Angular applications providing prin
---
## Core Libraries (6 libraries)
## Core Libraries (7 libraries)
### `@isa/core/auth`
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
**Location:** `libs/core/auth/`
### `@isa/core/connectivity`
Network connectivity status service providing reactive online/offline observables for monitoring network state across the application.
**Location:** `libs/core/connectivity/`
### `@isa/core/config`
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.

View File

@@ -0,0 +1,7 @@
# core-connectivity
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test core-connectivity` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'core',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'core',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "core-connectivity",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/connectivity/src",
"prefix": "core",
"projectType": "library",
"tags": ["scope:core", "type:core"],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/connectivity"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,6 @@
export {
NetworkStatusService,
NetworkStatus,
injectNetworkStatus$,
injectNetworkStatus,
} from './lib/network-status.service';

View File

@@ -0,0 +1,137 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, take, toArray } from 'rxjs';
import {
NetworkStatusService,
injectNetworkStatus$,
injectNetworkStatus,
} from './network-status.service';
describe('NetworkStatusService', () => {
let service: NetworkStatusService;
let originalNavigatorOnLine: boolean;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NetworkStatusService);
originalNavigatorOnLine = navigator.onLine;
});
afterEach(() => {
Object.defineProperty(navigator, 'onLine', {
value: originalNavigatorOnLine,
writable: true,
configurable: true,
});
});
const setNavigatorOnLine = (value: boolean) => {
Object.defineProperty(navigator, 'onLine', {
value,
writable: true,
configurable: true,
});
};
describe('status$', () => {
it('should emit initial status based on navigator.onLine', async () => {
setNavigatorOnLine(true);
const newService = TestBed.inject(NetworkStatusService);
const status = await firstValueFrom(newService.status$);
expect(status).toBe('online');
});
it('should emit "offline" when navigator.onLine is false', async () => {
setNavigatorOnLine(false);
const status = await firstValueFrom(service.status$);
expect(status).toBe('offline');
});
it('should emit "online" when online event is dispatched', async () => {
setNavigatorOnLine(false);
const statusPromise = firstValueFrom(service.status$.pipe(take(2), toArray()));
setNavigatorOnLine(true);
window.dispatchEvent(new Event('online'));
const statuses = await statusPromise;
expect(statuses).toContain('online');
});
it('should emit "offline" when offline event is dispatched', async () => {
setNavigatorOnLine(true);
const statusPromise = firstValueFrom(service.status$.pipe(take(2), toArray()));
setNavigatorOnLine(false);
window.dispatchEvent(new Event('offline'));
const statuses = await statusPromise;
expect(statuses).toContain('offline');
});
it('should share the same observable instance (shareReplay)', async () => {
const subscription1Values: string[] = [];
const subscription2Values: string[] = [];
const sub1 = service.status$.subscribe((v) => subscription1Values.push(v));
const sub2 = service.status$.subscribe((v) => subscription2Values.push(v));
// Both subscribers should receive the same initial value
expect(subscription1Values.length).toBeGreaterThan(0);
expect(subscription2Values.length).toBeGreaterThan(0);
expect(subscription1Values[0]).toBe(subscription2Values[0]);
sub1.unsubscribe();
sub2.unsubscribe();
});
it('should replay last value to new subscribers', async () => {
// First subscriber gets the value
const firstValue = await firstValueFrom(service.status$);
// Second subscriber should get the same replayed value
const secondValue = await firstValueFrom(service.status$);
expect(firstValue).toBe(secondValue);
});
});
describe('injectNetworkStatus$', () => {
it('should return status$ observable from service', () => {
TestBed.runInInjectionContext(() => {
const status$ = injectNetworkStatus$();
expect(status$).toBe(service.status$);
});
});
});
describe('injectNetworkStatus', () => {
it('should return a signal with current network status', () => {
setNavigatorOnLine(true);
TestBed.runInInjectionContext(() => {
const statusSignal = injectNetworkStatus();
expect(statusSignal()).toBe('online');
});
});
it('should return a signal that reflects offline status', () => {
setNavigatorOnLine(false);
TestBed.runInInjectionContext(() => {
const statusSignal = injectNetworkStatus();
expect(statusSignal()).toBe('offline');
});
});
});
});

View File

@@ -0,0 +1,28 @@
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
map,
Observable,
fromEvent,
merge,
startWith,
shareReplay,
} from 'rxjs';
export type NetworkStatus = 'online' | 'offline';
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
readonly status$: Observable<NetworkStatus> = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null), // emit immediately
map((): NetworkStatus => (navigator.onLine ? 'online' : 'offline')),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
export const injectNetworkStatus = () => toSignal(injectNetworkStatus$());

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/core/connectivity',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-core-connectivity.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/core/connectivity',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -70,6 +70,7 @@
],
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/connectivity": ["libs/core/connectivity/src/index.ts"],
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
"@isa/core/navigation": ["libs/core/navigation/src/index.ts"],
"@isa/core/storage": ["libs/core/storage/src/index.ts"],