UI updates

This commit is contained in:
Adina Țeudan 2020-11-11 21:43:44 +02:00 committed by Timo Bejan
parent 08979ec31c
commit dc73f615fa
29 changed files with 469 additions and 372 deletions

View File

@ -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,

View File

@ -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');
});
}

View File

@ -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;

View File

@ -1,11 +0,0 @@
<mat-form-field appearance="none" class="red-select">
<mat-select
[(ngModel)]="activeOption"
panelClass="red-select-panel"
(selectionChange)="dropdownSelect()"
>
<mat-option *ngFor="let option of sortingOptions" [value]="option">
{{ option.label | translate }}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -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<SortingOption>();
public sortingOptions: SortingOption[];
public activeOption: SortingOption;
constructor() {}
public ngOnInit(): void {
if (this.initialOption) {
this.setOption(this.initialOption);
}
}
private _addCustomOption(option: Partial<SortingOption>) {
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);
}
}

View File

@ -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 };
}
}

View File

@ -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<string>();
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() {

View File

@ -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<FileDetailsDialogComponent> {
public openFileDetailsDialog($event: MouseEvent, file: FileStatus): MatDialogRef<FileDetailsDialogComponent> {
$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<ConfirmationDialogComponent> {
$event.stopPropagation();
public openDeleteFilesDialog($event: MouseEvent, projectId: string, fileIds: string[], cb?: Function): MatDialogRef<ConfirmationDialogComponent> {
$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<ManualAnnotationDialogComponent> {
public openManualRedactionDialog($event: ManualRedactionEntryWrapper, cb?: Function): MatDialogRef<ManualAnnotationDialogComponent> {
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<ConfirmationDialogComponent> {
public openAcceptSuggestionModal($event: MouseEvent, annotation: AnnotationWrapper, callback?: Function): MatDialogRef<ConfirmationDialogComponent> {
$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<ConfirmationDialogComponent> {
public openRejectSuggestionModal($event: MouseEvent, annotation: AnnotationWrapper, rejectCallback: () => void): MatDialogRef<ConfirmationDialogComponent> {
$event.stopPropagation();
const ref = this._dialog.open(ConfirmationDialogComponent, {
@ -145,10 +118,7 @@ export class DialogService {
return ref;
}
public openEditProjectDialog(
$event: MouseEvent,
project: Project
): MatDialogRef<AddEditProjectDialogComponent> {
public openEditProjectDialog($event: MouseEvent, project: Project): MatDialogRef<AddEditProjectDialogComponent> {
$event.stopPropagation();
return this._dialog.open(AddEditProjectDialogComponent, {
...dialogConfig,
@ -157,11 +127,7 @@ export class DialogService {
});
}
public openDeleteProjectDialog(
$event: MouseEvent,
project: Project,
cb?: Function
): MatDialogRef<ConfirmationDialogComponent> {
public openDeleteProjectDialog($event: MouseEvent, project: Project, cb?: Function): MatDialogRef<ConfirmationDialogComponent> {
$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<AssignOwnerDialogComponent> {
public openAssignProjectMembersAndOwnerDialog($event: MouseEvent, project: Project, cb?: Function): MatDialogRef<AssignOwnerDialogComponent> {
$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<AssignOwnerDialogComponent> {
public openAssignFileReviewerDialog(file: FileStatus, cb?: Function): MatDialogRef<AssignOwnerDialogComponent> {
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<AssignOwnerDialogComponent> {
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();
}
});
}
});

View File

@ -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`));
}
}
}

View File

@ -40,33 +40,19 @@
<div class="flex red-content-inner">
<div class="left-container">
<div class="grid-container bulk-select">
<div class="header-item span-5">
<div class="select-all-container">
<div class="select-oval always-visible" [class.active]="areAllProjectsSelected()" (click)="toggleSelectAll()"></div>
<span class="all-caps-label">
{{ 'project-listing.table-header.title' | translate: { length: displayedProjects.length || 0 } }}
</span>
</div>
<div class="actions">
<redaction-sorting
#sortingComponent
[initialOption]="sortingOption"
(optionChanged)="sortingOptionChanged($event)"
type="project-listing"
></redaction-sorting>
</div>
<div class="grid-container">
<div class="header-item span-4">
<span class="all-caps-label">
{{ 'project-listing.table-header.title' | translate: { length: displayedProjects.length || 0 } }}
</span>
</div>
<div class="select-oval-placeholder"></div>
<redaction-table-col-name
label="project-listing.table-col-names.name"
column="project.projectName"
[activeSortingOption]="sortingOption"
[withSort]="true"
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="sortingService.toggleSort('project-listing', $event)"
></redaction-table-col-name>
<redaction-table-col-name label="project-listing.table-col-names.needs-work"></redaction-table-col-name>
@ -83,10 +69,6 @@
class="table-item"
[class.pointer]="canOpenProject(pw)"
>
<div class="pr-0">
<div class="select-oval" [class.active]="isProjectSelected(pw)" (click)="toggleProjectSelected($event, pw)"></div>
</div>
<div>
<div class="table-item-title">
{{ pw.project.projectName }}

View File

@ -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 {

View File

@ -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);
}

View File

@ -0,0 +1,67 @@
<ng-container *ngIf="areSomeFilesSelected">
<button
(click)="delete()"
*ngIf="canDelete"
[matTooltip]="'project-overview.bulk.delete' | translate"
class="dark"
color="accent"
mat-icon-button
matTooltipPosition="above"
>
<mat-icon svgIcon="red:trash"></mat-icon>
</button>
<button
(click)="assign()"
*ngIf="canAssign"
[matTooltip]="'project-overview.bulk.assign' | translate"
class="dark"
color="accent"
mat-icon-button
matTooltipPosition="above"
>
<mat-icon svgIcon="red:assign"></mat-icon>
</button>
<div [matTooltip]="reanalyseTooltip | translate" matTooltipPosition="above">
<button (click)="reanalyse()" *ngIf="canReanalyse()" [disabled]="reanalyseDisabled" class="dark" color="accent" mat-icon-button>
<mat-icon svgIcon="red:refresh"></mat-icon>
</button>
</div>
<button
(click)="approveDocuments()"
*ngIf="canApprove"
[matTooltip]="'project-overview.approve' | translate"
class="dark"
color="accent"
mat-icon-button
matTooltipPosition="above"
>
<mat-icon svgIcon="red:check-alt"></mat-icon>
</button>
<button
(click)="setToUnderApproval()"
*ngIf="canSetToUnderApproval"
[matTooltip]="'project-overview.under-approval' | translate"
class="dark"
color="accent"
mat-icon-button
matTooltipPosition="above"
>
<mat-icon svgIcon="red:check-alt"></mat-icon>
</button>
<button
(click)="setToUnderReview()"
*ngIf="canSetToUnderReview"
[matTooltip]="'project-overview.under-review' | translate"
class="dark"
color="accent"
mat-icon-button
matTooltipPosition="above"
>
<mat-icon svgIcon="red:refresh"></mat-icon>
</button>
</ng-container>

View File

@ -0,0 +1,3 @@
:host {
display: flex;
}

View File

@ -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();
});
}
}

View File

@ -44,32 +44,39 @@
<input #fileInput (change)="uploadFiles($event.target['files'])" class="file-upload-input" multiple="true" type="file" />
</div>
</div>
tmp
<div class="flex red-content-inner">
<div class="left-container">
<div class="grid-container bulk-select">
<div class="header-item span-7">
<div class="select-all-container">
<div (click)="toggleSelectAll()" [class.active]="areAllFilesSelected()" class="select-oval always-visible"></div>
<span class="all-caps-label">
{{ 'project-overview.table-header.title' | translate: { length: displayedFiles.length || 0 } }}
</span>
</div>
<div class="actions">
<redaction-sorting
#sortingComponent
[initialOption]="sortingOption"
(optionChanged)="sortingOptionChanged($event)"
type="project-overview"
></redaction-sorting>
<div
(click)="toggleSelectAll()"
[class.active]="areAllFilesSelected"
class="select-oval always-visible"
*ngIf="!areAllFilesSelected && !areSomeFilesSelected"
></div>
<mat-icon *ngIf="areAllFilesSelected" (click)="toggleSelectAll()" class="selection-icon active" svgIcon="red:radio-selected"></mat-icon>
<mat-icon
*ngIf="areSomeFilesSelected && !areAllFilesSelected"
(click)="toggleSelectAll()"
class="selection-icon"
svgIcon="red:radio-indeterminate"
></mat-icon>
</div>
<span class="all-caps-label">
{{ 'project-overview.table-header.title' | translate: { length: displayedFiles.length || 0 } }}
</span>
<redaction-bulk-actions [selectedFileIds]="selectedFileIds" (reload)="reloadProjects()"></redaction-bulk-actions>
</div>
<!-- Table column names-->
<div class="select-oval-placeholder"></div>
<redaction-table-col-name
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="toggleSort($event)"
[activeSortingOption]="sortingOption"
[withSort]="true"
column="filename"
@ -77,7 +84,7 @@
></redaction-table-col-name>
<redaction-table-col-name
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="toggleSort($event)"
[activeSortingOption]="sortingOption"
[withSort]="true"
column="added"
@ -87,7 +94,7 @@
<redaction-table-col-name label="project-overview.table-col-names.needs-work"></redaction-table-col-name>
<redaction-table-col-name
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="toggleSort($event)"
[activeSortingOption]="sortingOption"
[withSort]="true"
column="reviewerName"
@ -95,7 +102,7 @@
></redaction-table-col-name>
<redaction-table-col-name
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="toggleSort($event)"
[activeSortingOption]="sortingOption"
[withSort]="true"
column="pages"
@ -103,7 +110,7 @@
></redaction-table-col-name>
<redaction-table-col-name
(toggleSort)="sortingComponent.toggleSort($event)"
(toggleSort)="toggleSort($event)"
[activeSortingOption]="sortingOption"
[withSort]="true"
class="flex-end"
@ -120,7 +127,13 @@
class="table-item"
>
<div class="pr-0">
<div (click)="toggleFileSelected($event, fileStatus)" [class.active]="isFileSelected(fileStatus)" class="select-oval"></div>
<div (click)="toggleFileSelected($event, fileStatus)" *ngIf="!isFileSelected(fileStatus)" class="select-oval"></div>
<mat-icon
class="selection-icon active"
*ngIf="isFileSelected(fileStatus)"
(click)="toggleFileSelected($event, fileStatus)"
svgIcon="red:radio-selected"
></mat-icon>
</div>
<div matTooltipPosition="above" [matTooltip]="'[' + fileStatus.status + '] ' + fileStatus.filename">

View File

@ -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;
}

View File

@ -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<string>();
const allDistinctPeople = new Set<string>();
@ -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')
});
});

View File

@ -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';

View File

@ -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];
}
}

View File

@ -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! <a href='/ui/projects'>Back to Project Listing. <a/>"
"no-project": "Requested project: {{projectId}} does not exist! <a href='/ui/projects'>Back to Project Listing. <a/>",
"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",

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>BACD0033-A5B4-40C6-9A6E-99CC587814E4</title>
<g id="Bulk-Actions" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="01.-Bulk-Actions" transform="translate(-10.000000, -127.000000)">
<rect fill="none" x="0" y="0" width="1440" height="980"></rect>
<polygon id="Rectangle" fill="none" points="0 112 1086 112 1086 162 0 162"></polygon>
<g id="intermediary_selection" transform="translate(10.000000, 127.000000)" fill="currentColor">
<path d="M10,0 C15.5228475,0 20,4.4771525 20,10 C20,15.5228475 15.5228475,20 10,20 C4.4771525,20 0,15.5228475 0,10 C0,4.4771525 4.4771525,0 10,0 Z M14,8.9 L6,8.9 C5.44771525,8.9 5,9.34771525 5,9.9 L5,9.9 L5,10.1 C5,10.6522847 5.44771525,11.1 6,11.1 L6,11.1 L14,11.1 C14.5522847,11.1 15,10.6522847 15,10.1 L15,10.1 L15,9.9 C15,9.34771525 14.5522847,8.9 14,8.9 L14,8.9 Z"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>53931886-242F-46D3-AD2D-DBADB674ACDB</title>
<g id="Bulk-Actions" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="01.-Bulk-Actions" transform="translate(-10.000000, -209.000000)">
<rect fill="none" x="0" y="0" width="1440" height="980"></rect>
<g id="Entry" transform="translate(0.000000, 194.000000)" fill="none">
<polygon id="Rectangle" points="0 0 1086 0 1086 50 0 50"></polygon>
</g>
<g id="Radio_selected" transform="translate(10.000000, 209.000000)" fill="currentColor">
<path d="M10,0 C15.5228475,0 20,4.4771525 20,10 C20,15.5228475 15.5228475,20 10,20 C4.4771525,20 0,15.5228475 0,10 C0,4.4771525 4.4771525,0 10,0 Z M13.6545842,6.07220062 L8.50054373,11.3199291 L6.58289039,9.38262084 C6.34328715,9.14059736 5.95224003,9.14059736 5.71263679,9.38262084 L5.06605523,10.0357335 C4.82983344,10.2743414 4.82983344,10.6586767 5.06605523,10.8972846 L8.06804104,13.9295935 C8.30826653,14.1722455 8.70055256,14.1715218 8.93988111,13.9279851 L15.1747747,7.58346184 C15.4094976,7.34461174 15.4087908,6.96150162 15.1731882,6.72351919 L14.5266067,6.07040651 C14.2863095,5.82768204 13.8938806,5.82848943 13.6545842,6.07220062 Z" id="radio_selected"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -66,4 +66,8 @@
&:hover:not(.warn):not(.primary) {
background-color: $grey-2;
}
&.dark:hover {
background-color: $grey-4;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -173,12 +173,7 @@ body {
.select-all-container {
display: flex;
gap: 16px;
align-items: center;
.select-oval {
margin-left: 0;
}
}
.pr-0 {

View File

@ -1,6 +1,6 @@
{
"name": "redaction",
"version": "0.0.137",
"version": "0.0.138",
"license": "MIT",
"husky": {
"hooks": {