diff --git a/apps/red-ui/src/app/app.module.ts b/apps/red-ui/src/app/app.module.ts index f5966db37..f26458a8e 100644 --- a/apps/red-ui/src/app/app.module.ts +++ b/apps/red-ui/src/app/app.module.ts @@ -64,7 +64,6 @@ import { ManualAnnotationDialogComponent } from './dialogs/manual-redaction-dial import { ToastComponent } from './components/toast/toast.component'; import { FilterComponent } from './common/filter/filter.component'; import { AppInfoComponent } from './screens/app-info/app-info.component'; -import { SortingComponent } from './components/sorting/sorting.component'; import { TableColNameComponent } from './components/table-col-name/table-col-name.component'; import { ProjectDetailsComponent } from './screens/project-overview-screen/project-details/project-details.component'; import { PageIndicatorComponent } from './screens/file/page-indicator/page-indicator.component'; @@ -77,6 +76,7 @@ import { FileActionsComponent } from './common/file-actions/file-actions.compone import { TypeAnnotationIconComponent } from './components/type-annotation-icon/type-annotation-icon.component'; import { TypeFilterComponent } from './components/type-filter/type-filter.component'; import { DictionaryAnnotationIconComponent } from './components/dictionary-annotation-icon/dictionary-annotation-icon.component'; +import { BulkActionsComponent } from './screens/project-overview-screen/bulk-actions/bulk-actions.component'; export function HttpLoaderFactory(httpClient: HttpClient) { return new TranslateHttpLoader(httpClient, '/assets/i18n/', '.json'); @@ -108,7 +108,6 @@ export function HttpLoaderFactory(httpClient: HttpClient) { ToastComponent, FilterComponent, AppInfoComponent, - SortingComponent, TableColNameComponent, ProjectDetailsComponent, PageIndicatorComponent, @@ -121,7 +120,9 @@ export function HttpLoaderFactory(httpClient: HttpClient) { FileActionsComponent, TypeAnnotationIconComponent, TypeFilterComponent, - DictionaryAnnotationIconComponent + DictionaryAnnotationIconComponent, + BulkActionsComponent, + FileActionsComponent ], imports: [ BrowserModule, diff --git a/apps/red-ui/src/app/common/file-actions/file-actions.component.ts b/apps/red-ui/src/app/common/file-actions/file-actions.component.ts index 6d5caa78b..5d8b1c77d 100644 --- a/apps/red-ui/src/app/common/file-actions/file-actions.component.ts +++ b/apps/red-ui/src/app/common/file-actions/file-actions.component.ts @@ -52,7 +52,7 @@ export class FileActionsComponent implements OnInit, AfterViewInit { } openDeleteFileDialog($event: MouseEvent, fileStatusWrapper: FileStatusWrapper) { - this._dialogService.openDeleteFileDialog($event, fileStatusWrapper.projectId, fileStatusWrapper.fileId, () => { + this._dialogService.openDeleteFilesDialog($event, fileStatusWrapper.projectId, [fileStatusWrapper.fileId], () => { this.actionPerformed.emit('delete'); }); } diff --git a/apps/red-ui/src/app/common/service/permissions.service.ts b/apps/red-ui/src/app/common/service/permissions.service.ts index 302f51e02..eda23950d 100644 --- a/apps/red-ui/src/app/common/service/permissions.service.ts +++ b/apps/red-ui/src/app/common/service/permissions.service.ts @@ -81,13 +81,17 @@ export class PermissionsService { return fileStatus.status === 'APPROVED'; } - canApprove(fileStatus?: FileStatusWrapper) { + canSetUnderReview(fileStatus?: FileStatusWrapper) { if (!fileStatus) { fileStatus = this._appStateService.activeFile; } return fileStatus.status === 'UNDER_APPROVAL' && this.isManagerAndOwner(); } + canApprove(fileStatus?: FileStatusWrapper) { + return this.canSetUnderReview && !fileStatus.hasRequests; + } + canSetUnderApproval(fileStatus?: FileStatusWrapper) { if (!fileStatus) { fileStatus = this._appStateService.activeFile; diff --git a/apps/red-ui/src/app/components/sorting/sorting.component.html b/apps/red-ui/src/app/components/sorting/sorting.component.html deleted file mode 100644 index 9cee56903..000000000 --- a/apps/red-ui/src/app/components/sorting/sorting.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ option.label | translate }} - - - diff --git a/apps/red-ui/src/app/components/sorting/sorting.component.scss b/apps/red-ui/src/app/components/sorting/sorting.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/red-ui/src/app/components/sorting/sorting.component.ts b/apps/red-ui/src/app/components/sorting/sorting.component.ts deleted file mode 100644 index e7a5526b0..000000000 --- a/apps/red-ui/src/app/components/sorting/sorting.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; - -export class SortingOption { - label?: string; - order: 'asc' | 'desc'; - column: string; -} - -const SORTING_OPTIONS: { [key: string]: SortingOption[] } = { - 'project-listing': [ - { label: 'sorting.recent', order: 'desc', column: 'projectDate' }, - { label: 'sorting.alphabetically', order: 'asc', column: 'project.projectName' } - ], - 'project-overview': [ - { label: 'sorting.recent', order: 'desc', column: 'added' }, - { label: 'sorting.alphabetically', order: 'asc', column: 'filename' }, - { label: 'sorting.number-of-pages', order: 'asc', column: 'numberOfPages' }, - { label: 'sorting.number-of-analyses', order: 'desc', column: 'numberOfAnalyses' } - ] -}; - -@Component({ - selector: 'redaction-sorting', - templateUrl: './sorting.component.html', - styleUrls: ['./sorting.component.scss'] -}) -export class SortingComponent implements OnInit { - @Input() initialOption: SortingOption; - - @Input() - private type: 'project-overview' | 'project-listing'; - - @Output() - private optionChanged = new EventEmitter(); - - public sortingOptions: SortingOption[]; - public activeOption: SortingOption; - - constructor() {} - - public ngOnInit(): void { - if (this.initialOption) { - this.setOption(this.initialOption); - } - } - - private _addCustomOption(option: Partial) { - const customOption = { - label: 'sorting.custom', - column: option.column, - order: option.order - }; - this.sortingOptions.push(customOption); - this.activeOption = customOption; - } - - private _resetOptions() { - if (this.activeOption?.label !== 'sorting.custom') { - this.sortingOptions = [...SORTING_OPTIONS[this.type]]; - } - } - - public dropdownSelect() { - this._resetOptions(); - this.optionChanged.emit(this.activeOption); - } - - public setOption(option: { column: string; order: 'asc' | 'desc' }) { - if (!this.sortingOptions) { - this._resetOptions(); - } - const existingOption = this.sortingOptions.find( - (o) => o.column === option.column && o.order === option.order - ); - if (existingOption) { - this.activeOption = existingOption; - this._resetOptions(); - } else { - this._addCustomOption(option); - } - } - - public toggleSort(column: string) { - if (this.activeOption.column === column) { - const currentOrder = this.activeOption.order; - this.setOption({ column, order: currentOrder === 'asc' ? 'desc' : 'asc' }); - } else { - this.setOption({ column, order: 'asc' }); - } - this.optionChanged.emit(this.activeOption); - } -} diff --git a/apps/red-ui/src/app/components/table-col-name/table-col-name.component.ts b/apps/red-ui/src/app/components/table-col-name/table-col-name.component.ts index a127a3292..915491e58 100644 --- a/apps/red-ui/src/app/components/table-col-name/table-col-name.component.ts +++ b/apps/red-ui/src/app/components/table-col-name/table-col-name.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { SortingOption } from '../sorting/sorting.component'; +import { SortingOption } from '../../utils/sorting.service'; @Component({ selector: 'redaction-table-col-name', @@ -19,16 +19,8 @@ export class TableColNameComponent implements OnInit { ngOnInit(): void {} public get arrowColor(): { up: string; down: string } { - const up = - this.activeSortingOption.order === 'desc' && - this.activeSortingOption.column === this.column - ? 'primary' - : 'accent'; - const down = - this.activeSortingOption.order === 'asc' && - this.activeSortingOption.column === this.column - ? 'primary' - : 'accent'; + const up = this.activeSortingOption.order === 'desc' && this.activeSortingOption.column === this.column ? 'primary' : 'accent'; + const down = this.activeSortingOption.order === 'asc' && this.activeSortingOption.column === this.column ? 'primary' : 'accent'; return { up, down }; } } diff --git a/apps/red-ui/src/app/dialogs/assign-owner-dialog/assign-owner-dialog.component.ts b/apps/red-ui/src/app/dialogs/assign-owner-dialog/assign-owner-dialog.component.ts index e93a7a232..e6eb29fec 100644 --- a/apps/red-ui/src/app/dialogs/assign-owner-dialog/assign-owner-dialog.component.ts +++ b/apps/red-ui/src/app/dialogs/assign-owner-dialog/assign-owner-dialog.component.ts @@ -10,7 +10,7 @@ import { FileStatusWrapper } from '../../screens/file/model/file-status.wrapper' class DialogData { type: 'file' | 'project'; project?: Project; - file?: FileStatusWrapper; + files?: FileStatusWrapper[]; } @Component({ @@ -44,9 +44,13 @@ export class AssignOwnerDialogComponent { } if (this.data.type === 'file') { - const file = this.data.file; + const uniqueReviewers = new Set(); + for (const file of this.data.files) { + uniqueReviewers.add(file.currentReviewer); + } + const singleUser = uniqueReviewers.size === 1 ? uniqueReviewers.values().next().value : []; this.usersForm = this._formBuilder.group({ - singleUser: [file?.currentReviewer] + singleUser: [singleUser] }); } } @@ -61,47 +65,35 @@ export class AssignOwnerDialogComponent { project.ownerId = ownerId; await this._appStateService.addOrUpdateProject(project); this._notificationService.showToastNotification( - 'Successfully assigned ' + - this.userService.getNameForId(ownerId) + - ' to project: ' + - project.projectName + 'Successfully assigned ' + this.userService.getNameForId(ownerId) + ' to project: ' + project.projectName ); } if (this.data.type === 'file') { const reviewerId = this.usersForm.get('singleUser').value; - await this._statusControllerService - .assignProjectOwner( - this._appStateService.activeProjectId, - this.data.file.fileId, - reviewerId - ) - .toPromise(); - this.data.file.currentReviewer = reviewerId; - this.data.file.reviewerName = this.userService.getNameForId(reviewerId); - this._notificationService.showToastNotification( - 'Successfully assigned ' + - this.userService.getNameForId(reviewerId) + - ' to file: ' + - this.data.file.filename + const promises = this.data.files.map((file) => + this._statusControllerService.assignProjectOwner(this._appStateService.activeProjectId, file.fileId, reviewerId).toPromise() ); + + await Promise.all(promises); + for (const file of this.data.files) { + file.currentReviewer = reviewerId; + file.reviewerName = this.userService.getNameForId(reviewerId); + this._notificationService.showToastNotification( + 'Successfully assigned ' + this.userService.getNameForId(reviewerId) + ' to file: ' + file.filename + ); + } } } catch (error) { - this._notificationService.showToastNotification( - 'Failed: ' + error.error.message, - null, - NotificationType.ERROR - ); + this._notificationService.showToastNotification('Failed: ' + error.error.message, null, NotificationType.ERROR); } this.dialogRef.close(); } get singleUsersSelectOptions() { - return this.data.type === 'file' - ? this._appStateService.activeProject.project.memberIds - : this.userService.managerUsers.map((m) => m.userId); + return this.data.type === 'file' ? this._appStateService.activeProject.project.memberIds : this.userService.managerUsers.map((m) => m.userId); } get multiUsersSelectOptions() { diff --git a/apps/red-ui/src/app/dialogs/dialog.service.ts b/apps/red-ui/src/app/dialogs/dialog.service.ts index f07b6e792..ee8f80ee9 100644 --- a/apps/red-ui/src/app/dialogs/dialog.service.ts +++ b/apps/red-ui/src/app/dialogs/dialog.service.ts @@ -1,12 +1,7 @@ import { Injectable } from '@angular/core'; import { FileDetailsDialogComponent } from './file-details-dialog/file-details-dialog.component'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { - FileStatus, - FileUploadControllerService, - ManualRedactionControllerService, - Project -} from '@redaction/red-ui-http'; +import { FileStatus, FileUploadControllerService, ManualRedactionControllerService, Project } from '@redaction/red-ui-http'; import { ConfirmationDialogComponent } from '../common/confirmation-dialog/confirmation-dialog.component'; import { NotificationService, NotificationType } from '../notification/notification.service'; import { TranslateService } from '@ngx-translate/core'; @@ -38,10 +33,7 @@ export class DialogService { private readonly _manualRedactionControllerService: ManualRedactionControllerService ) {} - public openFileDetailsDialog( - $event: MouseEvent, - file: FileStatus - ): MatDialogRef { + public openFileDetailsDialog($event: MouseEvent, file: FileStatus): MatDialogRef { $event.stopPropagation(); return this._dialog.open(FileDetailsDialogComponent, { ...dialogConfig, @@ -49,41 +41,32 @@ export class DialogService { }); } - public openDeleteFileDialog( - $event: MouseEvent, - projectId: string, - fileId: string, - cb?: Function - ): MatDialogRef { - $event.stopPropagation(); + public openDeleteFilesDialog($event: MouseEvent, projectId: string, fileIds: string[], cb?: Function): MatDialogRef { + $event?.stopPropagation(); + const ref = this._dialog.open(ConfirmationDialogComponent, dialogConfig); ref.afterClosed().subscribe((result) => { if (result) { - const file = this._appStateService.getFileById(projectId, fileId); - this._fileUploadControllerService.deleteFile(file.projectId, file.fileId).subscribe( - async () => { + const promises = fileIds + .map((fileId) => this._appStateService.getFileById(projectId, fileId)) + .map((file) => this._fileUploadControllerService.deleteFile(file.projectId, file.fileId).toPromise()); + + Promise.all(promises) + .then(async () => { await this._appStateService.reloadActiveProjectFiles(); if (cb) cb(); - }, - () => { - this._notificationService.showToastNotification( - this._translateService.instant('delete-file-error', file), - null, - NotificationType.ERROR - ); - } - ); + }) + .catch(() => { + this._notificationService.showToastNotification(this._translateService.instant('delete-files-error'), null, NotificationType.ERROR); + }); } }); return ref; } - public openManualRedactionDialog( - $event: ManualRedactionEntryWrapper, - cb?: Function - ): MatDialogRef { + public openManualRedactionDialog($event: ManualRedactionEntryWrapper, cb?: Function): MatDialogRef { const ref = this._dialog.open(ManualAnnotationDialogComponent, { ...dialogConfig, autoFocus: true, @@ -99,34 +82,24 @@ export class DialogService { return ref; } - public openAcceptSuggestionModal( - $event: MouseEvent, - annotation: AnnotationWrapper, - callback?: Function - ): MatDialogRef { + public openAcceptSuggestionModal($event: MouseEvent, annotation: AnnotationWrapper, callback?: Function): MatDialogRef { $event.stopPropagation(); const ref = this._dialog.open(ConfirmationDialogComponent, dialogConfig); ref.afterClosed().subscribe((result) => { if (result) { - this._manualAnnotationService - .approveRequest(annotation.id) - .subscribe((acceptResult) => { - if (callback) { - callback(acceptResult); - } - }); + this._manualAnnotationService.approveRequest(annotation.id).subscribe((acceptResult) => { + if (callback) { + callback(acceptResult); + } + }); } }); return ref; } - public openRejectSuggestionModal( - $event: MouseEvent, - annotation: AnnotationWrapper, - rejectCallback: () => void - ): MatDialogRef { + public openRejectSuggestionModal($event: MouseEvent, annotation: AnnotationWrapper, rejectCallback: () => void): MatDialogRef { $event.stopPropagation(); const ref = this._dialog.open(ConfirmationDialogComponent, { @@ -145,10 +118,7 @@ export class DialogService { return ref; } - public openEditProjectDialog( - $event: MouseEvent, - project: Project - ): MatDialogRef { + public openEditProjectDialog($event: MouseEvent, project: Project): MatDialogRef { $event.stopPropagation(); return this._dialog.open(AddEditProjectDialogComponent, { ...dialogConfig, @@ -157,11 +127,7 @@ export class DialogService { }); } - public openDeleteProjectDialog( - $event: MouseEvent, - project: Project, - cb?: Function - ): MatDialogRef { + public openDeleteProjectDialog($event: MouseEvent, project: Project, cb?: Function): MatDialogRef { $event.stopPropagation(); const ref = this._dialog.open(ConfirmationDialogComponent, dialogConfig); ref.afterClosed().subscribe(async (result) => { @@ -173,11 +139,7 @@ export class DialogService { return ref; } - public openAssignProjectMembersAndOwnerDialog( - $event: MouseEvent, - project: Project, - cb?: Function - ): MatDialogRef { + public openAssignProjectMembersAndOwnerDialog($event: MouseEvent, project: Project, cb?: Function): MatDialogRef { $event?.stopPropagation(); const ref = this._dialog.open(AssignOwnerDialogComponent, { ...dialogConfig, @@ -189,13 +151,27 @@ export class DialogService { return ref; } - public openAssignFileReviewerDialog( - file: FileStatus, - cb?: Function - ): MatDialogRef { + public openAssignFileReviewerDialog(file: FileStatus, cb?: Function): MatDialogRef { const ref = this._dialog.open(AssignOwnerDialogComponent, { ...dialogConfig, - data: { type: 'file', file: file } + data: { type: 'file', files: [file] } + }); + + ref.afterClosed().subscribe(() => { + if (cb) cb(); + }); + + return ref; + } + + public openBulkAssignFileReviewerDialog(fileIds: string[], cb?: Function): MatDialogRef { + const projectId = this._appStateService.activeProject.project.projectId; + const ref = this._dialog.open(AssignOwnerDialogComponent, { + ...dialogConfig, + data: { + type: 'file', + files: fileIds.map((fileId) => this._appStateService.getFileById(projectId, fileId)) + } }); ref.afterClosed().subscribe(() => { @@ -218,11 +194,7 @@ export class DialogService { return ref; } - openRemoveAnnotationModal( - $event: MouseEvent, - annotation: AnnotationWrapper, - callback: () => void - ) { + openRemoveAnnotationModal($event: MouseEvent, annotation: AnnotationWrapper, callback: () => void) { $event.stopPropagation(); const ref = this._dialog.open(ConfirmationDialogComponent, { @@ -232,13 +204,11 @@ export class DialogService { ref.afterClosed().subscribe((result) => { if (result) { - this._manualAnnotationService - .removeOrSuggestRemoveAnnotation(annotation) - .subscribe(() => { - if (callback) { - callback(); - } - }); + this._manualAnnotationService.removeOrSuggestRemoveAnnotation(annotation).subscribe(() => { + if (callback) { + callback(); + } + }); } }); diff --git a/apps/red-ui/src/app/icons/icons.module.ts b/apps/red-ui/src/app/icons/icons.module.ts index a01107d44..9f2df675b 100644 --- a/apps/red-ui/src/app/icons/icons.module.ts +++ b/apps/red-ui/src/app/icons/icons.module.ts @@ -36,6 +36,8 @@ export class IconsModule { 'pages', 'plus', 'preview', + 'radio-indeterminate', + 'radio-selected', 'refresh', 'report', 'secret', @@ -51,11 +53,7 @@ export class IconsModule { ]; for (const icon of icons) { - iconRegistry.addSvgIconInNamespace( - 'red', - icon, - sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/general/${icon}.svg`) - ); + iconRegistry.addSvgIconInNamespace('red', icon, sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/general/${icon}.svg`)); } } } diff --git a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.html b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.html index 2c63eade2..643961d01 100644 --- a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.html +++ b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.html @@ -40,33 +40,19 @@
-
-
-
-
- - {{ 'project-listing.table-header.title' | translate: { length: displayedProjects.length || 0 } }} - -
- -
- -
+
+
+ + {{ 'project-listing.table-header.title' | translate: { length: displayedProjects.length || 0 } }} +
-
- @@ -83,10 +69,6 @@ class="table-item" [class.pointer]="canOpenProject(pw)" > -
-
-
-
{{ pw.project.projectName }} diff --git a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.scss b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.scss index e3a8235e2..f883f9bf8 100644 --- a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.scss +++ b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.scss @@ -12,10 +12,6 @@ .grid-container { grid-template-columns: 2fr 1fr 1fr auto; - - &.bulk-select { - grid-template-columns: auto 2fr 1fr 1fr auto; - } } .stats-subtitle { @@ -25,13 +21,6 @@ .status-container { width: 160px; } - - .actions { - .active { - font-weight: 600; - color: $primary; - } - } } .right-fixed-container { diff --git a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.ts b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.ts index b3533179a..8665f4b01 100644 --- a/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.ts +++ b/apps/red-ui/src/app/screens/project-listing-screen/project-listing-screen.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { Project } from '@redaction/red-ui-http'; import { AppStateService } from '../../state/app-state.service'; import { UserService } from '../../user/user.service'; @@ -7,7 +7,6 @@ import { groupBy, humanize } from '../../utils/functions'; import { DialogService } from '../../dialogs/dialog.service'; import { FilterModel } from '../../common/filter/model/filter.model'; import * as moment from 'moment'; -import { SortingOption } from '../../components/sorting/sorting.component'; import { annotationFilterChecker, dueDateChecker, @@ -17,6 +16,7 @@ import { RedactionFilterSorter } from '../../common/filter/utils/filter-utils'; import { TranslateService } from '@ngx-translate/core'; +import { SortingOption, SortingService } from '../../utils/sorting.service'; import { PermissionsService } from '../../common/service/permissions.service'; import { ProjectWrapper } from '../../state/model/project.wrapper'; @@ -41,7 +41,6 @@ export class ProjectListingScreenComponent implements OnInit { }; public displayedProjects: ProjectWrapper[] = []; - public sortingOption: SortingOption = { column: 'projectDate', order: 'desc' }; constructor( public readonly appStateService: AppStateService, @@ -49,7 +48,8 @@ export class ProjectListingScreenComponent implements OnInit { public readonly permissionsService: PermissionsService, private readonly _changeDetectorRef: ChangeDetectorRef, private readonly _dialogService: DialogService, - private readonly _translateService: TranslateService + private readonly _translateService: TranslateService, + public readonly sortingService: SortingService ) {} public ngOnInit(): void { @@ -78,6 +78,10 @@ export class ProjectListingScreenComponent implements OnInit { return this.userService.user; } + public get sortingOption(): SortingOption { + return this.sortingService.getSortingOption('project-listing'); + } + public get activeProjects() { return this.appStateService.allProjects.reduce((i, p) => i + (p.project.status === Project.StatusEnum.ACTIVE ? 1 : 0), 0); } @@ -123,10 +127,6 @@ export class ProjectListingScreenComponent implements OnInit { this._dialogService.openAssignProjectMembersAndOwnerDialog($event, project); } - public sortingOptionChanged(option: SortingOption) { - this.sortingOption = option; - } - public getProjectStatusConfig(pw: ProjectWrapper) { const obj = pw.files.reduce((acc, file) => { const status = file.status; @@ -236,32 +236,6 @@ export class ProjectListingScreenComponent implements OnInit { this._changeDetectorRef.detectChanges(); } - public toggleProjectSelected($event: MouseEvent, pw: ProjectWrapper) { - $event.stopPropagation(); - const idx = this._selectedProjectIds.indexOf(pw.project.projectId); - if (idx === -1) { - this._selectedProjectIds.push(pw.project.projectId); - } else { - this._selectedProjectIds.splice(idx, 1); - } - } - - public toggleSelectAll() { - if (this.areAllProjectsSelected()) { - this._selectedProjectIds = []; - } else { - this._selectedProjectIds = this.appStateService.allProjects.map((pw) => pw.project.projectId); - } - } - - public areAllProjectsSelected(): boolean { - return this.appStateService.allProjects.length !== 0 && this._selectedProjectIds.length === this.appStateService.allProjects.length; - } - - public isProjectSelected(pw: ProjectWrapper): boolean { - return this._selectedProjectIds.indexOf(pw.project.projectId) !== -1; - } - openEditProjectDialog($event: MouseEvent, project: Project) { this._dialogService.openEditProjectDialog($event, project); } diff --git a/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.html b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.html new file mode 100644 index 000000000..4a8ad6a4c --- /dev/null +++ b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.html @@ -0,0 +1,67 @@ + + + + + +
+ +
+ + + + + + +
diff --git a/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.scss b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.scss new file mode 100644 index 000000000..e7a72018d --- /dev/null +++ b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.scss @@ -0,0 +1,3 @@ +:host { + display: flex; +} diff --git a/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.ts b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.ts new file mode 100644 index 000000000..1b9b83062 --- /dev/null +++ b/apps/red-ui/src/app/screens/project-overview-screen/bulk-actions/bulk-actions.component.ts @@ -0,0 +1,134 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AppStateService } from '../../../state/app-state.service'; +import { UserService } from '../../../user/user.service'; +import { ReanalysisControllerService } from '@redaction/red-ui-http'; +import { DialogService } from '../../../dialogs/dialog.service'; +import { PermissionsService } from '../../../common/service/permissions.service'; +import { FileStatusWrapper } from '../../file/model/file-status.wrapper'; +import { FileActionService } from '../../file/service/file-action.service'; + +@Component({ + selector: 'redaction-bulk-actions', + templateUrl: './bulk-actions.component.html', + styleUrls: ['./bulk-actions.component.scss'] +}) +export class BulkActionsComponent { + @Input() private selectedFileIds: string[]; + @Output() private reload = new EventEmitter(); + + constructor( + private readonly _appStateService: AppStateService, + private readonly _userService: UserService, + private readonly _dialogService: DialogService, + private readonly _reanalysisControllerService: ReanalysisControllerService, + private readonly _permissionsService: PermissionsService, + private readonly _fileActionService: FileActionService + ) {} + + private get selectedFiles(): FileStatusWrapper[] { + return this.selectedFileIds.map((fileId) => this._appStateService.getFileById(this._appStateService.activeProject.project.projectId, fileId)); + } + + private get _hasOutdatedDocuments() { + return this.selectedFiles.filter((file) => this._permissionsService.fileRequiresReanalysis(file)).length > 0; + } + + public get areAllFilesSelected() { + return this._appStateService.activeProject.files.length !== 0 && this.selectedFileIds.length === this._appStateService.activeProject.files.length; + } + + public get areSomeFilesSelected() { + return this.selectedFileIds.length > 0; + } + + public get canDelete() { + return this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canDeleteFile(file), true); + } + + public get canAssign() { + return this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canAssignReviewer(file), true); + } + + public canReanalyse() { + return this._permissionsService.isProjectMember(); + } + + public get reanalyseDisabled() { + return !this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canReanalyseFile(file), true); + } + + public get fileStatuses() { + return this.selectedFiles.map((file) => file.fileStatus.status); + } + + public get reanalyseTooltip() { + if (!this.reanalyseDisabled) { + return 'project-overview.bulk.reanalyse'; + } + + if (!this._hasOutdatedDocuments) { + return 'project-overview.bulk.reanalyse-error-outdated'; + } + + return 'project-overview.bulk.reanalyse-error-assign'; + } + + public delete() { + this._dialogService.openDeleteFilesDialog(null, this._appStateService.activeProject.project.projectId, this.selectedFileIds, () => { + this.reload.emit(); + this.selectedFileIds.splice(0, this.selectedFileIds.length); + }); + } + + public assign() { + this._dialogService.openBulkAssignFileReviewerDialog(this.selectedFileIds); + } + + public reanalyse() { + const promises = this.selectedFiles + .filter((file) => this._permissionsService.fileRequiresReanalysis(file)) + .map((file) => this._reanalysisControllerService.reanalyzeFile(this._appStateService.activeProject.project.projectId, file.fileId).toPromise()); + + Promise.all(promises).then(() => { + this.reload.emit(); + }); + } + + // Under review + public get canSetToUnderReview() { + return this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canSetUnderReview(file), true); + } + public setToUnderReview() { + const promises = this.selectedFiles.map((file) => this._fileActionService.setFileUnderReview(file).toPromise()); + + Promise.all(promises).then(() => { + this.reload.emit(); + }); + } + + // Under approval + public get canSetToUnderApproval() { + return this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canSetUnderApproval(file), true); + } + public setToUnderApproval() { + const promises = this.selectedFiles.map((file) => this._fileActionService.setFileUnderApproval(file).toPromise()); + console.log(promises.length); + + Promise.all(promises).then(() => { + this.reload.emit(); + console.log('done'); + }); + } + + // Approve + public get canApprove() { + return this.selectedFiles.reduce((acc, file) => acc && this._permissionsService.canApprove(file), true); + } + public approveDocuments() { + const promises = this.selectedFiles.map((file) => this._fileActionService.setFileApproved(file).toPromise()); + + Promise.all(promises).then(() => { + this.reload.emit(); + }); + } +} diff --git a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.html b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.html index 21c742f67..4a37067fb 100644 --- a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.html +++ b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.html @@ -44,32 +44,39 @@
- + tmp
-
- - {{ 'project-overview.table-header.title' | translate: { length: displayedFiles.length || 0 } }} - -
-
- +
+ +
+ + + {{ 'project-overview.table-header.title' | translate: { length: displayedFiles.length || 0 } }} + + +
-
+
+
diff --git a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.scss b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.scss index 6c4b28717..5e980717a 100644 --- a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.scss +++ b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.scss @@ -1,13 +1,6 @@ @import '../../../assets/styles/red-variables'; @import '../../../assets/styles/red-mixins'; -.actions { - .active { - font-weight: 600; - color: $primary; - } -} - .file-upload-input { display: none; } diff --git a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.ts b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.ts index f103d9f35..9ec7f9381 100644 --- a/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.ts +++ b/apps/red-ui/src/app/screens/project-overview-screen/project-overview-screen.component.ts @@ -12,12 +12,13 @@ import { TranslateService } from '@ngx-translate/core'; import { FileActionService } from '../file/service/file-action.service'; import { FilterModel } from '../../common/filter/model/filter.model'; import * as moment from 'moment'; -import { SortingOption } from '../../components/sorting/sorting.component'; import { ProjectDetailsComponent } from './project-details/project-details.component'; import { FileStatusWrapper } from '../file/model/file-status.wrapper'; import { annotationFilterChecker, getFilteredEntities, keyChecker, RedactionFilterSorter } from '../../common/filter/utils/filter-utils'; +import { SortingOption, SortingService } from '../../utils/sorting.service'; import { PermissionsService } from '../../common/service/permissions.service'; import { UserService } from '../../user/user.service'; +import { FileStatus } from '@redaction/red-ui-http'; @Component({ selector: 'redaction-project-overview-screen', @@ -25,11 +26,10 @@ import { UserService } from '../../user/user.service'; styleUrls: ['./project-overview-screen.component.scss'] }) export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { - private _selectedFileIds: string[] = []; - - statusFilters: FilterModel[]; - peopleFilters: FilterModel[]; - needsWorkFilters: FilterModel[]; + public selectedFileIds: string[] = []; + public statusFilters: FilterModel[]; + public peopleFilters: FilterModel[]; + public needsWorkFilters: FilterModel[]; displayedFiles: FileStatusWrapper[] = []; @@ -41,12 +41,11 @@ export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { @ViewChild('projectDetailsComponent', { static: false }) private _projectDetailsComponent: ProjectDetailsComponent; - sortingOption: SortingOption = { column: 'added', order: 'desc' }; - constructor( public readonly appStateService: AppStateService, + public readonly userService: UserService, + private readonly _sortingService: SortingService, public readonly permissionsService: PermissionsService, - private readonly _userService: UserService, private readonly _activatedRoute: ActivatedRoute, private readonly _notificationService: NotificationService, private readonly _dialogService: DialogService, @@ -111,6 +110,26 @@ export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { } } + public isPending(fileStatusWrapper: FileStatusWrapper) { + return fileStatusWrapper.status === FileStatus.StatusEnum.UNPROCESSED; + } + + public isError(fileStatusWrapper: FileStatusWrapper) { + return fileStatusWrapper.status === FileStatus.StatusEnum.ERROR; + } + + public isProcessing(fileStatusWrapper: FileStatusWrapper) { + return [FileStatus.StatusEnum.REPROCESS, FileStatus.StatusEnum.PROCESSING].includes(fileStatusWrapper.status); + } + + public get sortingOption(): SortingOption { + return this._sortingService.getSortingOption('project-overview'); + } + + public toggleSort($event) { + this._sortingService.toggleSort('project-overview', $event); + } + reloadProjects() { this.appStateService.getFiles().then(() => { this.calculateData(); @@ -126,31 +145,35 @@ export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { toggleFileSelected($event: MouseEvent, file: FileStatusWrapper) { $event.stopPropagation(); - const idx = this._selectedFileIds.indexOf(file.fileId); + const idx = this.selectedFileIds.indexOf(file.fileId); if (idx === -1) { - this._selectedFileIds.push(file.fileId); + this.selectedFileIds.push(file.fileId); } else { - this._selectedFileIds.splice(idx, 1); + this.selectedFileIds.splice(idx, 1); } } - toggleSelectAll() { - if (this.areAllFilesSelected()) { - this._selectedFileIds = []; + public toggleSelectAll() { + if (this.areAllFilesSelected) { + this.selectedFileIds = []; } else { - this._selectedFileIds = this.appStateService.activeProject.files.map((file) => file.fileId); + this.selectedFileIds = this.appStateService.activeProject.files.map((file) => file.fileId); } } - areAllFilesSelected() { - return this.appStateService.activeProject.files.length !== 0 && this._selectedFileIds.length === this.appStateService.activeProject.files.length; + public get areAllFilesSelected() { + return this.appStateService.activeProject.files.length !== 0 && this.selectedFileIds.length === this.appStateService.activeProject.files.length; } - isFileSelected(file: FileStatusWrapper) { - return this._selectedFileIds.indexOf(file.fileId) !== -1; + public get areSomeFilesSelected() { + return this.selectedFileIds.length > 0; } - fileId(index, item) { + public isFileSelected(file: FileStatusWrapper) { + return this.selectedFileIds.indexOf(file.fileId) !== -1; + } + + public fileId(index, item) { return item.fileId; } @@ -170,10 +193,6 @@ export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { this._uploadStatusOverlayService.openStatusOverlay(); } - sortingOptionChanged(option: SortingOption) { - this.sortingOption = option; - } - private _computeAllFilters() { const allDistinctFileStatusWrapper = new Set(); const allDistinctPeople = new Set(); @@ -215,7 +234,7 @@ export class ProjectOverviewScreenComponent implements OnInit, OnDestroy { allDistinctPeople.forEach((userId) => { this.peopleFilters.push({ key: userId, - label: userId ? this._userService.getNameForId(userId) : this._translateService.instant('initials-avatar.unassigned') + label: userId ? this.userService.getNameForId(userId) : this._translateService.instant('initials-avatar.unassigned') }); }); diff --git a/apps/red-ui/src/app/state/app-state.service.ts b/apps/red-ui/src/app/state/app-state.service.ts index efcdcfdbc..9c6c2d80d 100644 --- a/apps/red-ui/src/app/state/app-state.service.ts +++ b/apps/red-ui/src/app/state/app-state.service.ts @@ -13,7 +13,7 @@ import { import { NotificationService, NotificationType } from '../notification/notification.service'; import { TranslateService } from '@ngx-translate/core'; import { Router } from '@angular/router'; -import { UserService } from '../user/user.service'; +import { UserService, UserWrapper } from '../user/user.service'; import { forkJoin, of, timer } from 'rxjs'; import { tap } from 'rxjs/operators'; import { download } from '../utils/file-download-utils'; diff --git a/apps/red-ui/src/app/utils/sorting.service.ts b/apps/red-ui/src/app/utils/sorting.service.ts new file mode 100644 index 000000000..759774fa2 --- /dev/null +++ b/apps/red-ui/src/app/utils/sorting.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; + +export class SortingOption { + order: 'asc' | 'desc'; + column: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class SortingService { + private _options: { [key: string]: SortingOption } = { + 'project-listing': { column: 'project.projectName', order: 'asc' }, + 'project-overview': { column: 'filename', order: 'asc' } + }; + + constructor() {} + + public toggleSort(screen: 'project-listing' | 'project-overview', column: string) { + if (this._options[screen].column === column) { + const currentOrder = this._options[screen].order; + this._options[screen].order = currentOrder === 'asc' ? 'desc' : 'asc'; + } else { + this._options[screen] = { column, order: 'asc' }; + } + } + + public getSortingOption(screen: 'project-listing' | 'project-overview') { + return this._options[screen]; + } +} diff --git a/apps/red-ui/src/assets/i18n/en.json b/apps/red-ui/src/assets/i18n/en.json index 504070d01..8e9a2622a 100644 --- a/apps/red-ui/src/assets/i18n/en.json +++ b/apps/red-ui/src/assets/i18n/en.json @@ -209,6 +209,7 @@ }, "upload-error": "Failed to upload file: {{name}}", "delete-file-error": "Failed to delete file: {{filename}}", + "delete-files-error": "Failed to delete files.", "reanalyse": { "action": "Reanalyze File" }, @@ -242,7 +243,15 @@ }, "header": "Project Overview", "upload-document": "Upload Document", - "no-project": "Requested project: {{projectId}} does not exist! Back to Project Listing. " + "no-project": "Requested project: {{projectId}} does not exist! Back to Project Listing. ", + "bulk": { + "delete": "Delete documents", + "assign": "Assign reviewer", + "change-state": "Change state", + "reanalyse": "Reanalyse documents", + "reanalyse-error-outdated": "No outdated documents have been selected.", + "reanalyse-error-member-assign": "Not all selected documents are assigned to you." + } }, "file-preview": { "show-redacted-view": "Show Redacted Preview", diff --git a/apps/red-ui/src/assets/icons/general/radio-indeterminate.svg b/apps/red-ui/src/assets/icons/general/radio-indeterminate.svg new file mode 100644 index 000000000..044e09768 --- /dev/null +++ b/apps/red-ui/src/assets/icons/general/radio-indeterminate.svg @@ -0,0 +1,13 @@ + + + BACD0033-A5B4-40C6-9A6E-99CC587814E4 + + + + + + + + + + diff --git a/apps/red-ui/src/assets/icons/general/radio-selected.svg b/apps/red-ui/src/assets/icons/general/radio-selected.svg new file mode 100644 index 000000000..8652d564e --- /dev/null +++ b/apps/red-ui/src/assets/icons/general/radio-selected.svg @@ -0,0 +1,15 @@ + + + 53931886-242F-46D3-AD2D-DBADB674ACDB + + + + + + + + + + + + diff --git a/apps/red-ui/src/assets/styles/red-button.scss b/apps/red-ui/src/assets/styles/red-button.scss index afe2c1990..42ec6a74e 100644 --- a/apps/red-ui/src/assets/styles/red-button.scss +++ b/apps/red-ui/src/assets/styles/red-button.scss @@ -66,4 +66,8 @@ &:hover:not(.warn):not(.primary) { background-color: $grey-2; } + + &.dark:hover { + background-color: $grey-4; + } } diff --git a/apps/red-ui/src/assets/styles/red-components.scss b/apps/red-ui/src/assets/styles/red-components.scss index ead6b6fa4..910f2913e 100644 --- a/apps/red-ui/src/assets/styles/red-components.scss +++ b/apps/red-ui/src/assets/styles/red-components.scss @@ -45,7 +45,7 @@ } &.white-dark { - border: 1px solid #e2e4e9; + border: 1px solid $grey-4; } } @@ -135,6 +135,7 @@ .select-oval { width: 20px; height: 20px; + box-sizing: border-box; border-radius: 50%; border: 1px solid $grey-5; background-color: $white; @@ -145,9 +146,10 @@ &.always-visible { opacity: 1; } - - &.active { - opacity: 1; - background-color: $primary; - } +} + +.selection-icon { + width: 20px !important; + color: $primary; + cursor: pointer; } diff --git a/apps/red-ui/src/assets/styles/red-grid.scss b/apps/red-ui/src/assets/styles/red-grid.scss index 9fe4c1e28..9a4241798 100644 --- a/apps/red-ui/src/assets/styles/red-grid.scss +++ b/apps/red-ui/src/assets/styles/red-grid.scss @@ -5,11 +5,11 @@ height: 50px; padding: 0 16px; display: flex; - justify-content: space-between; align-items: center; z-index: 1; position: sticky; top: 50px; + gap: 16px; .actions { display: flex; @@ -20,7 +20,7 @@ &.span-7 { grid-column-end: span 7; } - &.span-5 { - grid-column-end: span 5; + &.span-4 { + grid-column-end: span 4; } } diff --git a/apps/red-ui/src/assets/styles/red-page-layout.scss b/apps/red-ui/src/assets/styles/red-page-layout.scss index eac1e7f0e..b8f790b41 100644 --- a/apps/red-ui/src/assets/styles/red-page-layout.scss +++ b/apps/red-ui/src/assets/styles/red-page-layout.scss @@ -173,12 +173,7 @@ body { .select-all-container { display: flex; - gap: 16px; align-items: center; - - .select-oval { - margin-left: 0; - } } .pr-0 { diff --git a/package.json b/package.json index 33c8d760a..09f2e6bb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redaction", - "version": "0.0.137", + "version": "0.0.138", "license": "MIT", "husky": { "hooks": {