feat: add core storage and scroll position libraries with initial implementations and configurations

This commit is contained in:
Lorenz Hilpert
2025-03-13 21:22:43 +01:00
parent 39e4efff2b
commit 703090eabd
30 changed files with 564 additions and 42 deletions

View File

@@ -27,6 +27,7 @@ import {
} from './guards/activate-process-id.guard';
import { MatomoRouteData } from 'ngx-matomo-client';
import { processResolverFn } from '@isa/core/process';
import { provideScrollPositionRestoration } from '@isa/core/scroll-position';
const routes: Routes = [
{
@@ -176,5 +177,6 @@ if (isDevMode()) {
@NgModule({
imports: [RouterModule.forRoot(routes), TokenLoginModule],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,7 @@
# core-scroll-position
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test core-scroll-position` 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-scroll-position',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/core/scroll-position',
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-scroll-position",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/scroll-position/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/core/scroll-position/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/scroll-position-restoration';

View File

@@ -0,0 +1,63 @@
import { ViewportScroller } from '@angular/common';
import {
afterNextRender,
EnvironmentProviders,
inject,
Injector,
provideEnvironmentInitializer,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { SessionStorageProvider } from '@isa/core/storage';
// const route: Route = {
// component: AnyComponent,
// data: {
// scrollPositionRestoration: true
// }
// }
const getDeepestActivatedRoute = (route: ActivatedRoute): ActivatedRoute => {
while (route.firstChild) {
route = route.firstChild;
}
return route;
};
export function provideScrollPositionRestoration(): EnvironmentProviders {
return provideEnvironmentInitializer(() => {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
const url = router.url;
const route = getDeepestActivatedRoute(router.routerState.root);
if (route.snapshot.data?.['scrollPositionRestoration']) {
sessionStorage.set(url, viewportScroller.getScrollPosition());
}
}
});
});
}
export async function restoreScrollPosition() {
const injector = inject(Injector);
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
const url = router.url;
const position = await sessionStorage.get(url);
if (position) {
afterNextRender(
() => {
viewportScroller.scrollToPosition(position as [number, number]);
},
{ injector },
);
}
}

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 @@
# core-storage
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test core-storage` 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-storage',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/core/storage',
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-storage",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/storage/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/core/storage/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/idb.storage-provider';
export * from './lib/local.storage-provider';
export * from './lib/session.storage-provider';
export * from './lib/storage-provider';
export * from './lib/storage';

View File

@@ -0,0 +1,4 @@
export function hash(obj: object): string {
// TODO: Implement hash function
return JSON.stringify(obj);
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
const DB_NAME = 'storage';
@Injectable({ providedIn: 'root' })
export class IDBStorageProvider implements StorageProvider {
private db!: IDBDatabase;
private async openDB(): Promise<IDBDatabase> {
if (this.db) {
return this.db; // Datenbank bereits geöffnet, bestehende Verbindung zurückgeben
}
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('isa-cache', 1);
request.onerror = (event) => {
reject(event);
};
request.onupgradeneeded = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
if (!this.db.objectStoreNames.contains(DB_NAME)) {
this.db.createObjectStore(DB_NAME, { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve(this.db);
};
});
}
private async getObjectStore(mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
const db = await this.openDB();
const transaction = db.transaction(DB_NAME, mode);
return transaction.objectStore(DB_NAME);
}
async set(key: string, value: unknown): Promise<void> {
const store = await this.getObjectStore('readwrite');
return new Promise<void>((resolve, reject) => {
const request = store.put({ key, value });
request.onsuccess = () => resolve();
request.onerror = (event) => reject(event);
});
}
async get(key: string): Promise<unknown> {
const store = await this.getObjectStore();
return new Promise<unknown>((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => resolve(request.result?.value);
request.onerror = (event) => reject(event);
});
}
}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
@Injectable({ providedIn: 'root' })
export class LocalStorageProvider implements StorageProvider {
async set(key: string, value: unknown): Promise<void> {
localStorage.setItem(key, JSON.stringify(value));
}
async get(key: string): Promise<unknown> {
const data = localStorage.getItem(key);
if (data) {
return JSON.parse(data);
}
return data;
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
@Injectable({ providedIn: 'root' })
export class SessionStorageProvider implements StorageProvider {
async set(key: string, value: unknown): Promise<void> {
sessionStorage.setItem(key, JSON.stringify(value));
}
async get(key: string): Promise<unknown> {
const data = sessionStorage.getItem(key);
if (data) {
return JSON.parse(data);
}
return data;
}
}

View File

@@ -0,0 +1,5 @@
export interface StorageProvider {
set(key: string, value: unknown): Promise<void>;
get(key: string): Promise<unknown>;
}

View File

@@ -0,0 +1,49 @@
import { inject, Type } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { z } from 'zod';
import { hash } from './hash.utils';
export class Storage {
constructor(private storageProvider: StorageProvider) {}
set<T>(token: string | object, value: T): Promise<void> {
let key: string;
if (typeof token === 'string') {
key = token;
} else {
key = hash(token);
}
return this.storageProvider.set(key, value);
}
async get<T>(token: string | object, schema?: z.ZodType<T>): Promise<T | undefined> {
let key: string;
if (typeof token === 'string') {
key = token;
} else {
key = hash(token);
}
const data = await this.storageProvider.get(key);
if (schema) {
return await schema.parse(data);
}
return undefined;
}
}
const storageMap = new WeakMap<Type<StorageProvider>, Storage>();
export function storage(storageProvider: Type<StorageProvider>): Storage {
if (storageMap.has(storageProvider)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return storageMap.get(storageProvider)!;
}
const storage = new Storage(inject(storageProvider));
storageMap.set(storageProvider, storage);
return storage;
}

View File

@@ -0,0 +1,28 @@
import { inject, Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { UserStateService } from '@generated/swagger/isa-api';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserStorageProvider implements StorageProvider {
#userStateService = inject(UserStateService);
async set(key: string, value: unknown): Promise<void> {
const current = (await this.get(key)) || {};
firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: JSON.stringify({ ...current, [key]: value }),
}),
);
}
async get(key: string): Promise<unknown> {
const res = await firstValueFrom(this.#userStateService.UserStateGetUserState());
if (res.result?.content) {
return JSON.parse(res.result.content)[key];
}
return undefined;
}
}

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

@@ -1,5 +1,4 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
@@ -23,10 +22,8 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FilterService, SearchBarInputComponent } from '@isa/shared/filter';
import { IconButtonComponent } from '@isa/ui/buttons';
import { ViewportScroller } from '@angular/common';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, fromEvent, map } from 'rxjs';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { restoreScrollPosition } from '@isa/core/scroll-position';
type EmptyState = {
title: string;
@@ -50,7 +47,6 @@ type EmptyState = {
],
})
export class ResultsPageComponent {
#viewportScroller = inject(ViewportScroller);
#route = inject(ActivatedRoute);
#router = inject(Router);
@@ -87,16 +83,7 @@ export class ResultsPageComponent {
};
});
listElements =
viewChildren<QueryList<ReturnResultsItemListComponent>>('listElement');
scrollPosY = toSignal(
fromEvent(window, 'scroll').pipe(
debounceTime(100),
map(() => this.#viewportScroller.getScrollPosition()[1]),
),
{ initialValue: this.#viewportScroller.getScrollPosition()[1] },
);
listElements = viewChildren<QueryList<ReturnResultsItemListComponent>>('listElement');
searchEffectFn = () =>
effect(() => {
@@ -107,8 +94,7 @@ export class ResultsPageComponent {
if (processId) {
const entity = this._entity();
if (entity) {
const isPending =
this.entityStatus() === ReturnSearchStatus.Pending;
const isPending = this.entityStatus() === ReturnSearchStatus.Pending;
// Trigger reload search if no search request is already pending and
// 1. List scrolled to bottom
// 2. After Process change AND no items in entity
@@ -123,31 +109,9 @@ export class ResultsPageComponent {
});
});
saveScrollPosEffectFn = () =>
effect(() => {
const scrollPos = this.scrollPosY();
untracked(() => {
const processId = this._processId();
if (processId) {
console.log('scrollPos', scrollPos);
this._returnSearchStore.setScrollPos(processId, scrollPos);
}
});
});
applyScrollPosFn = () =>
afterNextRender(() => {
const entity = this._entity();
console.log('after next render scroll pos');
if (entity && entity.scrollPos) {
this.#viewportScroller.scrollToPosition([0, entity.scrollPos]);
}
});
constructor() {
this.applyScrollPosFn();
this.searchEffectFn();
this.saveScrollPosEffectFn();
restoreScrollPosition();
}
async onSearch() {

View File

@@ -12,8 +12,15 @@ export const routes: Routes = [
resolve: { querySettings: querySettingsResolverFn },
children: [
{ path: '', component: MainPageComponent },
{ path: 'results', component: ResultsPageComponent },
{ path: 'receipt/:receiptId', component: DetailsPageComponent },
{
path: 'results',
component: ResultsPageComponent,
data: { scrollPositionRestoration: true },
},
{
path: 'receipt/:receiptId',
component: DetailsPageComponent,
},
],
},
];

View File

@@ -48,6 +48,8 @@
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/process": ["libs/core/process/src/index.ts"],
"@isa/core/scanner": ["libs/core/scanner/src/index.ts"],
"@isa/core/scroll-position": ["libs/core/scroll-position/src/index.ts"],
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
"@isa/icons": ["libs/icons/src/index.ts"],
"@isa/oms/utils/translation": ["libs/oms/utils/translation/src/index.ts"],
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],