mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -1 +0,0 @@
|
||||
export * from './lib/scroll-position-restoration';
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './filter.service';
|
||||
export * from './mappings';
|
||||
export * from './provide-filter';
|
||||
export * from './schemas';
|
||||
export * from './tokens';
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
libs/shared/filter/src/lib/core/provide-filter.ts
Normal file
94
libs/shared/filter/src/lib/core/provide-filter.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
13
libs/shared/filter/src/lib/core/tokens.ts
Normal file
13
libs/shared/filter/src/lib/core/tokens.ts
Normal 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');
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/breakpoint.directive';
|
||||
export * from './lib/breakpoint';
|
||||
|
||||
|
||||
7
libs/utils/scroll-position/README.md
Normal file
7
libs/utils/scroll-position/README.md
Normal 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.
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
@@ -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',
|
||||
@@ -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": {
|
||||
2
libs/utils/scroll-position/src/index.ts
Normal file
2
libs/utils/scroll-position/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/scroll-position-restoration';
|
||||
export * from './lib/scrolled-into-viewport.directive';
|
||||
@@ -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]);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user