All files / app/features/picture/services picture-state.service.ts

83.54% Statements 66/79
68.18% Branches 15/22
76.47% Functions 13/17
86.3% Lines 63/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 1352x 2x 2x 2x 2x   2x   2x 2x     2x 10x 10x 10x   10x 10x 10x 10x 10x 10x 10x   10x   10x 10x 10x 10x   10x 2x 2x 2x 1x   1x     10x                 10x 10x   2x           10x     10x     13x       11x 11x 11x 11x 11x   1x 1x             11x 11x 11x         3x 3x       1x       2x       2x 2x 1x     1x 1x 1x   1x                   1x       1x 1x 1x   1x        
import { buildRows, PictureRow } from './picture-row-builder';
import { PictureService } from '../../../services/pictures';
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Logger, LoggerService } from '@drevo-web/core';
import { Picture } from '@drevo-web/shared';
import { catchError, debounceTime, distinctUntilChanged, map, of, startWith, Subject, switchMap } from 'rxjs';
 
const DEBOUNCE_TIME_MS = 500;
const TARGET_ROW_HEIGHT = 200;
 
@Injectable()
export class PictureStateService {
    private readonly pictureService = inject(PictureService);
    private readonly destroyRef = inject(DestroyRef);
    private readonly logger: Logger = inject(LoggerService).withContext('PicturesStateService');
 
    private readonly _pictures = signal<Picture[]>([]);
    private readonly _isLoading = signal(false);
    private readonly _isLoadingMore = signal(false);
    private readonly _totalItems = signal(0);
    private readonly _currentPage = signal(1);
    private readonly _containerWidth = signal(0);
    private readonly _searchQuery = signal('');
 
    private readonly searchSubject = new Subject<string>();
 
    readonly isLoading = this._isLoading.asReadonly();
    readonly isLoadingMore = this._isLoadingMore.asReadonly();
    readonly totalItems = this._totalItems.asReadonly();
    readonly searchQuery = this._searchQuery.asReadonly();
 
    readonly rows = computed<readonly PictureRow[]>(() => {
        const pictures = this._pictures();
        const width = this._containerWidth();
        if (width <= 0 || pictures.length === 0) {
            return [];
        }
        return buildRows(pictures, width, TARGET_ROW_HEIGHT);
    });
 
    readonly totalRows = computed(() => {
        const total = this._totalItems();
        const loaded = this._pictures().length;
        const rowCount = this.rows().length;
        if (loaded === 0 || total === 0) return 0;
        // Estimate total rows based on ratio
        return Math.ceil((total / loaded) * rowCount);
    });
 
    readonly hasResults = computed(() => this._pictures().length > 0 && !this._isLoading());
    readonly showNoResults = computed(
        () =>
            this._searchQuery().length > 0 &&
            !this._isLoading() &&
            this._totalItems() === 0 &&
            this._pictures().length === 0,
    );
 
    readonly trackByFn = (_index: number, row: PictureRow): string => row.items.map(item => item.picture.id).join(',');
 
    init(): void {
        this.searchSubject
            .pipe(
                startWith(''),
                map(query => query.trim()),
                distinctUntilChanged(),
                debounceTime(DEBOUNCE_TIME_MS),
                switchMap(query => {
                    this._isLoading.set(true);
                    this._isLoadingMore.set(false);
                    this._currentPage.set(1);
                    this.logger.info('Searching pictures', { query });
                    return this.pictureService.getPictures({ query, page: 1 }).pipe(
                        catchError(error => {
                            this.logger.error('Failed to load pictures', error);
                            return of({ items: [] as Picture[], total: 0 });
                        }),
                    );
                }),
                takeUntilDestroyed(this.destroyRef),
            )
            .subscribe(response => {
                this._pictures.set([...response.items]);
                this._totalItems.set(response.total);
                this._isLoading.set(false);
            });
    }
 
    onSearchChange(value: string): void {
        this._searchQuery.set(value);
        this.searchSubject.next(value);
    }
 
    onContainerResize(width: number): void {
        this._containerWidth.set(width);
    }
 
    loadMore(): void {
        Iif (this._isLoadingMore()) {
            return;
        }
 
        const currentPictures = this._pictures();
        if (currentPictures.length >= this._totalItems()) {
            return;
        }
 
        const nextPage = this._currentPage() + 1;
        const queryAtRequest = this._searchQuery();
        this._isLoadingMore.set(true);
 
        this.pictureService
            .getPictures({ query: queryAtRequest, page: nextPage })
            .pipe(
                catchError(error => {
                    this.logger.error('Failed to load more pictures', error);
                    return of({ items: [] as Picture[], total: 0 });
                }),
                takeUntilDestroyed(this.destroyRef),
            )
            .subscribe(response => {
                Iif (this._searchQuery() !== queryAtRequest) {
                    this._isLoadingMore.set(false);
                    return;
                }
                Eif (response.items.length > 0) {
                    this._pictures.set([...this._pictures(), ...response.items]);
                    this._currentPage.set(nextPage);
                }
                this._isLoadingMore.set(false);
            });
    }
}