filter search results by dossier

This commit is contained in:
Dan Percic 2021-07-27 21:17:24 +03:00
parent e50e5cbfb2
commit c61e6e1aa5
10 changed files with 104 additions and 75 deletions

View File

@ -9,7 +9,6 @@
<redaction-popup-filter <redaction-popup-filter
(filtersChanged)="filtersChanged($event)" (filtersChanged)="filtersChanged($event)"
[actionsTemplate]="annotationFilterActionTemplate" [actionsTemplate]="annotationFilterActionTemplate"
[chevron]="true"
[filterTemplate]="annotationFilterTemplate" [filterTemplate]="annotationFilterTemplate"
[primaryFilters]="primaryFilters" [primaryFilters]="primaryFilters"
[secondaryFilters]="secondaryFilters" [secondaryFilters]="secondaryFilters"

View File

@ -3,6 +3,7 @@
[searchPlaceholder]="'search.placeholder' | translate" [searchPlaceholder]="'search.placeholder' | translate"
[searchWidth]="600" [searchWidth]="600"
[showCloseButton]="true" [showCloseButton]="true"
[searchPosition]="searchPositions.beforeFilters"
></redaction-page-header> ></redaction-page-header>
<div class="overlay-shadow"></div> <div class="overlay-shadow"></div>
@ -25,13 +26,13 @@
class="table-item" class="table-item"
> >
<div class="filename"> <div class="filename">
<div [matTooltip]="item.fileName" class="table-item-title heading" matTooltipPosition="above"> <div [matTooltip]="item.filename" class="table-item-title heading" matTooltipPosition="above">
<span <span
*ngIf="item.highlights.filename; else defaultFilename" *ngIf="item.highlights.filename; else defaultFilename"
[innerHTML]="item.highlights.filename[0]" [innerHTML]="item.highlights.filename[0]"
class="highlights" class="highlights"
></span> ></span>
<ng-template #defaultFilename>{{ item.fileName }}</ng-template> <ng-template #defaultFilename>{{ item.filename }}</ng-template>
</div> </div>
<ng-container *ngIf="item.highlights['sections.text'] as highlights"> <ng-container *ngIf="item.highlights['sections.text'] as highlights">
@ -49,7 +50,7 @@
>&nbsp;<s>{{ term }}</s></span >&nbsp;<s>{{ term }}</s></span
>.&nbsp;{{ 'search-screen.must-contain' | translate }}: >.&nbsp;{{ 'search-screen.must-contain' | translate }}:
<span <span
(click)="$event.stopPropagation(); updateNavigation({ query: search$.getValue(), mustContain: term })" (click)="$event.stopPropagation(); updateNavigation(search$.getValue(), term)"
*ngFor="let term of unmatched" *ngFor="let term of unmatched"
>&nbsp;<u>{{ term }}</u></span >&nbsp;<u>{{ term }}</u></span
> >
@ -78,7 +79,7 @@
<div class="small-label stats-subtitle"> <div class="small-label stats-subtitle">
<div> <div>
<mat-icon svgIcon="red:pages"></mat-icon> <mat-icon svgIcon="red:pages"></mat-icon>
{{ item.pages }} {{ item.numberOfPages }}
</div> </div>
</div> </div>
@ -87,7 +88,7 @@
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
<redaction-scroll-button <redaction-scroll-button
*ngIf="(screenStateService.noData$ | async) === false" *ngIf="searchResult.length"
[itemSize]="itemSize" [itemSize]="itemSize"
[scrollViewport]="scrollViewport" [scrollViewport]="scrollViewport"
></redaction-scroll-button> ></redaction-scroll-button>

View File

@ -2,7 +2,7 @@ import { Component, Injector, OnDestroy } from '@angular/core';
import { BaseListingComponent } from '../../../shared/base/base-listing.component'; import { BaseListingComponent } from '../../../shared/base/base-listing.component';
import { MatchedDocument, SearchControllerService, SearchResult } from '@redaction/red-ui-http'; import { MatchedDocument, SearchControllerService, SearchResult } from '@redaction/red-ui-http';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { debounceTime, map, switchMap, tap } from 'rxjs/operators'; import { debounceTime, map, skip, switchMap, tap } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TableColConfig } from '../../../shared/components/table-col-name/table-col-name.component'; import { TableColConfig } from '../../../shared/components/table-col-name/table-col-name.component';
import { FilterService } from '../../../shared/services/filter.service'; import { FilterService } from '../../../shared/services/filter.service';
@ -14,16 +14,20 @@ import { FileStatusWrapper } from '../../../../models/file/file-status.wrapper';
import { LoadingService } from '../../../../services/loading.service'; import { LoadingService } from '../../../../services/loading.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { fileStatusTranslations } from '../../translations/file-status-translations'; import { fileStatusTranslations } from '../../translations/file-status-translations';
import { searchPositions } from '../../../shared/components/page-header/models/search-positions.type';
import { keyChecker } from '../../../shared/components/filters/popup-filter/utils/filter-utils';
import { DossierWrapper } from '../../../../state/model/dossier.wrapper';
import { TranslateService } from '@ngx-translate/core';
interface ListItem { interface ListItem {
fileName: string; readonly dossierId: string;
matchedDocument: MatchedDocument; readonly filename: string;
unmatched: string[] | null; readonly unmatched: string[] | null;
highlights: { [key: string]: string[] }; readonly highlights: { [key: string]: string[] };
routerLink: string; readonly routerLink: string;
status: string; readonly status: string;
dossierName: string; readonly dossierName: string;
pages: number; readonly numberOfPages: number;
} }
@Component({ @Component({
@ -32,14 +36,15 @@ interface ListItem {
providers: [FilterService, SearchService, ScreenStateService, SortingService] providers: [FilterService, SearchService, ScreenStateService, SortingService]
}) })
export class SearchScreenComponent extends BaseListingComponent<ListItem> implements OnDestroy { export class SearchScreenComponent extends BaseListingComponent<ListItem> implements OnDestroy {
fileStatusTranslations = fileStatusTranslations; readonly fileStatusTranslations = fileStatusTranslations;
readonly searchPositions = searchPositions;
readonly itemSize = 85; readonly itemSize = 85;
readonly search$ = new BehaviorSubject<string>(null); readonly search$ = new BehaviorSubject<string>(null);
readonly searchResults$: Observable<ListItem[]> = this.search$.asObservable().pipe( readonly searchResults$: Observable<ListItem[]> = this.search$.asObservable().pipe(
switchMap(query => this._search(query)), switchMap(query => this._search(query)),
map(searchResult => this._toMatchedDocuments(searchResult)), map(searchResult => this._toMatchedDocuments(searchResult)),
map(documents => this._toListItems(documents)), map(docs => this._toListItems(docs)),
tap(result => this.screenStateService.setEntities(result)), tap(result => this.screenStateService.setEntities(result)),
tap(() => this._loadingService.stop()) tap(() => this._loadingService.stop())
); );
@ -58,7 +63,7 @@ export class SearchScreenComponent extends BaseListingComponent<ListItem> implem
} }
]; ];
protected readonly _primaryKey = 'fileName'; protected readonly _primaryKey = 'fileName';
protected _tableHeaderLabel = _('search-screen.table-header'); protected readonly _tableHeaderLabel = _('search-screen.table-header');
private _dossierId: string; private _dossierId: string;
constructor( constructor(
@ -67,38 +72,47 @@ export class SearchScreenComponent extends BaseListingComponent<ListItem> implem
private readonly _activatedRoute: ActivatedRoute, private readonly _activatedRoute: ActivatedRoute,
private readonly _appStateService: AppStateService, private readonly _appStateService: AppStateService,
private readonly _loadingService: LoadingService, private readonly _loadingService: LoadingService,
private readonly _translateService: TranslateService,
private readonly _router: Router private readonly _router: Router
) { ) {
super(_injector); super(_injector);
this.filterService.addFilterGroup({
slug: 'dossiers',
label: this._translateService.instant('search-screen.filters.by-dossier'),
icon: 'red:folder',
values: this._appStateService.allDossiers.map(dossier => ({
key: dossier.dossierId,
label: dossier.dossierName
})),
checker: keyChecker('dossierId')
});
this.addSubscription = _activatedRoute.queryParamMap this.addSubscription = _activatedRoute.queryParamMap
.pipe( .pipe(
tap(() => this._loadingService.start()), tap(() => this._loadingService.start()),
map(value => ({ query: value.get('query'), dossierId: value.get('dossierId') })), map(value => ({ query: value.get('query'), dossierId: value.get('dossierId') }))
tap(mappedValue => this._updateValues(mappedValue))
) )
.subscribe(); .subscribe(mappedValue => this._updateValues(mappedValue));
this.addSubscription = this.searchService.searchForm this.addSubscription = this.searchService.searchForm
.get('query') .get('query')
.valueChanges.pipe(debounceTime(300)) .valueChanges.pipe(debounceTime(300))
.subscribe(value => this.updateNavigation({ query: value })); .subscribe(value => this.updateNavigation(value));
this.addSubscription = this.filterService.filterGroups$
.pipe(skip(1))
.subscribe(() => this.updateNavigation(this.search$.getValue()));
} }
setInitialConfig() { setInitialConfig() {
return; return;
} }
updateNavigation({ query, mustContain }: { readonly query: string; readonly mustContain?: string }) { updateNavigation(query: string, mustContain?: string) {
const newQuery = query?.replace(mustContain, `"${mustContain}"`); const newQuery = query?.replace(mustContain, `"${mustContain}"`);
const queryParams = newQuery && newQuery !== '' ? { query: newQuery } : {}; const queryParams = newQuery && newQuery !== '' ? { query: newQuery } : {};
const queryParamsHandling = this._dossierId ? 'merge' : ''; this._router.navigate([], { queryParams }).then();
this._router
.navigate([], {
queryParams,
queryParamsHandling
})
.then();
} }
private _search(query: string): Observable<SearchResult> { private _search(query: string): Observable<SearchResult> {
@ -112,6 +126,7 @@ export class SearchScreenComponent extends BaseListingComponent<ListItem> implem
} }
private _updateValues({ query, dossierId }: { readonly query: string; readonly dossierId: string }) { private _updateValues({ query, dossierId }: { readonly query: string; readonly dossierId: string }) {
if (dossierId) this.filterService.toggleFilter('dossiers', dossierId);
this._dossierId = dossierId; this._dossierId = dossierId;
this.searchService.searchValue = query; this.searchService.searchValue = query;
this.search$.next(query); this.search$.next(query);
@ -121,34 +136,31 @@ export class SearchScreenComponent extends BaseListingComponent<ListItem> implem
return this._appStateService.getFileById(dossierId, fileId); return this._appStateService.getFileById(dossierId, fileId);
} }
private _getDossierWrapper(dossierId: string) { private _getDossierWrapper(dossierId: string): DossierWrapper {
return this._appStateService.getDossierById(dossierId); return this._appStateService.getDossierById(dossierId);
} }
private _toMatchedDocuments({ matchedDocuments }: SearchResult) { private _toMatchedDocuments({ matchedDocuments }: SearchResult): MatchedDocument[] {
return matchedDocuments.filter(doc => doc.score > 0 && doc.matchedTerms.length > 0); return matchedDocuments.filter(doc => doc.score > 0 && doc.matchedTerms.length > 0);
} }
private _toListItems(matchedDocuments: MatchedDocument[]) { private _toListItems(matchedDocuments: MatchedDocument[]): ListItem[] {
return matchedDocuments return matchedDocuments.map(document => this._toListItem(document)).filter(value => value);
.map<ListItem>(document => { }
const fileStatus = this._getFileWrapper(document.dossierId, document.fileId);
if (!fileStatus) {
return undefined;
}
const { dossierId, dossierName } = this._getDossierWrapper(document.dossierId); private _toListItem({ dossierId, fileId, unmatchedTerms, highlights }: MatchedDocument): ListItem {
return { const fileWrapper = this._getFileWrapper(dossierId, fileId);
matchedDocument: document, if (!fileWrapper) return undefined;
unmatched: document.unmatchedTerms.length ? document.unmatchedTerms : null,
highlights: document.highlights, return {
status: fileStatus.status, dossierId,
pages: fileStatus.numberOfPages, unmatched: unmatchedTerms || null,
dossierName: dossierName, highlights,
fileName: fileStatus.filename, status,
routerLink: `/main/dossiers/${dossierId}/file/${fileStatus.fileId}` numberOfPages: fileWrapper.numberOfPages,
} as ListItem; dossierName: this._getDossierWrapper(dossierId).dossierName,
}) filename: fileWrapper.filename,
.filter(value => value); routerLink: `/main/dossiers/${dossierId}/file/${fileId}`
};
} }
} }

View File

@ -1,5 +1,5 @@
<redaction-icon-button <redaction-icon-button
*ngIf="!chevron" *ngIf="icon"
[icon]="icon" [icon]="icon"
[label]="filterLabel || ('filter-menu.label' | translate)" [label]="filterLabel || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu" [matMenuTriggerFor]="filterMenu"
@ -7,7 +7,7 @@
></redaction-icon-button> ></redaction-icon-button>
<redaction-chevron-button <redaction-chevron-button
*ngIf="chevron" *ngIf="!icon"
[label]="filterLabel || ('filter-menu.label' | translate)" [label]="filterLabel || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu" [matMenuTriggerFor]="filterMenu"
[showDot]="hasActiveFilters" [showDot]="hasActiveFilters"

View File

@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, T
import { FilterModel } from './model/filter.model'; import { FilterModel } from './model/filter.model';
import { handleCheckedValue } from './utils/filter-utils'; import { handleCheckedValue } from './utils/filter-utils';
import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox'; import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';
import { TranslateService } from '@ngx-translate/core';
@Component({ @Component({
selector: 'redaction-popup-filter', selector: 'redaction-popup-filter',
@ -29,12 +28,11 @@ export class PopupFilterComponent implements OnChanges {
@Input() secondaryFilters: FilterModel[] = []; @Input() secondaryFilters: FilterModel[] = [];
@Input() filterLabel; @Input() filterLabel;
@Input() icon: string; @Input() icon: string;
@Input() chevron = false;
atLeastOneFilterIsExpandable = false; atLeastOneFilterIsExpandable = false;
atLeastOneSecondaryFilterIsExpandable = false; atLeastOneSecondaryFilterIsExpandable = false;
constructor(private readonly _changeDetectorRef: ChangeDetectorRef, private readonly _translateService: TranslateService) {} constructor(private readonly _changeDetectorRef: ChangeDetectorRef) {}
get hasActiveFilters(): boolean { get hasActiveFilters(): boolean {
return !!this._allFilters.find(f => f.checked || f.indeterminate); return !!this._allFilters.find(f => f.checked || f.indeterminate);

View File

@ -0,0 +1,6 @@
export const searchPositions = {
beforeFilters: 'beforeFilters',
afterFilters: 'afterFilters'
} as const;
export type SearchPosition = keyof typeof searchPositions;

View File

@ -1,8 +1,10 @@
<div class="page-header"> <div class="page-header">
<div *ngIf="pageLabel" class="breadcrumb">{{ pageLabel }}</div> <div *ngIf="pageLabel" class="breadcrumb">{{ pageLabel }}</div>
<div *ngIf="filters$ | async as filters" [style.max-width]="computedWidth" [style.width]="computedWidth" class="filters"> <div *ngIf="filters$ | async as filters" class="filters">
<div *ngIf="filters.length" translate="filters.filter-by"></div> <div *ngIf="filters.length && searchPosition !== searchPositions.beforeFilters" translate="filters.filter-by"></div>
<ng-container *ngIf="searchPosition === searchPositions.beforeFilters" [ngTemplateOutlet]="searchBar"></ng-container>
<ng-container *ngFor="let config of filters; trackBy: trackByLabel"> <ng-container *ngFor="let config of filters; trackBy: trackByLabel">
<redaction-popup-filter <redaction-popup-filter
@ -15,13 +17,7 @@
></redaction-popup-filter> ></redaction-popup-filter>
</ng-container> </ng-container>
<redaction-input-with-action <ng-container *ngIf="searchPosition === searchPositions.afterFilters" [ngTemplateOutlet]="searchBar"></ng-container>
*ngIf="searchPlaceholder"
[form]="searchService.searchForm"
[placeholder]="searchPlaceholder"
[width]="searchWidth"
type="search"
></redaction-input-with-action>
<div (click)="resetFilters()" *ngIf="showResetFilters$ | async" class="reset-filters" translate="reset-filters"></div> <div (click)="resetFilters()" *ngIf="showResetFilters$ | async" class="reset-filters" translate="reset-filters"></div>
</div> </div>
@ -60,3 +56,14 @@
></redaction-circle-button> ></redaction-circle-button>
</div> </div>
</div> </div>
<ng-template #searchBar>
<redaction-input-with-action
*ngIf="searchPlaceholder && searchService"
[form]="searchService.searchForm"
[placeholder]="searchPlaceholder"
[width]="searchWidth"
[class.mr-8]="searchPosition === searchPositions.beforeFilters"
type="search"
></redaction-input-with-action>
</ng-template>

View File

@ -5,6 +5,7 @@ import { FilterService } from '@shared/services/filter.service';
import { SearchService } from '@shared/services/search.service'; import { SearchService } from '@shared/services/search.service';
import { distinctUntilChanged, map } from 'rxjs/operators'; import { distinctUntilChanged, map } from 'rxjs/operators';
import { combineLatest, Observable, of } from 'rxjs'; import { combineLatest, Observable, of } from 'rxjs';
import { SearchPosition, searchPositions } from '@shared/components/page-header/models/search-positions.type';
@Component({ @Component({
selector: 'redaction-page-header', selector: 'redaction-page-header',
@ -12,12 +13,15 @@ import { combineLatest, Observable, of } from 'rxjs';
styleUrls: ['./page-header.component.scss'] styleUrls: ['./page-header.component.scss']
}) })
export class PageHeaderComponent { export class PageHeaderComponent {
readonly searchPositions = searchPositions;
@Input() pageLabel: string; @Input() pageLabel: string;
@Input() showCloseButton: boolean; @Input() showCloseButton: boolean;
@Input() actionConfigs: ActionConfig[]; @Input() actionConfigs: ActionConfig[];
@Input() buttonConfigs: ButtonConfig[]; @Input() buttonConfigs: ButtonConfig[];
@Input() searchPlaceholder: string; @Input() searchPlaceholder: string;
@Input() searchWidth: number | 'full'; @Input() searchWidth: number | 'full';
@Input() searchPosition: SearchPosition = this.searchPositions.afterFilters;
readonly filters$ = this.filterService?.filterGroups$.pipe(map(all => all.filter(f => f.icon))); readonly filters$ = this.filterService?.filterGroups$.pipe(map(all => all.filter(f => f.icon)));
readonly showResetFilters$ = this._showResetFilters$; readonly showResetFilters$ = this._showResetFilters$;
@ -37,10 +41,6 @@ export class PageHeaderComponent {
); );
} }
get computedWidth() {
return this.searchWidth === 'full' ? '100%' : `${this.searchWidth}px`;
}
resetFilters(): void { resetFilters(): void {
this.filterService.reset(); this.filterService.reset();
this.searchService.reset(); this.searchService.reset();

View File

@ -2,14 +2,17 @@ import { Injectable } from '@angular/core';
import { FilterModel } from '@shared/components/filters/popup-filter/model/filter.model'; import { FilterModel } from '@shared/components/filters/popup-filter/model/filter.model';
import { processFilters } from '@shared/components/filters/popup-filter/utils/filter-utils'; import { processFilters } from '@shared/components/filters/popup-filter/utils/filter-utils';
import { FilterGroup } from '@shared/components/filters/popup-filter/model/filter-wrapper.model'; import { FilterGroup } from '@shared/components/filters/popup-filter/model/filter-wrapper.model';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
@Injectable() @Injectable()
export class FilterService { export class FilterService {
private readonly _filterGroups$ = new BehaviorSubject<FilterGroup[]>([]); private readonly _filterGroups$ = new BehaviorSubject<FilterGroup[]>([]);
private readonly _refresh$ = new BehaviorSubject(null); private readonly _refresh$ = new Subject();
readonly filterGroups$ = this._refresh$.pipe(switchMap(() => this._filterGroups$.asObservable())); readonly filterGroups$ = this._refresh$.pipe(
startWith(''),
switchMap(() => this._filterGroups$.asObservable())
);
readonly showResetFilters$ = this._showResetFilters$; readonly showResetFilters$ = this._showResetFilters$;
get filterGroups(): FilterGroup[] { get filterGroups(): FilterGroup[] {
@ -25,7 +28,7 @@ export class FilterService {
} }
refresh(): void { refresh(): void {
this._refresh$.next(null); this._refresh$.next();
} }
toggleFilter(slug: string, key: string) { toggleFilter(slug: string, key: string) {

View File

@ -1205,6 +1205,9 @@
"pages": "Pages", "pages": "Pages",
"status": "Status" "status": "Status"
}, },
"filters": {
"by-dossier": "Filter by Dossier"
},
"missing": "Missing", "missing": "Missing",
"must-contain": "Must contain", "must-contain": "Must contain",
"no-data": "Please enter a keyword in the search input to look for documents or document content.", "no-data": "Please enter a keyword in the search input to look for documents or document content.",