diff --git a/apps/red-ui/src/app/app-routing.module.ts b/apps/red-ui/src/app/app-routing.module.ts index f8063def5..5fe4abfad 100644 --- a/apps/red-ui/src/app/app-routing.module.ts +++ b/apps/red-ui/src/app/app-routing.module.ts @@ -3,12 +3,12 @@ import { AuthGuard } from './modules/auth/auth.guard'; import { CompositeRouteGuard, CustomRouteReuseStrategy } from '@iqser/common-ui'; import { RedRoleGuard } from './modules/auth/red-role.guard'; import { BaseScreenComponent } from '@components/base-screen/base-screen.component'; -import { RouteReuseStrategy, RouterModule } from '@angular/router'; +import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'; import { NgModule } from '@angular/core'; import { DownloadsListScreenComponent } from '@components/downloads-list-screen/downloads-list-screen.component'; import { AppStateGuard } from '@state/app-state.guard'; -const routes = [ +const routes: Routes = [ { path: '', redirectTo: 'main/dossiers', diff --git a/apps/red-ui/src/app/app.module.ts b/apps/red-ui/src/app/app.module.ts index 32387e151..824f27453 100644 --- a/apps/red-ui/src/app/app.module.ts +++ b/apps/red-ui/src/app/app.module.ts @@ -35,6 +35,7 @@ import * as links from '../assets/help-mode/links.json'; import { HELP_DOCS, IqserHelpModeModule, MAX_RETRIES_ON_SERVER_ERROR, ServerErrorInterceptor, ToastComponent } from '@iqser/common-ui'; import { KeycloakService } from 'keycloak-angular'; import { GeneralSettingsService } from '@services/general-settings.service'; +import { BreadcrumbsComponent } from './components/breadcrumbs/breadcrumbs.component'; export function httpLoaderFactory(httpClient: HttpClient): PruningTranslationLoader { return new PruningTranslationLoader(httpClient, '/assets/i18n/', '.json'); @@ -55,7 +56,7 @@ const screens = [BaseScreenComponent, DownloadsListScreenComponent]; const components = [AppComponent, AuthErrorComponent, NotificationsComponent, SpotlightSearchComponent, ...screens]; @NgModule({ - declarations: [...components], + declarations: [...components, BreadcrumbsComponent], imports: [ BrowserModule, BrowserAnimationsModule, diff --git a/apps/red-ui/src/app/components/base-screen/base-screen.component.html b/apps/red-ui/src/app/components/base-screen/base-screen.component.html index cd67b2fef..6a7584830 100644 --- a/apps/red-ui/src/app/components/base-screen/base-screen.component.html +++ b/apps/red-ui/src/app/components/base-screen/base-screen.component.html @@ -4,41 +4,7 @@
diff --git a/apps/red-ui/src/app/components/base-screen/base-screen.component.ts b/apps/red-ui/src/app/components/base-screen/base-screen.component.ts index 5816397e5..3bfa76673 100644 --- a/apps/red-ui/src/app/components/base-screen/base-screen.component.ts +++ b/apps/red-ui/src/app/components/base-screen/base-screen.component.ts @@ -11,6 +11,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { filter, map, startWith } from 'rxjs/operators'; import { DossiersService } from '@services/entity-services/dossiers.service'; import { shareDistinctLast } from '@iqser/common-ui'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; interface MenuItem { readonly name: string; @@ -21,7 +22,6 @@ interface MenuItem { } const isNavigationStart = event => event instanceof NavigationStart; -const isDossiersView = url => url.includes('/main/dossiers') && !url.includes('/search'); const isSearchScreen = url => url.includes('/main/dossiers') && url.includes('/search'); @Component({ @@ -62,8 +62,8 @@ export class BaseScreenComponent { { text: this._translateService.instant('search.this-dossier'), icon: 'red:enter', - hide: (): boolean => true, // TODO - action: (query): void => this._search(query, 'todo'), // TODO + hide: (): boolean => this._hideSearchThisDossier, + action: (query): void => this._searchThisDossier(query), }, { text: this._translateService.instant('search.entire-platform'), @@ -77,7 +77,6 @@ export class BaseScreenComponent { startWith(this._router.url), shareDistinctLast(), ); - readonly isDossiersView$ = this._navigationStart$.pipe(map(isDossiersView)); readonly isSearchScreen$ = this._navigationStart$.pipe(map(isSearchScreen)); constructor( @@ -89,8 +88,19 @@ export class BaseScreenComponent { readonly fileDownloadService: FileDownloadService, private readonly _router: Router, private readonly _translateService: TranslateService, + readonly breadcrumbsService: BreadcrumbsService, ) {} + private get _hideSearchThisDossier() { + const routerLink = this.breadcrumbsService.breadcrumbs[1]?.routerLink; + if (!routerLink) { + return true; + } + + const isDossierOverview = routerLink.includes('dossiers') && routerLink.length === 3; + return !isDossierOverview; + } + trackByName(index: number, item: MenuItem) { return item.name; } @@ -99,4 +109,13 @@ export class BaseScreenComponent { const queryParams = { query, dossierId }; this._router.navigate(['main/dossiers/search'], { queryParams }).then(); } + + private _searchThisDossier(query: string) { + const routerLink = this.breadcrumbsService.breadcrumbs[1]?.routerLink; + if (!routerLink) { + return this._search(query); + } + const dossierId = routerLink[2]; + return this._search(query, dossierId); + } } diff --git a/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.html b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 000000000..e1993a09b --- /dev/null +++ b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,25 @@ + + + + {{ 'top-bar.navigation-items.back' | translate }} + + + + + + + + {{ breadcrumb.name$ | async }} + + + + diff --git a/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.scss b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.scss new file mode 100644 index 000000000..e04aa5995 --- /dev/null +++ b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.scss @@ -0,0 +1,10 @@ +:host { + display: flex; + align-items: center; + overflow: hidden; + height: 100%; + + > *:not(:last-child) { + margin-right: 6px; + } +} diff --git a/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.ts b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 000000000..9b2256831 --- /dev/null +++ b/apps/red-ui/src/app/components/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; + +@Component({ + selector: 'redaction-breadcrumbs', + templateUrl: './breadcrumbs.component.html', + styleUrls: ['./breadcrumbs.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BreadcrumbsComponent { + constructor(readonly breadcrumbsService: BreadcrumbsService) {} +} diff --git a/apps/red-ui/src/app/guards/go-back-guard.service.ts b/apps/red-ui/src/app/guards/go-back-guard.service.ts new file mode 100644 index 000000000..893f470f4 --- /dev/null +++ b/apps/red-ui/src/app/guards/go-back-guard.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, CanDeactivate } from '@angular/router'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; + +@Injectable({ providedIn: 'root' }) +export class GoBackGuard implements CanActivate, CanDeactivate { + constructor(private readonly _breadcrumbsService: BreadcrumbsService) {} + + canActivate(): boolean { + this._breadcrumbsService.showGoBack(); + return true; + } + + canDeactivate() { + this._breadcrumbsService.hideGoBack(); + return true; + } +} diff --git a/apps/red-ui/src/app/modules/dossier/dossiers-routing.module.ts b/apps/red-ui/src/app/modules/dossier/dossiers-routing.module.ts index 6136a7069..011792c21 100644 --- a/apps/red-ui/src/app/modules/dossier/dossiers-routing.module.ts +++ b/apps/red-ui/src/app/modules/dossier/dossiers-routing.module.ts @@ -1,24 +1,29 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { CompositeRouteGuard } from '@iqser/common-ui'; import { SearchScreenComponent } from './screens/search-screen/search-screen.component'; import { FilePreviewScreenComponent } from './screens/file-preview-screen/file-preview-screen.component'; -import { FilesGuard } from './utils/file.guard'; +import { FilesGuard } from './guards/file.guard'; +import { DossiersGuard } from './guards/dossiers.guard'; +import { GoBackGuard } from '@guards/go-back-guard.service'; const routes: Routes = [ { path: 'search', component: SearchScreenComponent, + canActivate: [GoBackGuard], + canDeactivate: [GoBackGuard], }, { path: ':dossierId', - canActivate: [CompositeRouteGuard], + canActivate: [DossiersGuard], + canDeactivate: [DossiersGuard], loadChildren: () => import('./screens/dossier-overview/dossier-overview.module').then(m => m.DossierOverviewModule), }, { path: ':dossierId/file/:fileId', component: FilePreviewScreenComponent, - canActivate: [FilesGuard], + canActivate: [DossiersGuard, FilesGuard], + canDeactivate: [FilesGuard], data: { reuse: true, }, @@ -27,6 +32,8 @@ const routes: Routes = [ path: '', pathMatch: 'full', loadChildren: () => import('./screens/dossiers-listing/dossiers-listing.module').then(m => m.DossiersListingModule), + canActivate: [DossiersGuard], + canDeactivate: [DossiersGuard], }, ]; diff --git a/apps/red-ui/src/app/modules/dossier/dossiers.module.ts b/apps/red-ui/src/app/modules/dossier/dossiers.module.ts index 1100ecb71..babf35e58 100644 --- a/apps/red-ui/src/app/modules/dossier/dossiers.module.ts +++ b/apps/red-ui/src/app/modules/dossier/dossiers.module.ts @@ -40,7 +40,11 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { SharedDossiersModule } from './shared/shared-dossiers.module'; import { PlatformSearchService } from './shared/services/platform-search.service'; import { ResizeAnnotationDialogComponent } from './dialogs/resize-annotation-dialog/resize-annotation-dialog.component'; -import { FilesGuard } from './utils/file.guard'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; +import { of } from 'rxjs'; +import { FilesGuard } from './guards/file.guard'; +import { DossiersGuard } from './guards/dossiers.guard'; +import { TranslateService } from '@ngx-translate/core'; const screens = [FilePreviewScreenComponent, SearchScreenComponent]; @@ -91,7 +95,15 @@ const services = [ @NgModule({ declarations: [...components], - providers: [...services, FilesGuard], + providers: [...services, FilesGuard, DossiersGuard], imports: [CommonModule, SharedModule, SharedDossiersModule, FileUploadDownloadModule, DossiersRoutingModule, OverlayModule], }) -export class DossiersModule {} +export class DossiersModule { + constructor(breadcrumbsService: BreadcrumbsService, translateService: TranslateService) { + breadcrumbsService.append({ + name$: of(translateService.instant('top-bar.navigation-items.dossiers')), + routerLink: ['/main', 'dossiers'], + routerLinkActiveOptions: { exact: true }, + }); + } +} diff --git a/apps/red-ui/src/app/modules/dossier/guards/dossiers.guard.ts b/apps/red-ui/src/app/modules/dossier/guards/dossiers.guard.ts new file mode 100644 index 000000000..e7fb484dc --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/guards/dossiers.guard.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, CanDeactivate, Router, RouterStateSnapshot } from '@angular/router'; +import { AppStateService } from '@state/app-state.service'; +import { DossiersService } from '@services/entity-services/dossiers.service'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; +import { pluck } from 'rxjs/operators'; +import { LoadingService } from '@iqser/common-ui'; + +@Injectable() +export class DossiersGuard implements CanActivate, CanDeactivate { + constructor( + private readonly _dossiersService: DossiersService, + private readonly _appStateService: AppStateService, + private readonly _loadingService: LoadingService, + private readonly _breadcrumbsService: BreadcrumbsService, + private readonly _router: Router, + ) {} + + async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise { + if (state.url === '/main/dossiers') { + this._breadcrumbsService.hideGoBack(); + return true; + } + + const dossierId = route.paramMap.get('dossierId'); + + if (!this._dossiersService.find(dossierId)) { + this._loadingService.start(); + await this._appStateService.loadAllDossiers(); + this._loadingService.stop(); + + if (!this._dossiersService.find(dossierId)) { + await this._router.navigate(['/main', 'dossiers']); + return false; + } + } + + this._breadcrumbsService.append({ + name$: this._dossiersService.getEntityChanged$(dossierId).pipe(pluck('dossierName')), + routerLink: ['/main', 'dossiers', dossierId], + routerLinkActiveOptions: { exact: true }, + }); + + this._breadcrumbsService.hideGoBack(); + return true; + } + + canDeactivate( + component: unknown, + currentRoute: ActivatedRouteSnapshot, + currentState: RouterStateSnapshot, + nextState?: RouterStateSnapshot, + ) { + const dossierId = currentRoute.paramMap.get('dossierId'); + this._breadcrumbsService.remove(['/main', 'dossiers', dossierId]); + if (!nextState.url.startsWith('/main/dossiers')) { + this._breadcrumbsService.showGoBack(); + } + return true; + } +} diff --git a/apps/red-ui/src/app/modules/dossier/guards/file.guard.ts b/apps/red-ui/src/app/modules/dossier/guards/file.guard.ts new file mode 100644 index 000000000..791da361e --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/guards/file.guard.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, CanDeactivate, Router, RouterStateSnapshot } from '@angular/router'; +import { FilesMapService } from '@services/entity-services/files-map.service'; +import { AppStateService } from '@state/app-state.service'; +import { DossiersService } from '@services/entity-services/dossiers.service'; +import { BreadcrumbsService } from '@services/breadcrumbs.service'; +import { pluck } from 'rxjs/operators'; +import { LoadingService } from '@iqser/common-ui'; + +@Injectable() +export class FilesGuard implements CanActivate, CanDeactivate { + constructor( + private readonly _filesMapService: FilesMapService, + private readonly _dossiersService: DossiersService, + private readonly _appStateService: AppStateService, + private readonly _breadcrumbsService: BreadcrumbsService, + private readonly _loadingService: LoadingService, + private readonly _router: Router, + ) {} + + async canActivate(route: ActivatedRouteSnapshot): Promise { + const dossierId = route.paramMap.get('dossierId'); + const fileId = route.paramMap.get('fileId'); + + if (!this._filesMapService.get(dossierId, fileId)) { + this._loadingService.start(); + const dossier = this._dossiersService.find(dossierId); + await this._appStateService.getFiles(dossier); + this._loadingService.stop(); + + if (!this._filesMapService.get(dossierId, fileId)) { + await this._router.navigate([dossier.routerLink]); + return false; + } + } + + this._breadcrumbsService.append({ + name$: this._filesMapService.watch$(dossierId, fileId).pipe(pluck('filename')), + routerLink: ['/main', 'dossiers', dossierId, 'file', fileId], + }); + + return true; + } + + canDeactivate( + component: unknown, + currentRoute: ActivatedRouteSnapshot, + currentState: RouterStateSnapshot, + nextState?: RouterStateSnapshot, + ) { + const dossierId = currentRoute.paramMap.get('dossierId'); + const fileId = currentRoute.paramMap.get('fileId'); + this._breadcrumbsService.remove(['/main', 'dossiers', dossierId, 'file', fileId]); + + if (!nextState.root.paramMap.get('dossierId')) { + this._breadcrumbsService.remove(['/main', 'dossiers', dossierId]); + } + + if (!nextState.url.startsWith('/main/dossiers')) { + this._breadcrumbsService.showGoBack(); + } + + return true; + } +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts index 6bb429dca..97941b9ce 100644 --- a/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts +++ b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts @@ -122,11 +122,12 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni private readonly _userPreferenceService: UserPreferenceService, ) { super(); - this._loadingService.start(); + _loadingService.start(); this.dossierId = _activatedRoute.snapshot.paramMap.get('dossierId'); - this.dossier$ = this._dossiersService.getEntityChanged$(this.dossierId); + this.dossier$ = _dossiersService.getEntityChanged$(this.dossierId); this.fileId = _activatedRoute.snapshot.paramMap.get('fileId'); this.file$ = _filesMapService.watch$(this.dossierId, this.fileId); + document.documentElement.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement) { this.fullScreen = false; @@ -259,10 +260,9 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni } async ngOnInit(): Promise { + this._loadingService.start(); await this._userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId); - await this._loadFileData(); - this.displayPDFViewer = true; this._updateCanPerformActions(); this._subscribeToFileUpdates(); @@ -270,6 +270,9 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni if (file?.analysisRequired) { this.fileActions.reanalyseFile(); } + + this._loadingService.stop(); + this.displayPDFViewer = true; } rebuildFilters(deletePreviousAnnotations = false): void { diff --git a/apps/red-ui/src/app/modules/dossier/utils/file.guard.ts b/apps/red-ui/src/app/modules/dossier/utils/file.guard.ts deleted file mode 100644 index 566eddbfc..000000000 --- a/apps/red-ui/src/app/modules/dossier/utils/file.guard.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; -import { FilesMapService } from '@services/entity-services/files-map.service'; -import { AppStateService } from '@state/app-state.service'; -import { DossiersService } from '@services/entity-services/dossiers.service'; - -@Injectable() -export class FilesGuard implements CanActivate { - constructor( - private readonly _filesMapService: FilesMapService, - private readonly _dossiersService: DossiersService, - private readonly _appStateService: AppStateService, - private readonly _router: Router, - ) {} - - async canActivate(route: ActivatedRouteSnapshot): Promise { - const dossierId = route.paramMap.get('dossierId'); - const fileId = route.paramMap.get('fileId'); - - if (!this._filesMapService.get(dossierId, fileId)) { - const dossier = this._dossiersService.find(dossierId); - await this._appStateService.getFiles(dossier); - - if (!this._filesMapService.get(dossierId, fileId)) { - await this._router.navigate([dossier.routerLink]); - return false; - } - } - - return true; - } -} diff --git a/apps/red-ui/src/app/services/breadcrumbs.service.ts b/apps/red-ui/src/app/services/breadcrumbs.service.ts new file mode 100644 index 000000000..4d6b7d478 --- /dev/null +++ b/apps/red-ui/src/app/services/breadcrumbs.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { List, shareDistinctLast } from '@iqser/common-ui'; +import { IsActiveMatchOptions } from '@angular/router'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export type RouterLinkActiveOptions = { exact: boolean } | IsActiveMatchOptions; + +export interface Breadcrumb { + readonly name$: Observable; + readonly routerLink?: string[]; + readonly routerLinkActiveOptions?: RouterLinkActiveOptions; +} + +export type Breadcrumbs = List; + +@Injectable({ + providedIn: 'root', +}) +export class BreadcrumbsService { + readonly breadcrumbs$: Observable; + readonly showGoBack$: Observable; + private readonly _showGoBack$ = new BehaviorSubject(false); + private readonly _store$ = new BehaviorSubject([]); + + constructor() { + this.breadcrumbs$ = this._store$.asObservable(); + this.showGoBack$ = this._showGoBack$.asObservable().pipe(shareDistinctLast()); + } + + get breadcrumbs() { + return this._store$.value; + } + + append(breadcrumb: Breadcrumb) { + const existing = this._store$.value.find(item => item.routerLink.toString() === breadcrumb.routerLink.toString()); + if (existing) { + this.remove(existing.routerLink); + } + this._store$.next([...this._store$.value, breadcrumb]); + } + + clear() { + this._store$.next([]); + } + + remove(routerLink: string[]) { + this._store$.next(this._store$.value.filter(item => item.routerLink.toString() !== routerLink.toString())); + } + + showGoBack() { + this._showGoBack$.next(true); + } + + hideGoBack() { + this._showGoBack$.next(false); + } +}