feat: add core-config and shared-product-image libraries; implement initial structure and configuration

This commit is contained in:
Lorenz Hilpert
2025-03-05 19:47:27 +01:00
parent ce9bc9511a
commit 76aa04bc4c
36 changed files with 393 additions and 299 deletions

View File

@@ -1,10 +1,17 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ErrorHandler, Injector, LOCALE_ID, NgModule, inject, provideAppInitializer } from '@angular/core';
import {
ErrorHandler,
Injector,
LOCALE_ID,
NgModule,
inject,
provideAppInitializer,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PlatformModule } from '@angular/cdk/platform';
import { Config, ConfigModule, JsonConfigLoader } from '@core/config';
import { Config } from '@core/config';
import { AuthModule, AuthService, LoginStrategy } from '@core/auth';
import { CoreCommandModule } from '@core/command';
@@ -66,7 +73,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
}
statusElement.innerHTML = 'Konfigurationen werden geladen...';
await config.init();
statusElement.innerHTML = 'Scanner wird initialisiert...';
const scanAdapter = injector.get(ScanAdapterService);
@@ -120,7 +126,10 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
};
}
export function _notificationsHubOptionsFactory(config: Config, auth: AuthService): SignalRHubOptions {
export function _notificationsHubOptionsFactory(
config: Config,
auth: AuthService,
): SignalRHubOptions {
const options = { ...config.get('hubs').notifications };
options.httpOptions.accessTokenFactory = () => auth.getToken();
return options;
@@ -137,10 +146,6 @@ export function _notificationsHubOptionsFactory(config: Config, auth: AuthServic
AppSwaggerModule,
AppDomainModule,
CoreBreadcrumbModule.forRoot(),
ConfigModule.forRoot({
useConfigLoader: JsonConfigLoader,
jsonConfigLoaderUrl: '/config/config.json',
}),
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
@@ -186,7 +191,11 @@ export function _notificationsHubOptionsFactory(config: Config, auth: AuthServic
},
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptorsFromDi()),
provideMatomo({ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' }, withRouter(), withRouteData()),
provideMatomo(
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
withRouter(),
withRouteData(),
),
],
})
export class AppModule {}

View File

@@ -1,8 +0,0 @@
import { Observable } from 'rxjs';
/**
* Config loader interface for loading configurations
*/
export interface ConfigLoader {
load(): Promise<any>;
}

View File

@@ -1,4 +0,0 @@
// start:ng42.barrel
export * from './config-loader';
export * from './json.config-loader';
// end:ng42.barrel

View File

@@ -1,36 +0,0 @@
// // unit test JsonConfigLoader
// import { HttpTestingController } from '@angular/common/http/testing';
// import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
// import { CORE_JSON_CONFIG_LOADER_URL } from '../tokens';
// import { JsonConfigLoader } from './json.config-loader';
// describe('JsonConfigLoader', () => {
// let spectator: SpectatorService<JsonConfigLoader>;
// const createService = createServiceFactory({
// imports: [HttpClientTestingModule],
// service: JsonConfigLoader,
// mocks: [],
// providers: [{ provide: CORE_JSON_CONFIG_LOADER_URL, useValue: '/assets/config.json' }],
// });
// let httpTestingController: HttpTestingController;
// beforeEach(() => {
// spectator = createService();
// httpTestingController = spectator.inject(HttpTestingController);
// });
// it('should create', () => {
// expect(spectator.service).toBeTruthy();
// });
// describe('load', () => {
// it('should call the provided url', async () => {
// const reqPromise = spectator.service.load();
// const req = httpTestingController.expectOne('/assets/config.json');
// req.flush({ unit: 'test' });
// const result = await reqPromise;
// httpTestingController.verify();
// expect(result).toEqual({ unit: 'test' });
// });
// });
// });

View File

@@ -1,16 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { ConfigLoader } from './config-loader';
import { CORE_JSON_CONFIG_LOADER_URL } from '../tokens';
@Injectable()
export class JsonConfigLoader implements ConfigLoader {
constructor(
@Inject(CORE_JSON_CONFIG_LOADER_URL) private url: string,
private http: HttpClient,
) {}
load(): Promise<any> {
return this.http.get(this.url).toPromise();
}
}

View File

@@ -1,7 +0,0 @@
import { Type } from '@angular/core';
import { ConfigLoader } from './config-loaders';
export interface ConfigModuleOptions {
useConfigLoader: Type<ConfigLoader>;
jsonConfigLoaderUrl?: string;
}

View File

@@ -1,30 +0,0 @@
import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core';
import { CORE_CONFIG_LOADER } from '@core/config';
import { Config } from './config';
import { ConfigModuleOptions } from './config-module-options';
import { CORE_JSON_CONFIG_LOADER_URL } from './tokens';
export function _initializeConfigFactory(config: Config) {
return () => config.init();
}
@NgModule({})
export class ConfigModule {
static forRoot(options: ConfigModuleOptions): ModuleWithProviders<ConfigModule> {
const configLoaderProvider = {
provide: CORE_CONFIG_LOADER,
useClass: options.useConfigLoader,
};
return {
ngModule: ConfigModule,
providers: [
Config,
configLoaderProvider,
options.jsonConfigLoaderUrl
? { provide: CORE_JSON_CONFIG_LOADER_URL, useValue: options.jsonConfigLoaderUrl }
: null,
],
};
}
}

View File

@@ -1,45 +0,0 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { Config } from './config';
import { ConfigLoader } from './config-loaders';
import { CORE_CONFIG_LOADER } from './tokens';
class TestConfigLoader implements ConfigLoader {
load() {
return Promise.resolve({});
}
}
// Unit test Config
describe('Config', () => {
let spectator: SpectatorService<Config>;
const createService = createServiceFactory({
service: Config,
providers: [{ provide: CORE_CONFIG_LOADER, useClass: TestConfigLoader }],
});
let configLoader: ConfigLoader;
beforeEach(() => {
spectator = createService();
configLoader = spectator.inject(CORE_CONFIG_LOADER);
});
it('should create', () => {
expect(spectator.service).toBeTruthy();
});
describe('init()', () => {
it('should load config and assigns it to _config', async () => {
const config = { unit: 'test' };
spyOn(configLoader, 'load').and.returnValue(Promise.resolve(config));
await spectator.service.init();
expect(spectator.service['_config']).toEqual(config);
});
});
describe('get()', () => {
it('should return config value', () => {
spectator.service['_config'] = { test: 'test' };
expect(spectator.service.get('test')).toEqual('test');
});
});
});

View File

@@ -1,27 +0,0 @@
import { Inject, Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { ConfigLoader } from './config-loaders';
import { CORE_CONFIG_LOADER } from './tokens';
import { pick } from './utils';
@Injectable()
export class Config {
private _config: any;
private readonly _initilized = new ReplaySubject<void>(1);
get initialized() {
return this._initilized.asObservable();
}
constructor(@Inject(CORE_CONFIG_LOADER) private readonly _configLoader: ConfigLoader) {}
// load config and assign it to this._config
async init() {
this._config = await this._configLoader.load();
this._initilized.next();
}
get(path: string) {
return pick(path, this._config);
}
}

View File

@@ -1,6 +0,0 @@
export * from './config-loaders';
export * from './config-module-options';
export * from './config.module';
export * from './config';
export * from './tokens';
export * from './utils';

View File

@@ -1,6 +0,0 @@
import { InjectionToken } from '@angular/core';
import { ConfigLoader } from './config-loaders';
export const CORE_CONFIG_LOADER = new InjectionToken<ConfigLoader>('core.config.loader');
export const CORE_JSON_CONFIG_LOADER_URL = new InjectionToken<ConfigLoader>('core.json.config.loader.url');

View File

@@ -1,3 +0,0 @@
// start:ng42.barrel
export * from './pick';
// end:ng42.barrel

View File

@@ -1,41 +0,0 @@
import { pick } from './pick';
describe('pick', () => {
it('should pick properties from the 1st level from the object', () => {
const obj = {
foo: 'bar',
};
expect(pick('foo', obj)).toEqual('bar');
});
it('should pick properties from the 2nd level from the object', () => {
const obj = {
foo: {
bar: 'baz',
},
};
expect(pick('foo.bar', obj)).toEqual('baz');
});
it('should pick properties from the 3rd level from the object', () => {
const obj = {
foo: {
bar: {
baz: 'qux',
},
},
};
expect(pick('foo.bar.baz', obj)).toEqual('qux');
});
it('should throw an error of obj is not an object', () => {
expect(() => pick('foo', 'bar')).toThrowError(`bar is not an object`);
});
it('should return undefined if the property is not found', () => {
const obj = {
foo: 'bar',
};
expect(pick('bar', obj)).toEqual(undefined);
});
});

View File

@@ -1,33 +0,0 @@
/**
* Pick a value from an object at a given path.
* @param path path of the value to pick
* @param obj object to pick from
* @returns the value at the path or undefined
* @throws if obj is not an object
*/
export function pick<T = any>(path: string, obj: object): T {
const paths = path.split('.');
// check if obj is null or undefined
if (obj == null) {
return undefined;
}
// check if obj is of type object and not an array
// and throw an error if not
if (typeof obj !== 'object' || Array.isArray(obj)) {
throw new Error(`${obj} is not an object`);
}
let result = obj;
// loop through the path and pick the value
// early exit if the path is empty
for (const path of paths) {
result = result[path];
if (result == null) {
return undefined;
}
}
return result as T;
}

View File

@@ -1,42 +1,27 @@
import { enableProdMode } from '@angular/core';
import { enableProdMode, isDevMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { CONFIG_DATA } from '@isa/core/config';
import * as moment from 'moment';
moment.locale('de');
import { AppModule } from './app/app.module';
import { DebugService } from './app/debug/debug.service';
import { environment } from './environments/environment';
if (environment.production) {
if (!isDevMode()) {
enableProdMode();
}
const debugService = new DebugService();
async function bootstrap() {
const configRes = await fetch('/config/config.json');
if (environment.debug) {
const consoleLog = console.log;
const config = await configRes.json();
console.log = (...args) => {
debugService.add({ type: 'log', args });
consoleLog(...args);
};
const consoleWarn = console.warn;
console.warn = (...args) => {
debugService.add({ type: 'warn', args });
consoleWarn(...args);
};
const consoleError = console.error;
console.error = (...args) => {
debugService.add({ type: 'error', args });
consoleError(...args);
};
platformBrowserDynamic([{ provide: CONFIG_DATA, useValue: config }]).bootstrapModule(AppModule);
}
try {
bootstrap();
} catch (error) {
console.error(error);
}
platformBrowserDynamic([{ provide: DebugService, useValue: debugService }])
.bootstrapModule(AppModule)
.catch((err) => console.error(err));

View File

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

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'core-config',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/core/config',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "core-config",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/config/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/core/config/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/config';

View File

@@ -0,0 +1,33 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { z } from 'zod';
import { coerceArray } from '@angular/cdk/coercion';
type JsonPrimitive = string | number | boolean | null | undefined;
export type JsonValue = Array<JsonPrimitive> | Record<string, JsonPrimitive> | JsonPrimitive;
export const CONFIG_DATA = new InjectionToken<JsonValue>('ConfigData');
@Injectable({ providedIn: 'root' })
export class Config {
#config = inject(CONFIG_DATA);
get(path: string | string[]): any;
get<TOut>(path: string | string[], zSchema: z.ZodSchema<TOut>): TOut;
get<TOut>(path: string | string[], zSchema?: z.ZodSchema<TOut>): TOut | any {
let result: JsonValue = this.#config;
for (const p of coerceArray(path)) {
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
result = (result as Record<string, JsonPrimitive>)[p];
} else {
return undefined;
}
if (result === null || result === undefined) {
return undefined;
}
}
return zSchema ? zSchema.parse(result) : result;
}
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"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"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

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

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'shared',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shared',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'shared-product-image',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/product-image',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "shared-product-image",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/product-image/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/product-image/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/product-image.service';

View File

@@ -0,0 +1,26 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
export const PRODUCT_IMAGE_URL = new InjectionToken<string>('PRODUCT_IMAGE_URL', {
factory: () => inject(Config).get('@cdn/product-image.url', z.string().url()),
});
@Injectable({ providedIn: 'root' })
export class ProductImageService {
readonly imageUrl = inject(PRODUCT_IMAGE_URL);
getImageUrl({
imageId,
width = 150,
height = 150,
showDummy = true,
}: {
imageId: string;
width?: number;
height?: number;
showDummy?: boolean;
}): string {
return `${this.imageUrl}/${imageId}_${width}x${height}.jpg?showDummy=${showDummy}`;
}
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"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"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -43,9 +43,12 @@
],
"@generated/swagger/wws-api": ["generated/swagger/wws-api/src/index.ts"],
"@hub/*": ["apps/isa-app/src/hub/*/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@core/config": ["libs/core/config/src/index.ts"], // fallback for old imports
"@isa/core/process": ["libs/core/process/src/index.ts"],
"@isa/icons": ["libs/icons/src/index.ts"],
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
"@isa/shared/product-image": ["libs/shared/product-image/src/index.ts"],
"@isa/ui/buttons": ["libs/ui/buttons/src/index.ts"],
"@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"],
"@isa/ui/search-bar": ["libs/ui/search-bar/src/index.ts"],