feat: add pagination support to query token schema with skip and take fields

feat: enhance return search component to handle search callbacks and update query parameters

fix: update return search result component template to use new search method and improve loading states

refactor: streamline return search result component logic and improve state management

feat: implement scroll position restoration in return search feature

feat: introduce filter service enhancements for query settings and synchronization with URL parameters

chore: create utils for scroll position management and viewport detection

fix: update filter service to use new input and query settings types

chore: add tests and configurations for new utils library

ref: #5033
This commit is contained in:
Lorenz Hilpert
2025-04-07 18:23:43 +02:00
parent 9950c76482
commit 492dae14f7
34 changed files with 421 additions and 359 deletions

View File

@@ -27,7 +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';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },

View File

@@ -12,3 +12,7 @@ export interface Result<T> {
data: T;
error?: unknown;
}
export type CallbackResult<T> = { data: T; error?: unknown } | { data?: T; error: unknown };
export type Callback<T> = (result: CallbackResult<T>) => void;

View File

@@ -1,7 +0,0 @@
# 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

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

View File

@@ -1,11 +1,5 @@
import { patchState, signalStore, type, withMethods } from '@ngrx/signals';
import {
addEntity,
entityConfig,
setEntity,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { Result, ResultStatus } from '@isa/common/result';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
@@ -41,7 +35,8 @@ export const ReturnDetailsStore = signalStore(
* @returns The updated or newly created entity.
*/
beforeFetch(receiptId: number) {
let entity: ReturnResult | undefined = store.entityMap()[receiptId];
// Using optional chaining to safely retrieve the entity from the map
let entity: ReturnResult | undefined = store.entityMap()?.[receiptId];
if (!entity) {
entity = { ...initialEntity, id: receiptId, status: ResultStatus.Pending };
patchState(store, addEntity(entity));

View File

@@ -136,42 +136,4 @@ describe('ReturnSearchStore', () => {
expect(entity?.error).toBe(error);
});
});
describe('reload', () => {
it('should append new results to the existing items', () => {
const spectator = createService();
const initialResponse: ListResponseArgs<ReceiptListItem> = {
hits: 2,
skip: 0,
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 1 }],
};
const reloadResponse: ListResponseArgs<ReceiptListItem> = {
hits: 3,
skip: 0,
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 2 }],
};
const returnSearchService = spectator.inject(ReturnSearchService);
returnSearchService.search
.mockReturnValueOnce(of(initialResponse))
.mockReturnValueOnce(of(reloadResponse));
spectator.service.search({
processId: 1,
query: { filter: {}, input: {}, orderBy: [], skip: 0, take: 5 },
});
spectator.service.reload({
processId: 1,
query: { filter: {}, input: {}, orderBy: [], skip: 5, take: 5 },
});
const entity = spectator.service.getEntity(1);
expect(entity?.items).toEqual([{ id: 1 }, { id: 2 }]);
});
});
});

View File

@@ -6,9 +6,10 @@ import { ReturnSearchService } from './return-search.service';
import { tapResponse } from '@ngrx/operators';
import { inject } from '@angular/core';
import { QueryTokenSchema } from './schemas';
import { ListResponseArgs } from '@isa/common/result';
import { Callback, ListResponseArgs } from '@isa/common/result';
import { ReceiptListItem } from './models';
import { Query } from '@isa/shared/filter';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
/**
* Enum representing the status of a return search process.
@@ -41,6 +42,7 @@ const config = entityConfig({
*/
export const ReturnSearchStore = signalStore(
{ providedIn: 'root' },
withStorage('oms-data-access.return-search-store', SessionStorageProvider),
withEntities<ReturnSearchEntity>(config),
withMethods((store) => ({
/**
@@ -58,10 +60,17 @@ export const ReturnSearchStore = signalStore(
* Prepares the store state before initiating a search operation.
*
* @param {number} processId - The unique identifier of the search process.
* @param {boolean} [clear=true] - Flag indicating whether to clear existing items.
*/
beforeSearch(processId: number) {
beforeSearch(processId: number, clear = true) {
const entity = store.getEntity(processId);
if (entity) {
let items = entity.items ?? [];
if (clear) {
items = [];
}
patchState(
store,
updateEntity(
@@ -69,7 +78,7 @@ export const ReturnSearchStore = signalStore(
id: processId,
changes: {
status: ReturnSearchStatus.Pending,
items: [],
items,
hits: 0,
},
},
@@ -85,26 +94,6 @@ export const ReturnSearchStore = signalStore(
}
},
/**
* Prepares the store state before reloading search results.
*
* @param {number} processId - The unique identifier of the search process.
*/
beforeReload(processId: number) {
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Pending,
},
},
config,
),
);
},
/**
* Handles the success response of a search operation.
*
@@ -118,36 +107,6 @@ export const ReturnSearchStore = signalStore(
}: {
processId: number;
response: ListResponseArgs<ReceiptListItem>;
}) {
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Success,
hits: response.hits,
items: response.result,
},
},
config,
),
);
},
/**
* Handles the success response of a reload operation.
*
* @param {Object} options - Options for handling the success response.
* @param {number} options.processId - The unique identifier of the search process.
* @param {ListResponseArgs<ReceiptListItem>} options.response - The reload response.
*/
handleReloadSuccess({
processId,
response,
}: {
processId: number;
response: ListResponseArgs<ReceiptListItem>;
}) {
const entityItems = store.getEntity(processId)?.items;
patchState(
@@ -164,6 +123,8 @@ export const ReturnSearchStore = signalStore(
config,
),
);
store.storeState();
},
/**
@@ -191,30 +152,6 @@ export const ReturnSearchStore = signalStore(
),
);
},
/**
* Handles errors encountered during a reload operation.
*
* @param {Object} options - Options for handling the error.
* @param {number} options.processId - The unique identifier of the search process.
* @param {unknown} options.error - The error encountered.
*/
handleReloadError({ processId, error }: { processId: number; error: unknown }) {
console.error(error);
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Error,
error,
},
},
config,
),
);
},
})),
withMethods((store, returnSearchService = inject(ReturnSearchService)) => ({
/**
@@ -222,12 +159,18 @@ export const ReturnSearchStore = signalStore(
*
* @param {Object} options - Options for the search operation.
* @param {number} options.processId - The unique identifier of the search process.
* @param {Query} options.query - The search query parameters.
* @param {Callback<ListResponseArgs<ReceiptListItem>>} [options.cb] - Optional callback for handling the response.
* @param {Record<string, string>} options.params - Search parameters.
*/
search: rxMethod<{ processId: number; query: Query }>(
search: rxMethod<{
processId: number;
query: Query;
cb?: Callback<ListResponseArgs<ReceiptListItem>>;
}>(
pipe(
tap(({ processId }) => store.beforeSearch(processId)),
switchMap(({ processId, query }) =>
tap(({ processId, query }) => store.beforeSearch(processId, !!query.skip)),
switchMap(({ processId, query, cb }) =>
returnSearchService
.search(
QueryTokenSchema.parse(
@@ -239,36 +182,14 @@ export const ReturnSearchStore = signalStore(
)
.pipe(
tapResponse(
(response) => store.handleSearchSuccess({ processId, response }),
(error) => store.handleSearchError({ processId, error }),
),
),
),
),
),
/**
* Initiates a reload operation to fetch additional search results.
*
* @param {Object} options - Options for the reload operation.
* @param {number} options.processId - The unique identifier of the search process.
* @param {Record<string, string>} options.params - Search parameters.
*/
reload: rxMethod<{ processId: number; query: Query }>(
pipe(
tap(({ processId }) => store.beforeReload(processId)),
switchMap(({ processId, query }) =>
returnSearchService
.search(
QueryTokenSchema.parse({
...query,
skip: store.getEntity(processId)?.items?.length ?? 0,
}),
)
.pipe(
tapResponse(
(response) => store.handleReloadSuccess({ processId, response }),
(error) => store.handleReloadError({ processId, error }),
(response) => {
store.handleSearchSuccess({ processId, response });
cb?.({ data: response });
},
(error) => {
store.handleSearchError({ processId, error });
cb?.({ error });
},
),
),
),

View File

@@ -11,6 +11,8 @@ export const QueryTokenSchema = z.object({
filter: z.record(z.any()).default({}),
input: z.record(z.any()).default({}),
orderBy: z.array(OrderBySchema).default([]),
skip: z.number().default(0),
take: z.number().default(25),
});
export type QueryTokenInput = z.infer<typeof QueryTokenSchema>;

View File

@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CallbackResult, ListResponseArgs } from '@isa/common/result';
import { injectActivatedProcessId } from '@isa/core/process';
import { ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import {
FilterService,
SearchBarInputComponent,
@@ -44,19 +45,29 @@ export class ReturnSearchMainComponent {
async onSearch() {
const processId = this._processId();
if (processId) {
await this._updateQueryParams();
this._filterService.commit();
this._returnSearchStore.search({
processId,
query: this._filterService.query(),
cb: this.onSearchCb,
});
}
}
private async _updateQueryParams() {
await this.#router.navigate([], {
queryParams: this._filterService.queryParams(),
onSearchCb = ({ data }: CallbackResult<ListResponseArgs<ReceiptListItem>>) => {
if (data) {
if (data.result.length === 1) {
this.navigate(['receipt', data.result[0].id]);
} else if (data.result.length > 1) {
this.navigate(['receipts']);
}
}
};
navigate(path: (string | number)[]) {
this.#router.navigate(path, {
relativeTo: this.#route,
replaceUrl: true,
queryParams: this._filterService.queryParams(),
});
}
}

View File

@@ -3,12 +3,12 @@
class="flex flex-row gap-4 h-12"
[appearance]="'results'"
inputKey="qs"
(triggerSearch)="onSearch()"
(triggerSearch)="search()"
></filter-search-bar-input>
<div class="flex flex-row gap-4 items-center">
<filter-filter-menu-button
(applied)="onSearch()"
(applied)="search()"
[rollbackOnClose]="true"
></filter-filter-menu-button>
@@ -18,7 +18,7 @@
<filter-order-by-toolbar
*uiBreakpoint="['desktop', 'dekstop-l', 'dekstop-xl']"
(toggled)="onSearch()"
(toggled)="search()"
></filter-order-by-toolbar>
</div>
</div>
@@ -27,7 +27,7 @@
<filter-order-by-toolbar
*uiBreakpoint="['tablet']"
class="w-full"
(toggled)="onSearch()"
(toggled)="search()"
></filter-order-by-toolbar>
}
@@ -35,10 +35,9 @@
{{ entityHits() }} Einträge
</span>
@let items = entityItems();
@if (items.length > 0) {
@if (renderItemList()) {
<div class="flex flex-col gap-4 w-full items-center justify-center">
@for (item of items; track item.id) {
@for (item of entityItems(); track item.id) {
@defer (on viewport) {
<a [routerLink]="['../', 'receipt', item.id]" class="w-full">
<oms-feature-return-search-result-item
@@ -53,17 +52,22 @@
</div>
}
}
@if (entityStatus() === ReturnSearchStatus.Pending) {
@if (renderPagingLoader()) {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
</div>
} @else if (renderPageTrigger()) {
<div (utilScrolledIntoViewport)="paging($event)"></div>
}
</div>
} @else if (items.length === 0 && entityStatus() === ReturnSearchStatus.Pending) {
} @else if (renderSearchLoader()) {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
</div>
} @else if (entityStatus() !== ReturnSearchStatus.Idle) {
<ui-empty-state [title]="emptyState().title" [description]="emptyState().description">
} @else {
<ui-empty-state
title="Keine Suchergebnisse"
description="Suchen Sie nach einer Rechnungsnummer oder Kundennamen."
>
</ui-empty-state>
}

View File

@@ -1,13 +1,10 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
QueryList,
signal,
untracked,
viewChildren,
} from '@angular/core';
import { injectActivatedProcessId } from '@isa/core/process';
@@ -21,17 +18,16 @@ import {
import { IconButtonComponent } from '@isa/ui/buttons';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { restoreScrollPosition } from '@isa/core/scroll-position';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionSort } from '@isa/icons';
import { ReturnSearchEntity, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import { ReturnSearchResultItemComponent } from './return-search-result-item/return-search-result-item.component';
import { BreakpointDirective } from '@isa/ui/layout';
type EmptyState = {
title: string;
description: string;
};
import { CallbackResult, ListResponseArgs } from '@isa/common/result';
import {
injectRestoreScrollPosition,
ScrolledIntoViewportDirective,
} from '@isa/utils/scroll-position';
@Component({
selector: 'oms-feature-return-search-result',
@@ -48,129 +44,109 @@ type EmptyState = {
NgIconComponent,
FilterMenuButtonComponent,
BreakpointDirective,
ScrolledIntoViewportDirective,
],
providers: [provideIcons({ isaActionSort })],
})
export class ReturnSearchResultComponent {
export class ReturnSearchResultComponent implements AfterViewInit {
#route = inject(ActivatedRoute);
#router = inject(Router);
#filterService = inject(FilterService);
private _processId = injectActivatedProcessId();
private _returnSearchStore = inject(ReturnSearchStore);
private _filterService = inject(FilterService);
restoreScrollPosition = injectRestoreScrollPosition();
processId = injectActivatedProcessId();
returnSearchStore = inject(ReturnSearchStore);
orderByVisible = signal(false);
ReturnSearchStatus = ReturnSearchStatus;
filterInputs = computed(() =>
this._filterService.inputs().filter((input) => input.group === 'filter'),
);
private _entity = computed(() => {
const processId = this._processId();
entity = computed(() => {
const processId = this.processId();
if (processId) {
return this._returnSearchStore.getEntity(processId);
return this.returnSearchStore.getEntity(processId);
}
return undefined;
});
entityItems = computed(() => {
return this._entity()?.items ?? [];
return this.entity()?.items ?? [];
});
entityHits = computed(() => {
return this._entity()?.hits ?? 0;
return this.entity()?.hits ?? 0;
});
entityStatus = computed(() => {
return this._entity()?.status ?? ReturnSearchStatus.Idle;
return this.entity()?.status ?? ReturnSearchStatus.Idle;
});
emptyState = computed<EmptyState>(() => {
return {
title: 'Keine Suchergebnisse',
description: 'Suchen Sie nach einer Rechnungsnummer oder Kundennamen.',
};
renderItemList = computed(() => {
return this.entityItems().length;
});
listElements = viewChildren<QueryList<ReturnSearchResultItemComponent>>('listElement');
renderPagingLoader = computed(() => {
return this.entityStatus() === ReturnSearchStatus.Pending;
});
searchEffectFn = () =>
effect(() => {
const processId = this._processId();
const listLength = this.listElements().length;
renderSearchLoader = computed(() => {
return this.entityStatus() === ReturnSearchStatus.Pending && this.entityItems().length === 0;
});
untracked(async () => {
if (processId) {
const entity = this._entity();
if (entity) {
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
if (!isPending) {
this._reload({ processId, entity, listLength });
}
} else {
// Init Search after F5 / Refresh Page / No Entity Available
await this._initSearch();
}
}
});
});
renderPageTrigger = computed(() => {
const entity = this.entity();
if (!entity) return false;
if (entity.status === ReturnSearchStatus.Pending) return false;
constructor() {
this.searchEffectFn();
restoreScrollPosition();
const { hits, items } = entity;
if (!hits || !Array.isArray(items)) return false;
return hits > items.length;
});
ngAfterViewInit(): void {
this.restoreScrollPosition();
}
// TODO: Suche als Provider in FilterService auslagern (+ Cancel Search, + Fetching Status)
async onSearch() {
const processId = this._processId();
search() {
const processId = this.processId();
if (processId) {
await this._updateQueryParams();
this._returnSearchStore.search({
this.#filterService.commit();
this.returnSearchStore.search({
processId,
query: this._filterService.query(),
query: this.#filterService.query(),
cb: this.searchCb,
});
}
}
private async _updateQueryParams() {
return await this.#router.navigate([], {
queryParams: this._filterService.queryParams(),
searchCb = ({ data }: CallbackResult<ListResponseArgs<ReceiptListItem>>) => {
if (data) {
if (data.result.length === 1) {
this.navigate(['receipt', data.result[0].id]);
}
}
};
paging(inViewport: boolean) {
if (!inViewport) {
return;
}
const processId = this.processId();
if (processId) {
this.returnSearchStore.search({
processId,
query: this.#filterService.query(),
});
}
}
navigate(path: (string | number)[]) {
this.#router.navigate(path, {
relativeTo: this.#route,
replaceUrl: true,
queryParams: this.#filterService.queryParams(),
});
}
private async _reload({
processId,
entity,
listLength,
}: {
processId: number;
entity: ReturnSearchEntity;
listLength: number;
}) {
const entityItemsLength = entity?.items?.length ?? 0;
const hits = entity?.hits ?? 0;
// Soll reloaden wenn man am Ende der Liste in der View angekommen ist und noch nicht alle Items insgesamt geladen wurden
if (listLength === entityItemsLength && hits !== entityItemsLength) {
await this._updateQueryParams();
this._returnSearchStore.reload({
processId,
query: this._filterService.query(),
});
}
}
private async _initSearch() {
const entities = this._returnSearchStore.entities();
// For routing away from the list this entities?.length === 0 check is necessary, otherwise the init search would trigger again
if (entities?.length === 0) {
await this.onSearch();
}
}
}

View File

@@ -3,14 +3,23 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { injectActivatedProcessId } from '@isa/core/process';
import { ReturnSearchStore } from '@isa/oms/data-access';
import { FilterService, provideQuerySettings } from '@isa/shared/filter';
import {
FilterService,
provideFilter,
withQueryParamsSync,
withQuerySettingsFactory,
} from '@isa/shared/filter';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
@Component({
selector: 'oms-feature-return-search',
template: `<router-outlet></router-outlet>`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet],
providers: [provideQuerySettings(() => inject(ActivatedRoute).snapshot.data['querySettings'])],
providers: [provideFilter(withQuerySettingsFactory(querySettingsFactory), withQueryParamsSync())],
host: {
'[class]': '"flex flex-col gap-5 isa-desktop:gap-6 items-center overflow-x-hidden"',
},
@@ -69,11 +78,6 @@ export class ReturnSearchComponent {
}
});
constructor() {
this.parseFilterParamsEffectFn();
this.searchResultsNavigationEffectFn();
}
private async _navigateTo(url: string[]) {
return await this.#router.navigate(url, {
queryParams: this.#filterService.queryParams(),

View File

@@ -11,7 +11,11 @@ export const routes: Routes = [
resolve: { querySettings: querySettingsResolverFn },
children: [
{ path: '', component: ReturnSearchMainComponent },
{ path: 'receipts', component: ReturnSearchResultComponent },
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true },
},
],
},
{

View File

@@ -1,25 +1,23 @@
import { computed, inject, Injectable, InjectionToken, Provider } from '@angular/core';
import { InputType, QuerySettingsDTO } from '../types';
import { computed, inject, Injectable, signal } from '@angular/core';
import { InputType } from '../types';
import { getState, patchState, signalState } from '@ngrx/signals';
import { mapToFilter } from './mappings';
import { isEqual } from 'lodash';
import { FilterInput, OrderByDirectionSchema, Query, QuerySchema } from './schemas';
export const QUERY_SETTINGS = new InjectionToken<QuerySettingsDTO>('QuerySettings');
export function provideQuerySettings(factory: () => QuerySettingsDTO): Provider[] {
return [{ provide: QUERY_SETTINGS, useFactory: factory }, FilterService];
}
import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens';
@Injectable()
export class FilterService {
#onInit = inject(FILTER_ON_INIT, { optional: true })?.map((fn) => fn(this));
#onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) => fn(this));
readonly settings = inject(QUERY_SETTINGS);
private readonly defaultState = mapToFilter(this.settings);
#commitdState = structuredClone(this.defaultState);
#commitdState = signal(structuredClone(this.defaultState));
#state = signalState(this.#commitdState);
#state = signalState(this.#commitdState());
groups = this.#state.groups;
@@ -27,6 +25,12 @@ export class FilterService {
orderBy = this.#state.orderBy;
constructor() {
this.#onInit?.forEach((initFn) => {
initFn();
});
}
setOrderBy(
orderBy: { by: string; dir: 'asc' | 'desc' | undefined },
options?: { commit: boolean },
@@ -185,7 +189,7 @@ export class FilterService {
return input;
}
return this.#commitdState.inputs.find((i) => i.key === key) || input;
return this.#commitdState().inputs.find((i) => i.key === key) || input;
});
patchState(this.#state, { inputs });
@@ -197,7 +201,11 @@ export class FilterService {
* of the `getState` function applied to the current `#state`.
*/
commit() {
this.#commitdState = getState(this.#state);
this.#commitdState.set(getState(this.#state));
this.#onCommit?.forEach((commitFn) => {
commitFn();
});
}
/**
@@ -218,24 +226,24 @@ export class FilterService {
return;
}
const inputIndex = this.#commitdState.inputs.findIndex((i) => i.key === key);
const inputIndex = this.#commitdState().inputs.findIndex((i) => i.key === key);
if (inputIndex === -1) {
console.warn(`No committed input found with key: ${key}`);
return;
}
this.#commitdState = {
...this.#commitdState,
inputs: this.#commitdState.inputs.map((input, index) =>
this.#commitdState.set({
...this.#commitdState(),
inputs: this.#commitdState().inputs.map((input, index) =>
index === inputIndex ? inputToCommit : input,
),
};
});
}
commitOrderBy() {
const orderBy = this.#state.orderBy().map((o) => {
const committedOrderBy = this.#commitdState.orderBy.find((co) => co.by === o.by);
const committedOrderBy = this.#commitdState().orderBy.find((co) => co.by === o.by);
return { ...o, dir: committedOrderBy?.dir };
});
@@ -256,7 +264,6 @@ export class FilterService {
});
patchState(this.#state, { inputs });
if (options?.commit) {
this.commit();
}
@@ -270,7 +277,6 @@ export class FilterService {
*/
reset(options?: { commit: boolean }) {
patchState(this.#state, mapToFilter(this.settings));
if (options?.commit) {
this.commit();
}
@@ -334,9 +340,10 @@ export class FilterService {
}
queryParams = computed<Record<string, string>>(() => {
const commited = this.#commitdState();
const result: Record<string, string> = {};
for (const input of this.inputs()) {
for (const input of commited.inputs) {
switch (input.type) {
case InputType.Text:
if (input.value) {
@@ -351,7 +358,7 @@ export class FilterService {
}
}
const orderBy = this.orderBy().find((o) => o.dir);
const orderBy = commited.orderBy.find((o) => o.dir);
if (orderBy) {
result['orderBy'] = `${orderBy.by}:${orderBy.dir}`;
@@ -360,12 +367,28 @@ export class FilterService {
return result;
});
query = computed<Query>(() => {
const inputs = this.inputs();
isQueryParamsEqual(params: Record<string, string>): boolean {
const currentParams = this.queryParams();
return this.queryParamKeys().every((key) => params[key] === currentParams[key]);
}
const filterGroup = inputs.filter((i) => i.group === 'filter');
const inputGroup = inputs.filter((i) => i.group === 'input');
const orderBy = this.orderBy().filter((o) => o.dir);
queryParamKeys = computed(() => {
const keys = this.inputs().map((i) => i.key);
const orderBy = this.orderBy().find((o) => o.dir);
if (orderBy) {
keys.push('orderBy');
}
return keys;
});
query = computed<Query>(() => {
const commited = this.#commitdState();
const filterGroup = commited.inputs.filter((i) => i.group === 'filter');
const inputGroup = commited.inputs.filter((i) => i.group === 'main');
const orderBy = commited.orderBy.filter((o) => o.dir);
return QuerySchema.parse({
filter: filterGroup.reduce<Record<string, string>>((acc, input) => {
@@ -428,3 +451,4 @@ export class FilterService {
}
}
}
export { QUERY_SETTINGS };

View File

@@ -1,3 +1,5 @@
export * from './filter.service';
export * from './mappings';
export * from './provide-filter';
export * from './schemas';
export * from './tokens';

View File

@@ -1,4 +1,4 @@
import { InputDTO, InputGroupDTO, InputType, OptionDTO, QuerySettingsDTO } from '../types';
import { Input, InputGroup, InputType, Option, QuerySettings } from '../types';
import {
CheckboxFilterInput,
CheckboxFilterInputOption,
@@ -15,7 +15,7 @@ import {
TextFilterInputSchema,
} from './schemas';
export function mapToFilter(settings: QuerySettingsDTO): Filter {
export function mapToFilter(settings: QuerySettings): Filter {
const filter: Filter = {
groups: [],
inputs: [],
@@ -49,7 +49,7 @@ export function mapToFilter(settings: QuerySettingsDTO): Filter {
return filter;
}
function mapToFilterGroup(group: InputGroupDTO): FilterGroup {
function mapToFilterGroup(group: InputGroup): FilterGroup {
return FilterGroupSchema.parse({
group: group.group,
label: group.label,
@@ -57,7 +57,7 @@ function mapToFilterGroup(group: InputGroupDTO): FilterGroup {
});
}
function mapToFilterInput(group: string, input: InputDTO): FilterInput {
function mapToFilterInput(group: string, input: Input): FilterInput {
switch (input.type) {
case InputType.Text:
return mapToTextFilterInput(group, input);
@@ -69,7 +69,7 @@ function mapToFilterInput(group: string, input: InputDTO): FilterInput {
throw new Error(`Unknown input type: ${input.type}`);
}
function mapToTextFilterInput(group: string, input: InputDTO): TextFilterInput {
function mapToTextFilterInput(group: string, input: Input): TextFilterInput {
return TextFilterInputSchema.parse({
group,
key: input.key,
@@ -82,7 +82,7 @@ function mapToTextFilterInput(group: string, input: InputDTO): TextFilterInput {
});
}
function mapToCheckboxFilterInput(group: string, input: InputDTO): CheckboxFilterInput {
function mapToCheckboxFilterInput(group: string, input: Input): CheckboxFilterInput {
return CheckboxFilterInputSchema.parse({
group,
key: input.key,
@@ -98,14 +98,14 @@ function mapToCheckboxFilterInput(group: string, input: InputDTO): CheckboxFilte
});
}
function mapToCheckboxOption(option: OptionDTO): CheckboxFilterInputOption {
function mapToCheckboxOption(option: Option): CheckboxFilterInputOption {
return CheckboxFilterInputOptionSchema.parse({
label: option.label,
value: option.value,
});
}
function mapToDateRangeFilterInput(group: string, input: InputDTO): DateRangeFilterInput {
function mapToDateRangeFilterInput(group: string, input: Input): DateRangeFilterInput {
return DateRangeFilterInputSchema.parse({
group,
key: input.key,

View File

@@ -0,0 +1,94 @@
import { DestroyRef, EnvironmentProviders, inject, Provider } from '@angular/core';
import { QuerySettings } from '../types';
import { FilterService, QUERY_SETTINGS } from './filter.service';
import { NavigationEnd, NavigationExtras, Router } from '@angular/router';
import { FILTER_ON_COMMIT, FILTER_ON_INIT } from './tokens';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export function provideFilter(...features: FilterFeature[]): Provider[] {
return [FilterService, features.map((feature) => feature.ɵproviders)];
}
const enum ProvideFilterKind {
QuerySettings,
QueryParamsSync,
}
export interface FilterFeature {
ɵkind: ProvideFilterKind;
ɵproviders: Array<Provider> | EnvironmentProviders;
}
function filterFeature<FeatureKind extends ProvideFilterKind>(
kind: FeatureKind,
providers: Array<Provider> | EnvironmentProviders,
): FilterFeature {
return {
ɵkind: kind,
ɵproviders: providers,
};
}
export function withQuerySettings(querySettings: QuerySettings): FilterFeature {
return filterFeature(ProvideFilterKind.QuerySettings, [
{ provide: QUERY_SETTINGS, useValue: querySettings },
]);
}
export function withQuerySettingsFactory(querySettingsFactory: () => QuerySettings): FilterFeature {
return filterFeature(ProvideFilterKind.QuerySettings, [
{ provide: QUERY_SETTINGS, useFactory: querySettingsFactory },
]);
}
export function withQueryParamsSync({
replaceUrl = true,
queryParamsHandling = 'merge',
}: Pick<NavigationExtras, 'replaceUrl' | 'queryParamsHandling'> = {}): FilterFeature {
function onCommitFactory() {
const router = inject(Router);
return (filterService: FilterService) => () => {
const queryParams = router.routerState.root.snapshot.queryParams;
if (!filterService.isQueryParamsEqual(queryParams)) {
router.navigate([], {
queryParams: filterService.queryParams(),
queryParamsHandling,
replaceUrl,
});
}
};
}
function onInitFactory() {
const router = inject(Router);
const destroyRef = inject(DestroyRef);
return (filterService: FilterService) => () => {
function parseQueryParams() {
const queryParams = router.routerState.root.snapshot.queryParams;
if (!filterService.isQueryParamsEqual(queryParams)) {
filterService.parseQueryParams(queryParams, { commit: true });
}
}
parseQueryParams();
router.events.pipe(takeUntilDestroyed(destroyRef)).subscribe((event) => {
if (event instanceof NavigationEnd) {
parseQueryParams();
}
});
};
}
return filterFeature(ProvideFilterKind.QueryParamsSync, [
{
provide: FILTER_ON_COMMIT,
useFactory: onCommitFactory,
multi: true,
},
{
provide: FILTER_ON_INIT,
useFactory: onInitFactory,
multi: true,
},
]);
}

View File

@@ -101,8 +101,6 @@ export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;
export type OrderByDirection = z.infer<typeof OrderByDirectionSchema>;
export type OrderBy = z.infer<typeof OrderBySchema>;
export type Query = z.infer<typeof QuerySchema>;
export type QueryOrderBy = z.infer<typeof QueryOrderBySchema>;

View File

@@ -0,0 +1,13 @@
import { InjectionToken } from '@angular/core';
import { QuerySettings } from '../types';
import { FilterService } from './filter.service';
export const QUERY_SETTINGS = new InjectionToken<QuerySettings>('Filter.QuerySettings');
export const FILTER_ON_INIT = new InjectionToken<
Array<(filterService: FilterService) => () => void>
>('Filter.OnInit');
export const FILTER_ON_COMMIT = new InjectionToken<
Array<(filterService: FilterService) => () => void>
>('Filter.OnCommit');

View File

@@ -14,7 +14,7 @@
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop "
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"

View File

@@ -4,24 +4,24 @@ export enum InputType {
DateRange = 128,
}
export interface QuerySettingsDTO {
export interface QuerySettings {
/**
* Filter
*/
filter: Array<InputGroupDTO>;
filter: Array<InputGroup>;
/**
* Eingabefelder
*/
input: Array<InputGroupDTO>;
input: Array<InputGroup>;
/**
* Sortierung
*/
orderBy: Array<OrderByDTO>;
orderBy: Array<OrderBy>;
}
export interface InputGroupDTO {
export interface InputGroup {
/**
* Beschreibung
*/
@@ -35,7 +35,7 @@ export interface InputGroupDTO {
/**
* Eingabefelder
*/
input: Array<InputDTO>;
input: Array<Input>;
/**
* Label
@@ -46,7 +46,7 @@ export interface InputGroupDTO {
/**
* Sortierwert
*/
export interface OrderByDTO {
export interface OrderBy {
/**
* Wert
*/
@@ -66,7 +66,7 @@ export interface OrderByDTO {
/**
* Eingabeelement
*/
export interface InputDTO {
export interface Input {
/**
* Regex-Überprüfung
*/
@@ -100,7 +100,7 @@ export interface InputDTO {
/**
* Auswahl
*/
options?: InputOptionsDTO;
options?: InputOptions;
/**
* Wasserzeichen
@@ -126,7 +126,7 @@ export interface InputDTO {
/**
* Auswahl
*/
export interface InputOptionsDTO {
export interface InputOptions {
/**
* Maximale Anzahl auswählbarer Elemente (null => alle, 1 = single select)
*/
@@ -135,13 +135,13 @@ export interface InputOptionsDTO {
/**
* Werte
*/
values?: Array<OptionDTO>;
values?: Array<Option>;
}
/**
* Auswahlelement
*/
export interface OptionDTO {
export interface Option {
/**
* Beschreibung
*/
@@ -190,5 +190,5 @@ export interface OptionDTO {
/**
* Unter-Optionen
*/
values?: Array<OptionDTO>;
values?: Array<Option>;
}

View File

@@ -1,2 +1,3 @@
export * from './lib/breakpoint.directive';
export * from './lib/breakpoint';

View File

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

View File

@@ -12,7 +12,7 @@ export default [
'error',
{
type: 'attribute',
prefix: 'lib',
prefix: 'util',
style: 'camelCase',
},
],
@@ -20,7 +20,7 @@ export default [
'error',
{
type: 'element',
prefix: 'lib',
prefix: 'util',
style: 'kebab-case',
},
],

View File

@@ -1,8 +1,8 @@
export default {
displayName: 'core-scroll-position',
displayName: 'utils-scroll-position',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/core/scroll-position',
coverageDirectory: '../../../coverage/libs/utils/scroll-position',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',

View File

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

View File

@@ -0,0 +1,2 @@
export * from './lib/scroll-position-restoration';
export * from './lib/scrolled-into-viewport.directive';

View File

@@ -1,12 +1,12 @@
import { ViewportScroller } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
afterNextRender,
afterRender,
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';
@@ -43,21 +43,29 @@ export function provideScrollPositionRestoration(): EnvironmentProviders {
});
}
export async function restoreScrollPosition() {
const injector = inject(Injector);
export async function storeScrollPosition() {
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 },
);
}
sessionStorage.set(url, viewportScroller.getScrollPosition());
}
export function injectRestoreScrollPosition(): () => Promise<void> {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
return async (delay = 0) => {
const url = router.url;
const position = await sessionStorage.get(url);
if (position) {
// wait for the next tick to ensure the DOM is ready
await new Promise((r) => setTimeout(r, delay));
viewportScroller.scrollToPosition(position as [number, number]);
}
};
}

View File

@@ -0,0 +1,38 @@
import { Directive, ElementRef, AfterViewInit, OnDestroy, output } from '@angular/core';
@Directive({
selector: '[utilScrolledIntoViewport]',
})
export class ScrolledIntoViewportDirective implements AfterViewInit, OnDestroy {
/**
* Emits true when the element enters the viewport and false when it leaves.
*/
scrolledIntoViewport = output<boolean>({ alias: 'utilScrolledIntoViewport' });
private observer: IntersectionObserver | null = null;
constructor(private elementRef: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.scrolledIntoViewport.emit(true);
} else {
this.scrolledIntoViewport.emit(false);
}
});
},
{ threshold: [0, 0.1, 0.5, 1.0] },
);
this.observer.observe(this.elementRef.nativeElement);
}
ngOnDestroy(): void {
if (this.observer) {
this.observer.disconnect();
}
}
}

View File

@@ -45,7 +45,6 @@
"@isa/core/notifications": ["libs/core/notifications/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/data-access": ["libs/oms/data-access/src/index.ts"],
@@ -69,6 +68,7 @@
"@isa/ui/progress-bar": ["libs/ui/progress-bar/src/index.ts"],
"@isa/ui/search-bar": ["libs/ui/search-bar/src/index.ts"],
"@isa/ui/toolbar": ["libs/ui/toolbar/src/index.ts"],
"@isa/utils/scroll-position": ["libs/utils/scroll-position/src/index.ts"],
"@isa/utils/z-safe-parse": ["libs/utils/z-safe-parse/src/index.ts"],
"@modal/*": ["apps/isa-app/src/modal/*/index.ts"],
"@page/*": ["apps/isa-app/src/page/*/index.ts"],