working entire platform search

This commit is contained in:
Dan Percic 2021-07-23 03:09:30 +03:00
parent 567980baa4
commit 5f8f11108f
34 changed files with 688 additions and 155 deletions

View File

@ -1,4 +1,2 @@
<router-outlet></router-outlet>
<redaction-full-page-loading-indicator
[displayed]="loadingService.isLoading | async"
></redaction-full-page-loading-indicator>
<redaction-full-page-loading-indicator [displayed]="loadingService.isLoading$ | async"></redaction-full-page-loading-indicator>

View File

@ -32,6 +32,7 @@ import { GlobalErrorHandler } from '@utils/global-error-handler.service';
import { REDMissingTranslationHandler } from '@utils/missing-translations-handler';
import { configurationInitializer } from '@app-config/configuration.initializer';
import { AppConfigService } from '@app-config/app-config.service';
import { SpotlightSearchComponent } from '@components/spotlight-search/spotlight-search.component';
export function httpLoaderFactory(httpClient: HttpClient) {
return new TranslateHttpLoader(httpClient, '/assets/i18n/', '.json');
@ -49,7 +50,15 @@ function cleanupBaseUrl(baseUrl: string) {
const screens = [BaseScreenComponent, DownloadsListScreenComponent, UserProfileScreenComponent];
const components = [AppComponent, LogoComponent, AuthErrorComponent, ToastComponent, NotificationsComponent, ...screens];
const components = [
AppComponent,
LogoComponent,
AuthErrorComponent,
ToastComponent,
NotificationsComponent,
SpotlightSearchComponent,
...screens
];
@NgModule({
declarations: [...components],

View File

@ -6,11 +6,7 @@
<mat-icon svgIcon="red:menu"></mat-icon>
</button>
<mat-menu #menuNav="matMenu">
<button
mat-menu-item
routerLink="/main/dossiers"
translate="top-bar.navigation-items.dossiers"
></button>
<button mat-menu-item routerLink="/main/dossiers" translate="top-bar.navigation-items.dossiers"></button>
<button
*ngIf="appStateService.activeDossier"
[routerLink]="'/main/dossiers/' + appStateService.activeDossierId"
@ -20,22 +16,14 @@
</button>
<button
*ngIf="appStateService.activeFile"
[routerLink]="
'/main/dossiers/' +
appStateService.activeDossierId +
'/file/' +
appStateService.activeFile.fileId
"
[routerLink]="'/main/dossiers/' + appStateService.activeDossierId + '/file/' + appStateService.activeFile.fileId"
mat-menu-item
>
{{ appStateService.activeFile.filename }}
</button>
</mat-menu>
</div>
<div
*ngIf="permissionsService.isUser()"
class="menu flex-2 visible-lg breadcrumbs-container"
>
<div *ngIf="permissionsService.isUser()" class="menu flex-2 visible-lg breadcrumbs-container">
<a
*ngIf="dossiersView"
[routerLinkActiveOptions]="{ exact: true }"
@ -49,15 +37,8 @@
{{ 'top-bar.navigation-items.back' | translate }}
</a>
<ng-container *ngIf="dossiersView">
<mat-icon
*ngIf="!appStateService.activeDossier"
class="primary"
svgIcon="red:arrow-down"
></mat-icon>
<mat-icon
*ngIf="appStateService.activeDossier"
svgIcon="red:arrow-right"
></mat-icon>
<mat-icon *ngIf="!appStateService.activeDossier" class="primary" svgIcon="red:arrow-down"></mat-icon>
<mat-icon *ngIf="appStateService.activeDossier" svgIcon="red:arrow-right"></mat-icon>
<a
*ngIf="appStateService.activeDossier"
[routerLinkActiveOptions]="{ exact: true }"
@ -70,12 +51,7 @@
<mat-icon *ngIf="appStateService.activeFile" svgIcon="red:arrow-right"></mat-icon>
<a
*ngIf="appStateService.activeFile"
[routerLink]="
'/main/dossiers/' +
appStateService.activeDossierId +
'/file/' +
appStateService.activeFile.fileId
"
[routerLink]="'/main/dossiers/' + appStateService.activeDossierId + '/file/' + appStateService.activeFile.fileId"
class="breadcrumb"
routerLinkActive="active"
>
@ -90,26 +66,21 @@
<div class="app-name">{{ titleService.getTitle() }}</div>
</div>
<div class="menu right flex-2">
<redaction-notifications
*ngIf="userPreferenceService.areDevFeaturesEnabled"
class="mr-8"
></redaction-notifications>
<redaction-circle-button
*ngIf="!isSearchScreen"
[icon]="'red:search'"
(action)="openSpotlightSearch()"
[tooltip]="'search.header-label' | translate"
tooltipPosition="below"
></redaction-circle-button>
<redaction-user-button
[matMenuTriggerFor]="userMenu"
[showDot]="showPendingDownloadsDot"
[user]="user"
></redaction-user-button>
<redaction-notifications *ngIf="userPreferenceService.areDevFeaturesEnabled" class="mr-8"></redaction-notifications>
<redaction-user-button [matMenuTriggerFor]="userMenu" [showDot]="showPendingDownloadsDot" [user]="user"></redaction-user-button>
<mat-menu #userMenu="matMenu" xPosition="before">
<ng-container *ngFor="let item of userMenuItems; trackBy: trackByName">
<button
(click)="(item.action)"
*ngIf="item.show"
[routerLink]="item.routerLink"
mat-menu-item
translate
>
<button (click)="(item.action)" *ngIf="item.show" [routerLink]="item.routerLink" mat-menu-item translate>
{{ item.name }}
</button>
</ng-container>
@ -124,10 +95,5 @@
<div class="divider"></div>
</div>
<div
*ngIf="userPreferenceService.areDevFeaturesEnabled"
class="dev-mode"
translate="dev-mode"
></div>
<div *ngIf="userPreferenceService.areDevFeaturesEnabled" class="dev-mode" translate="dev-mode"></div>
<router-outlet></router-outlet>

View File

@ -7,6 +7,10 @@ import { Router } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { FileDownloadService } from '@upload-download/services/file-download.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { SpotlightSearchComponent } from '@components/spotlight-search/spotlight-search.component';
import { SpotlightSearchAction } from '@components/spotlight-search/spotlight-search-action';
import { SpotlightSearchDialogData } from '@components/spotlight-search/spotlight-search-dialog-data';
interface MenuItem {
name: string;
@ -44,6 +48,8 @@ export class BaseScreenComponent {
}
];
showSearch = false;
constructor(
readonly appStateService: AppStateService,
readonly permissionsService: PermissionsService,
@ -52,10 +58,11 @@ export class BaseScreenComponent {
readonly fileDownloadService: FileDownloadService,
private readonly _router: Router,
private readonly _userService: UserService,
private readonly _translateService: TranslateService
private readonly _translateService: TranslateService,
private readonly _dialog: MatDialog
) {
_router.events.subscribe(() => {
this._dossiersView = _router.url.indexOf('/main/dossiers') === 0;
this._dossiersView = _router.url.includes('/main/dossiers') && !this.isSearchScreen;
});
}
@ -65,6 +72,10 @@ export class BaseScreenComponent {
return this._dossiersView;
}
get isSearchScreen() {
return this._router.url.includes('/search');
}
get user() {
return this._userService.user;
}
@ -77,6 +88,44 @@ export class BaseScreenComponent {
return this._translateService.langs;
}
openSpotlightSearch() {
const spotlightSearchActions: SpotlightSearchAction[] = [
{
text: this._translateService.instant('search.this-dossier'),
icon: 'red:enter',
hide: !this.appStateService.activeDossier,
action: query => this._searchThisDossier(query)
},
{
text: this._translateService.instant('search.entire-platform'),
icon: 'red:enter',
action: query => this._searchEntirePlatform(query)
}
];
this._dialog.open(SpotlightSearchComponent, {
data: {
actionsConfig: spotlightSearchActions,
placeholder: this._translateService.instant('search.placeholder')
} as SpotlightSearchDialogData
});
}
private _searchThisDossier(query: string) {
this._router
.navigate(['main/dossiers/search'], {
queryParams: {
query: query,
dossierId: this.appStateService.activeDossier.dossierId
}
})
.then();
}
private _searchEntirePlatform(query: string) {
this._router.navigate(['main/dossiers/search'], { queryParams: { query: query } }).then();
}
logout() {
this._userService.logout();
}

View File

@ -0,0 +1,6 @@
export interface SpotlightSearchAction {
text: string;
action: (query: string) => void;
icon?: string;
hide?: boolean;
}

View File

@ -0,0 +1,6 @@
import { SpotlightSearchAction } from './spotlight-search-action';
export interface SpotlightSearchDialogData {
actionsConfig: SpotlightSearchAction[];
placeholder: string;
}

View File

@ -0,0 +1,43 @@
<div class="spotlight-wrapper">
<form [formGroup]="formGroup">
<div class="search d-flex">
<input
tabindex="-1"
id="query"
type="text"
formControlName="query"
autocomplete="off"
class="spotlight-row"
[placeholder]="data.placeholder"
/>
<mat-icon class="mr-34" *ngIf="(showActions$ | async) === false" [svgIcon]="'red:search'"></mat-icon>
<redaction-circle-button
*ngIf="showActions$ | async"
class="mr-24"
(action)="close()"
icon="red:close"
></redaction-circle-button>
</div>
<div class="divider"></div>
<ng-container *ngIf="showActions$ | async">
<ng-container *ngFor="let item of data.actionsConfig">
<button
*ngIf="!item.hide"
#actions
(keydown.space)="$event.preventDefault()"
(keyup.space)="$event.preventDefault()"
tabindex="0"
class="spotlight-row focus pointer"
(click)="item.action(formGroup.get('query').value); close()"
>
<mat-icon class="mr-16" [svgIcon]="item.icon"></mat-icon>
<span>{{ item.text }}</span>
</button>
</ng-container>
</ng-container>
</form>
</div>

View File

@ -0,0 +1,51 @@
@import 'apps/red-ui/src/assets/styles/red-variables';
.spotlight-wrapper {
overflow: hidden;
width: 750px;
margin: auto;
position: absolute;
top: 15%;
left: 0;
right: 0;
border-radius: 10px;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.3);
}
.spotlight-row {
display: block;
width: 750px;
height: 60px;
margin: auto;
text-align: left;
font-size: 16px;
font-weight: 500;
border: none;
outline: none;
color: $grey-1;
padding: 0 24px;
background-color: $white;
}
.focus:focus {
background-color: $grey-2;
}
.search {
background-color: $white;
align-items: center;
}
.divider {
height: 1px;
background-color: rgba(226, 228, 233, 0.9);
}
input {
width: 668px !important;
}
mat-icon {
width: 14px;
height: 14px;
}

View File

@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, ElementRef, HostListener, Inject, QueryList, ViewChildren } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { debounceTime, map, startWith, tap } from 'rxjs/operators';
import { debounce } from '@utils/debounce';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { SpotlightSearchDialogData } from '@components/spotlight-search/spotlight-search-dialog-data';
@Component({
selector: 'redaction-spotlight-search',
templateUrl: './spotlight-search.component.html',
styleUrls: ['./spotlight-search.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SpotlightSearchComponent {
@ViewChildren('actions')
private readonly _actions: QueryList<ElementRef>;
private _currentActionIdx = 0;
formGroup = this._formBuilder.group({ query: [''] });
query$ = this.formGroup.get('query').valueChanges.pipe(startWith(''));
showActions$ = this.query$.pipe(
debounceTime(300),
tap(value => this._restoreFocusOnAction(value === '')),
map(value => value !== '')
);
constructor(
private readonly _formBuilder: FormBuilder,
private readonly _dialogRef: MatDialogRef<SpotlightSearchComponent>,
@Inject(MAT_DIALOG_DATA)
readonly data: SpotlightSearchDialogData
) {}
close() {
this._dialogRef.close();
}
@HostListener('document:keyup', ['$event'])
handleKeyDown(event: KeyboardEvent) {
console.log(event);
if (event.code === 'ArrowDown' && this._actions) {
this._currentActionIdx++;
return this._restoreFocusOnAction(this._currentActionIdx === this._actions.length);
}
if (event.code === 'ArrowUp' && this._actions) {
if (this._currentActionIdx === 0) this._currentActionIdx = this._actions.length - 1;
else this._currentActionIdx--;
return this._restoreFocusOnAction();
}
if (['Tab'].includes(event.code)) {
return;
}
document.getElementById('query').focus();
let query = this.formGroup.get('query').value as string;
if (event.code === 'Backspace') query = query.substring(0, query.length - 1);
else if (event.key.length === 1) query = query + event.key;
this.formGroup.patchValue({ query: query });
}
@debounce(50)
private _restoreFocusOnAction(resetToFirst = false) {
if (resetToFirst) this._currentActionIdx = 0;
this._actions.find((_, index) => index === this._currentActionIdx)?.nativeElement.focus();
}
}

View File

@ -21,42 +21,39 @@ export class RedRoleGuard implements CanActivate {
this._loadingService.stop();
obs.next(false);
obs.complete();
} else {
// we have at least 1 RED Role -> if it's not user he must be admin
return;
}
// we have at least 1 RED Role -> if it's not user he must be admin
if (
this._userService.user.isUserAdmin &&
!this._userService.user.isAdmin &&
!(
state.url.startsWith('/main/admin/users') ||
state.url.startsWith('/main/my-profile')
)
) {
this._router.navigate(['/main/admin/users']);
obs.next(false);
obs.complete();
return;
}
if (
this._userService.user.isUserAdmin &&
!this._userService.user.isAdmin &&
!(state.url.startsWith('/main/admin/users') || state.url.startsWith('/main/my-profile'))
) {
this._router.navigate(['/main/admin/users']);
obs.next(false);
obs.complete();
return;
}
if (!this._userService.isUser() && state.url.startsWith('/main/dossiers')) {
this._router.navigate(['/main/admin']);
obs.next(false);
obs.complete();
return;
}
if (route.data.requiredRoles) {
if (this._userService.hasAnyRole(route.data.requiredRoles)) {
obs.next(true);
obs.complete();
} else {
this._router.navigate(['/main/dossiers']);
obs.next(false);
obs.complete();
}
} else {
if (!this._userService.isUser() && state.url.startsWith('/main/dossiers')) {
this._router.navigate(['/main/admin']);
obs.next(false);
obs.complete();
return;
}
if (route.data.requiredRoles) {
if (this._userService.hasAnyRole(route.data.requiredRoles)) {
obs.next(true);
obs.complete();
} else {
this._router.navigate(['/main/dossiers']);
obs.next(false);
obs.complete();
}
} else {
obs.next(true);
obs.complete();
}
});
}

View File

@ -27,10 +27,6 @@ mat-slide-toggle {
line-height: 33px;
}
.mr-24 {
margin-right: 24px;
}
mat-slide-toggle {
margin-left: 8px;
margin-right: 5px;

View File

@ -1,15 +1,7 @@
<button
(click)="scroll(buttonType.TOP)"
[hidden]="!showScroll(buttonType.TOP)"
class="scroll-button top pointer"
>
<button (click)="scroll(buttonType.top)" [hidden]="!showScroll(buttonType.top)" class="scroll-button top pointer">
<mat-icon svgIcon="red:arrow-down-o"></mat-icon>
</button>
<button
(click)="scroll(buttonType.BOTTOM)"
[hidden]="!showScroll(buttonType.BOTTOM)"
class="scroll-button bottom pointer"
>
<button (click)="scroll(buttonType.bottom)" [hidden]="!showScroll(buttonType.bottom)" class="scroll-button bottom pointer">
<mat-icon svgIcon="red:arrow-down-o"></mat-icon>
</button>

View File

@ -1,10 +1,12 @@
import { Component, HostListener, Input } from '@angular/core';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
enum ButtonType {
TOP = 'top',
BOTTOM = 'bottom'
}
const ButtonTypes = {
top: 'top',
bottom: 'bottom'
} as const;
type ButtonType = keyof typeof ButtonTypes;
@Component({
selector: 'redaction-scroll-button',
@ -12,7 +14,7 @@ enum ButtonType {
styleUrls: ['./scroll-button.component.scss']
})
export class ScrollButtonComponent {
buttonType = ButtonType;
buttonType = ButtonTypes;
@Input()
scrollViewport: CdkVirtualScrollViewport;
@ -20,9 +22,7 @@ export class ScrollButtonComponent {
itemSize: number;
scroll(type: ButtonType): void {
const viewportSize =
(this.scrollViewport?.getViewportSize() - this.itemSize) *
(type === ButtonType.TOP ? -1 : 1);
const viewportSize = (this.scrollViewport?.getViewportSize() - this.itemSize) * (type === ButtonTypes.top ? -1 : 1);
const scrollOffset = this.scrollViewport?.measureScrollOffset('top');
this.scrollViewport?.scrollToOffset(scrollOffset + viewportSize, 'smooth');
}
@ -37,10 +37,10 @@ export class ScrollButtonComponent {
@HostListener('document:keyup', ['$event'])
spaceAndPageDownScroll(event: KeyboardEvent): void {
if (['Space', 'PageDown'].includes(event.code)) {
this.scroll(ButtonType.BOTTOM);
} else if (['PageUp'].includes(event.code)) {
this.scroll(ButtonType.TOP);
if (['Space', 'PageDown'].includes(event.code) && (event.target as any).tagName === 'BODY') {
this.scroll(ButtonTypes.bottom);
} else if (['PageUp'].includes(event.code) && (event.target as any).tagName === 'BODY') {
this.scroll(ButtonTypes.top);
}
}
}

View File

@ -1,21 +1,21 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { RouterModule, Routes } from '@angular/router';
import { DossierListingScreenComponent } from './screens/dossier-listing-screen/dossier-listing-screen.component';
import { CompositeRouteGuard } from '@guards/composite-route.guard';
import { AuthGuard } from '../auth/auth.guard';
import { RedRoleGuard } from '../auth/red-role.guard';
import { AppStateGuard } from '@state/app-state.guard';
import { DossierOverviewScreenComponent } from './screens/dossier-overview-screen/dossier-overview-screen.component';
import { SearchScreenComponent } from './screens/search-screen/search-screen.component';
import { FilePreviewScreenComponent } from './screens/file-preview-screen/file-preview-screen.component';
import { DossierOverviewScreenComponent } from './screens/dossier-overview-screen/dossier-overview-screen.component';
const routes = [
const routes: Routes = [
{
path: '',
component: DossierListingScreenComponent,
path: 'search',
component: SearchScreenComponent,
canActivate: [CompositeRouteGuard],
data: {
routeGuards: [AuthGuard, RedRoleGuard, AppStateGuard],
reuse: true
routeGuards: [AuthGuard, RedRoleGuard, AppStateGuard]
}
},
{
@ -35,6 +35,16 @@ const routes = [
routeGuards: [AuthGuard, RedRoleGuard, AppStateGuard],
reuse: true
}
},
{
path: '',
pathMatch: 'full',
component: DossierListingScreenComponent,
canActivate: [CompositeRouteGuard],
data: {
routeGuards: [AuthGuard, RedRoleGuard, AppStateGuard],
reuse: true
}
}
];

View File

@ -49,8 +49,9 @@ import { RecategorizeImageDialogComponent } from './dialogs/recategorize-image-d
import { EditDossierAttributesComponent } from './dialogs/edit-dossier-dialog/attributes/edit-dossier-attributes.component';
import { DossiersService } from './services/dossiers.service';
import { DossierDetailsStatsComponent } from './components/dossier-details-stats/dossier-details-stats.component';
import { SearchScreenComponent } from './screens/search-screen/search-screen.component';
const screens = [DossierListingScreenComponent, DossierOverviewScreenComponent, FilePreviewScreenComponent];
const screens = [DossierListingScreenComponent, DossierOverviewScreenComponent, FilePreviewScreenComponent, SearchScreenComponent];
const dialogs = [
AddDossierDialogComponent,

View File

@ -0,0 +1,98 @@
<section *ngIf="searchResults$ | async as searchResult">
<redaction-page-header
[showCloseButton]="true"
[searchPlaceholder]="'search.placeholder' | translate"
[searchWidth]="600"
></redaction-page-header>
<div class="overlay-shadow"></div>
<div class="red-content-inner">
<div class="content-container">
<redaction-table-header
[tableHeaderLabel]="'search-screen.table-header'"
[tableColConfigs]="tableColConfigs"
></redaction-table-header>
<redaction-empty-state
*ngIf="searchResult.length === 0"
[icon]="'red:search'"
[text]="'search-screen.no-data' | translate"
></redaction-empty-state>
<cdk-virtual-scroll-viewport [itemSize]="itemSize" redactionHasScrollbar>
<div
*cdkVirtualFor="let item of sortedDisplayedEntities$ | async; trackBy: trackByPrimaryKey"
[class.pointer]="true"
[routerLink]="item.routerLink"
class="table-item"
>
<div class="filename">
<div [matTooltip]="item.fileName" class="table-item-title heading" matTooltipPosition="above">
<span
*ngIf="item.highlights.filename; else defaultFilename"
class="highlights"
[innerHTML]="item.highlights.filename[0]"
></span>
<ng-template #defaultFilename>{{ item.fileName }}</ng-template>
</div>
<ng-container *ngIf="item.highlights['sections.text'] as highlights">
<div class="small-label" *ngIf="highlights.length > 0">
<span class="highlights" [innerHTML]="highlights[0]"></span>
</div>
<div class="small-label" *ngIf="highlights.length > 1">
<span class="highlights" [innerHTML]="highlights[1]"></span>
</div>
</ng-container>
<div class="small-label" *ngIf="item.unmatched as unmatched">
<span>
{{ 'search-screen.missing' | translate }}:<span *ngFor="let term of unmatched"
>&nbsp;<s>{{ term }}</s></span
>.&nbsp;{{ 'search-screen.must-contain' | translate }}:
<span
*ngFor="let term of unmatched"
(click)="$event.stopPropagation(); updateNavigation({ query: search$.getValue(), mustContain: term })"
>&nbsp;<u>{{ term }}</u></span
>
</span>
</div>
</div>
<div>
<redaction-status-bar
[small]="true"
[config]="[
{
color: item.status,
label: item.status | translate,
length: 1
}
]"
></redaction-status-bar>
</div>
<div class="small-label">
{{ item.dossierName }}
</div>
<div class="small-label stats-subtitle">
<div>
<mat-icon svgIcon="red:pages"></mat-icon>
{{ item.pages }}
</div>
</div>
<div class="scrollbar-placeholder"></div>
</div>
</cdk-virtual-scroll-viewport>
<redaction-scroll-button
*ngIf="(screenStateService.noData$ | async) === false"
[itemSize]="itemSize"
[scrollViewport]="scrollViewport"
></redaction-scroll-button>
</div>
</div>
</section>

View File

@ -0,0 +1,38 @@
@import 'apps/red-ui/src/assets/styles/red-mixins';
@import 'apps/red-ui/src/assets/styles/red-variables';
.content-container {
position: relative;
cdk-virtual-scroll-viewport {
::ng-deep.cdk-virtual-scroll-content-wrapper {
grid-template-columns: 2fr 1fr 1fr auto 11px;
.table-item {
> div {
height: 85px;
padding: 0 24px;
}
.status-container {
width: 160px;
padding-right: 13px;
}
.highlights em {
background-color: #fffcc4;
}
.highlights {
@include line-clamp(1);
}
}
}
&.has-scrollbar:hover {
::ng-deep.cdk-virtual-scroll-content-wrapper {
grid-template-columns: 2fr 1fr 1fr auto;
}
}
}
}

View File

@ -0,0 +1,155 @@
import { Component, Injector, OnDestroy } from '@angular/core';
import { BaseListingComponent } from '../../../shared/base/base-listing.component';
import { SearchControllerService, SearchResult } from '@redaction/red-ui-http';
import { BehaviorSubject, Observable } from 'rxjs';
import { debounceTime, map, switchMap, tap } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { MatchedDocument } from '@redaction/red-ui-http';
import { TableColConfig } from '../../../shared/components/table-col-name/table-col-name.component';
import { FilterService } from '../../../shared/services/filter.service';
import { SearchService } from '../../../shared/services/search.service';
import { ScreenStateService } from '../../../shared/services/screen-state.service';
import { SortingService } from '../../../../services/sorting.service';
import { AppStateService } from '../../../../state/app-state.service';
import { FileStatusWrapper } from '../../../../models/file/file-status.wrapper';
import { TranslateService } from '@ngx-translate/core';
import { LoadingService } from '../../../../services/loading.service';
interface ListItem {
fileName: string;
matchedDocument: MatchedDocument;
unmatched: string[] | null;
highlights: { [key: string]: string[] };
routerLink: string;
status: string;
dossierName: string;
pages: number;
}
@Component({
templateUrl: './search-screen.component.html',
styleUrls: ['./search-screen.component.scss'],
providers: [FilterService, SearchService, ScreenStateService, SortingService]
})
export class SearchScreenComponent extends BaseListingComponent<ListItem> implements OnDestroy {
protected readonly _primaryKey = 'fileName';
readonly itemSize = 85;
readonly search$ = new BehaviorSubject<string>(null);
readonly searchResults$: Observable<ListItem[]> = this.search$.asObservable().pipe(
switchMap(query => this._search(query)),
map(searchResult => this._toMatchedDocuments(searchResult)),
map(documents => this._toListItems(documents)),
tap(result => this.screenStateService.setEntities(result)),
tap(() => this._loadingService.stop())
);
private _dossierId: string;
readonly tableColConfigs: TableColConfig[] = [
{
label: this._translateService.instant('search-screen.cols.document')
},
{
label: this._translateService.instant('search-screen.cols.status')
},
{
label: this._translateService.instant('search-screen.cols.dossier')
},
{
label: this._translateService.instant('search-screen.cols.pages')
}
];
constructor(
protected readonly _injector: Injector,
private readonly _searchControllerService: SearchControllerService,
private readonly _activatedRoute: ActivatedRoute,
private readonly _appStateService: AppStateService,
private readonly _translateService: TranslateService,
private readonly _loadingService: LoadingService,
private readonly _router: Router
) {
super(_injector);
this.addSubscription = _activatedRoute.queryParamMap
.pipe(
tap(() => this._loadingService.start()),
map(value => ({ query: value.get('query'), dossierId: value.get('dossierId') })),
tap(mappedValue => this._updateValues(mappedValue))
)
.subscribe();
this.addSubscription = this.searchService.searchForm
.get('query')
.valueChanges.pipe(debounceTime(300))
.subscribe(value => this.updateNavigation({ query: value }));
}
setInitialConfig() {
return;
}
updateNavigation({ query, mustContain }: { readonly query: string; readonly mustContain?: string }) {
const newQuery = query?.replace(mustContain, `"${mustContain}"`);
const queryParams = newQuery && newQuery !== '' ? { query: newQuery } : {};
const queryParamsHandling = this._dossierId ? 'merge' : '';
this._router
.navigate([], {
queryParams,
queryParamsHandling
})
.then();
}
private _search(query: string): Observable<SearchResult> {
return this._searchControllerService.search({
dossierId: this._dossierId,
queryString: query ?? '',
from: 0,
returnSections: true,
size: 100
});
}
private _updateValues({ query, dossierId }: { readonly query: string; readonly dossierId: string }) {
this._dossierId = dossierId;
this.searchService.searchValue = query;
this.search$.next(query);
}
private _getFileWrapper(dossierId: string, fileId: string): FileStatusWrapper {
return this._appStateService.getFileById(dossierId, fileId);
}
private _getDossierWrapper(dossierId: string) {
return this._appStateService.getDossierById(dossierId);
}
private _toMatchedDocuments({ matchedDocuments }: SearchResult) {
return matchedDocuments.filter(doc => doc.score > 0 && doc.matchedTerms.length > 0);
}
private _toListItems(matchedDocuments: MatchedDocument[]) {
return matchedDocuments
.map<ListItem>(document => {
const fileStatus = this._getFileWrapper(document.dossierId, document.fileId);
if (!fileStatus) {
return undefined;
}
const { dossierId, dossierName } = this._getDossierWrapper(document.dossierId);
return {
matchedDocument: document,
unmatched: document.unmatchedTerms.length ? document.unmatchedTerms : null,
highlights: document.highlights,
status: fileStatus.status,
pages: fileStatus.numberOfPages,
dossierName: dossierName,
fileName: fileStatus.filename,
routerLink: `/main/dossiers/${dossierId}/file/${fileStatus.fileId}`
} as ListItem;
})
.filter(value => value);
}
}

View File

@ -9,10 +9,7 @@ import { DomSanitizer } from '@angular/platform-browser';
exports: [MatIconModule]
})
export class IconsModule {
constructor(
private readonly _iconRegistry: MatIconRegistry,
private readonly _sanitizer: DomSanitizer
) {
constructor(private readonly _iconRegistry: MatIconRegistry, private readonly _sanitizer: DomSanitizer) {
const icons = [
'add',
'analyse',
@ -38,6 +35,7 @@ export class IconsModule {
'download',
'edit',
'entries',
'enter',
'error',
'exclude-pages',
'exit-fullscreen',

View File

@ -17,7 +17,7 @@ export abstract class BaseListingComponent<T> extends AutoUnsubscribeComponent i
readonly permissionsService: PermissionsService;
readonly filterService: FilterService;
readonly sortingService: SortingService;
readonly searchService: SearchService<T>;
readonly searchService: SearchService;
readonly screenStateService: ScreenStateService<T>;
readonly sortedDisplayedEntities$: Observable<T[]>;

View File

@ -11,11 +11,7 @@
<span *ngIf="hint" [translate]="hint" class="hint"></span>
<!-- Search-->
<mat-icon
*ngIf="type === 'search' && !hasContent"
class="icon-right"
svgIcon="red:search"
></mat-icon>
<mat-icon *ngIf="type === 'search' && !hasContent" class="icon-right" svgIcon="red:search"></mat-icon>
<redaction-circle-button
(action)="clearContent()"
@ -23,8 +19,7 @@
[disabled]="form.invalid"
[size]="25"
icon="red:close"
>
</redaction-circle-button>
></redaction-circle-button>
<!-- Submit-->
<redaction-circle-button
@ -34,7 +29,6 @@
[icon]="icon"
[isSubmit]="true"
[size]="25"
>
</redaction-circle-button>
></redaction-circle-button>
</div>
</form>

View File

@ -29,7 +29,7 @@ export class InputWithActionComponent {
}
clearContent() {
this.form.patchValue({ query: '' });
this.form.patchValue({ query: '' }, { emitEvent: true });
}
executeAction($event?: MouseEvent) {

View File

@ -1,7 +1,7 @@
<div class="page-header">
<div *ngIf="pageLabel" class="breadcrumb">{{ pageLabel }}</div>
<div class="filters" *ngIf="filters$ | async as filters">
<div class="filters" [style.max-width]="computedWidth" [style.width]="computedWidth" *ngIf="filters$ | async as filters">
<div translate="filters.filter-by" *ngIf="filters.length"></div>
<ng-container *ngFor="let config of filters; trackBy: trackByLabel">
@ -16,6 +16,7 @@
</ng-container>
<redaction-input-with-action
[width]="searchWidth"
*ngIf="searchPlaceholder"
[form]="searchService.searchForm"
[placeholder]="searchPlaceholder"

View File

@ -11,17 +11,18 @@ import { combineLatest, Observable, of } from 'rxjs';
templateUrl: './page-header.component.html',
styleUrls: ['./page-header.component.scss']
})
export class PageHeaderComponent<T> {
export class PageHeaderComponent {
@Input() pageLabel: string;
@Input() showCloseButton: boolean;
@Input() actionConfigs: ActionConfig[];
@Input() buttonConfigs: ButtonConfig[];
@Input() searchPlaceholder: string;
@Input() searchWidth: number | 'full';
readonly filters$ = this.filterService?.filterGroups$.pipe(map(all => all.filter(f => f.icon)));
readonly showResetFilters$ = this._showResetFilters$;
constructor(@Optional() readonly filterService: FilterService, @Optional() readonly searchService: SearchService<T>) {}
constructor(@Optional() readonly filterService: FilterService, @Optional() readonly searchService: SearchService) {}
get _showResetFilters$(): Observable<boolean> {
if (!this.filterService) return of(false);
@ -36,6 +37,10 @@ export class PageHeaderComponent<T> {
);
}
get computedWidth() {
return this.searchWidth === 'full' ? '100%' : `${this.searchWidth}px`;
}
resetFilters(): void {
this.filterService.reset();
this.searchService.reset();

View File

@ -25,8 +25,8 @@ export class SyncWidthDirective implements AfterViewInit, OnDestroy {
@debounce(10)
matchWidth() {
const headerItems = this._elementRef.nativeElement.children;
// const tableRows = document.getElementsByClassName(this.redactionSyncWidth);
const tableRows = this._elementRef.nativeElement.parentElement.getElementsByClassName(this.redactionSyncWidth);
const tableRows = document.getElementsByClassName(this.redactionSyncWidth);
// const tableRows = this._elementRef.nativeElement.parentElement.getElementsByClassName(this.redactionSyncWidth);
if (!tableRows || !tableRows.length) {
return;

View File

@ -26,7 +26,7 @@ export class ScreenStateService<T> {
readonly areSomeEntitiesSelected$ = this._areSomeEntitiesSelected$;
readonly notAllEntitiesSelected$ = this._notAllEntitiesSelected$;
constructor(private readonly _filterService: FilterService, private readonly _searchService: SearchService<T>) {
constructor(private readonly _filterService: FilterService, private readonly _searchService: SearchService) {
// setInterval(() => {
// console.log('All entities subs: ', this._allEntities$.observers);
// console.log('Displayed entities subs: ', this._displayedEntities$.observers);

View File

@ -3,7 +3,7 @@ import { FormBuilder } from '@angular/forms';
import { startWith } from 'rxjs/operators';
@Injectable()
export class SearchService<T> {
export class SearchService {
private _searchKey: string;
readonly searchForm = this._formBuilder.group({
@ -14,7 +14,7 @@ export class SearchService<T> {
constructor(private readonly _formBuilder: FormBuilder) {}
searchIn(entities: T[]) {
searchIn<T>(entities: T[]) {
if (!this._searchKey) return entities;
const searchValue = this.searchValue.toLowerCase();
@ -29,11 +29,15 @@ export class SearchService<T> {
return this.searchForm.get('query').value;
}
reset(): void {
this.searchForm.reset({ query: '' });
set searchValue(value: string) {
this.searchForm.patchValue({ query: value });
}
private _searchField(entity: T): string {
return entity[this._searchKey].toLowerCase();
reset(): void {
this.searchForm.reset({ query: '' }, { emitEvent: true });
}
private _searchField<T>(entity: T): string {
return entity[this._searchKey].toString().toLowerCase();
}
}

View File

@ -10,7 +10,7 @@ export class LoadingService {
private readonly _loadingEvent = new BehaviorSubject(false);
private _loadingStarted: number;
get isLoading(): Observable<boolean> {
get isLoading$(): Observable<boolean> {
return this._loadingEvent.asObservable();
}

View File

@ -10,7 +10,7 @@ export class RouterHistoryService {
constructor(private readonly _router: Router) {
this._router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: NavigationEnd) => {
if (event.url.startsWith('/main/dossiers')) {
if (event.url.startsWith('/main/dossiers') && !event.url.includes('/search')) {
this._lastDossiersScreen = event.url;
}
});

View File

@ -9,7 +9,7 @@ import {
ReanalysisControllerService,
StatusControllerService
} from '@redaction/red-ui-http';
import { Toaster } from '../services/toaster.service';
import { Toaster } from '@services/toaster.service';
import { TranslateService } from '@ngx-translate/core';
import { Event, NavigationEnd, ResolveStart, Router } from '@angular/router';
import { UserService } from '@services/user.service';
@ -156,7 +156,12 @@ export class AppStateService {
}
private static _isDossierOverviewRoute(event: Event) {
return event instanceof ResolveStart && event.url.includes('/main/dossiers/') && !event.url.includes('/file/');
return (
event instanceof ResolveStart &&
event.url.includes('/main/dossiers/') &&
!event.url.includes('/file/') &&
!event.url.includes('/search')
);
}
private static _isRandomRoute(event: Event) {
@ -205,7 +210,7 @@ export class AppStateService {
}
getFileById(dossierId: string, fileId: string) {
return this.getDossierById(dossierId).files.find(file => file.fileId === fileId);
return this.getDossierById(dossierId)?.files.find(file => file.fileId === fileId);
}
async loadAllDossiers(emitEvents: boolean = true) {

View File

@ -1435,5 +1435,24 @@
"hours": "hours",
"day": "day",
"days": "days"
},
"search": {
"header-label": "Search entire platform",
"placeholder": "Search for documents or document content",
"this-dossier": "in this dossier",
"entire-platform": "entire platform",
"close": "Close"
},
"search-screen": {
"table-header": "{{length}} search results",
"cols": {
"document": "Document",
"status": "Status",
"dossier": "Dossier",
"pages": "Pages"
},
"no-data": "Please enter a keyword in the search input to look for documents or document content.",
"missing": "Missing",
"must-contain": "Must contain"
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<title>B99F3D5E-879A-47E3-A8C7-84877EE418F3</title>
<g id="04.Search-all-in-Document" transform="translate(-319.000000, -144.000000)">
<g id="Group-30" transform="translate(295.000000, 121.000000)">
<g id="collapse" transform="translate(24.000000, 23.000000)" fill="currentColor"
fill-rule="nonzero">
<path
d="M2.38,0 L2.38,7.98 L10.5,7.98 L8.75,6.16 L9.73,5.18 L13.16,8.68 L9.73,12.18 L8.75,11.2 L10.5,9.38 L1.96,9.38 L1.959,9.379 L0.98,9.38 L0.98,0 L2.38,0 Z"
id="Combined-Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -256,6 +256,10 @@ section.settings {
margin-left: 14px;
}
.mr-24 {
margin-right: 24px;
}
.pb-24 {
padding-bottom: 24px;
}
@ -358,6 +362,10 @@ section.settings {
margin-right: 16px;
}
.mr-34 {
margin-right: 34px;
}
.fit-content {
width: fit-content;
}

View File

@ -78,3 +78,4 @@ export * from './legalBasisChangeRequest';
export * from './manualLegalBasisChange';
export * from './searchRequest';
export * from './searchResult';
export * from './matchedDocument';