Multiselect in mediafiles

Allows multiselect on mediafiles using the new folder structure
Add bulk delete on server
Add movement logic and path view
This commit is contained in:
Sean Engelhardt 2019-08-09 10:08:45 +02:00
parent f25a8aefb2
commit a97ca18c36
7 changed files with 241 additions and 97 deletions

View File

@ -135,4 +135,15 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
directory_id: directoryId
});
}
/**
* Deletes many files.
*
* @param mediafiles The users to delete
*/
public async bulkDelete(mediafiles: ViewMediafile[]): Promise<void> {
await this.httpService.post('/rest/mediafiles/mediafile/bulk_delete/', {
ids: mediafiles.map(mediafile => mediafile.id)
});
}
}

View File

@ -11,7 +11,7 @@
type="button"
mat-icon-button
(click)="openUploadDialog(uploadDialog)"
*osPerms="'mediafiles.can_upload'"
*osPerms="'mediafiles.can_manage'"
>
<mat-icon>cloud_upload</mat-icon>
</button>

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="canUploadFiles" [multiSelectMode]="false" (mainEvent)="onMainEvent()">
<os-head-bar [mainButton]="canEdit" [multiSelectMode]="isMultiSelect" (mainEvent)="onMainEvent()">
<!-- Title -->
<div class="title-slot">
<h2 translate>Files</h2>
@ -6,35 +6,40 @@
<!-- Menu -->
<div class="menu-slot" *osPerms="'mediafiles.can_manage'">
<button type="button" mat-icon-button (click)="createNewFolder(newFolderDialog)">
<button type="button" mat-icon-button (click)="createNewFolder(newFolderDialog)" *ngIf="!isMultiSelect">
<mat-icon>create_new_folder</mat-icon>
</button>
<!--<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
</button>-->
</button>
</div>
<!-- Multiselect info -->
<!--<div *ngIf="this.isMultiSelect" class="central-info-slot">
<div *ngIf="isMultiSelect" class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>-->
</div>
</os-head-bar>
<!-- Folder navigation bar -->
<div>
<div class="custom-table-header folder-nav-bar">
<button class="folder" mat-button (click)="changeDirectory(null)">
<button class="folder" mat-button (click)="changeDirectory(null)" [disabled]="isMultiSelect">
<mat-icon class="file-icon">home</mat-icon>
</button>
<span *ngFor="let directory of directoryChain; let last = last">
<div class="arrow">
<mat-icon>chevron_right</mat-icon>
</div>
<button class="folder" mat-button (click)="changeDirectory(directory.id)" *ngIf="!last">
<button
class="folder"
mat-button
(click)="changeDirectory(directory.id)"
[disabled]="isMultiSelect"
*ngIf="!last"
>
<span class="folder-text">
{{ directory.title }}
{{ directory.filename }}
</span>
</button>
<button
@ -42,14 +47,15 @@
mat-button
[matMenuTriggerFor]="singleMediafileMenu"
[matMenuTriggerData]="{ mediafile: directory }"
[disabled]="isMultiSelect"
*ngIf="last && showFileMenu(directory)"
>
<os-icon-container icon="arrow_drop_down" swap="true" size="large">
{{ directory.title }}
{{ directory.filename }}
</os-icon-container>
</button>
<span class="folder fake-folder folder-text" *ngIf="last && !showFileMenu(directory)">
{{ directory.title }}
{{ directory.filename }}
</span>
</span>
<span class="visibility" *ngIf="canEdit && directory && directory.has_inherited_access_groups">
@ -83,23 +89,29 @@
showHeader="false"
vScrollAuto
[dataSource]="dataSource"
matCheckboxSelection="selection"
[columns]="columnSet"
[hideColumns]="hiddenColumns"
(rowClick)="onSelectRow($event)"
>
<!-- Icon column -->
<div *pblNgridCellDef="'icon'; row as mediafile" class="fill clickable">
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file"> </a>
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory"> </a>
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file && !isMultiSelect">
</a>
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory && !isMultiSelect">
</a>
<mat-icon class="file-icon">{{ mediafile.getIcon() }}</mat-icon>
</div>
<!-- Title column -->
<div *pblNgridCellDef="'title'; row as mediafile" class="fill clickable">
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file"> </a>
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory"> </a>
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file && !isMultiSelect">
</a>
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory && !isMultiSelect">
</a>
<div class="innerTable">
<div class="file-title ellipsis-overflow">
{{ mediafile.title }}
{{ mediafile.filename }}
</div>
<div class="info-text" *ngIf="mediafile.is_file">
<span> {{ getDateFromTimestamp(mediafile.timestamp) }} · {{ mediafile.size }} </span>
@ -135,6 +147,7 @@
mat-icon-button
[matMenuTriggerFor]="singleMediafileMenu"
[matMenuTriggerData]="{ mediafile: mediafile }"
[disabled]="isMultiSelect"
*ngIf="showFileMenu(mediafile)"
>
<mat-icon>more_vert</mat-icon>
@ -191,7 +204,7 @@
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item (click)="move(moveDialog, mediafile)">
<button mat-menu-item (click)="move(moveDialog, [mediafile])">
<mat-icon>near_me</mat-icon>
<span translate>Move</span>
</button>
@ -204,7 +217,7 @@
</mat-menu>
<!-- Menu for Mediafiles -->
<!--<mat-menu #mediafilesMenu="matMenu">
<mat-menu #mediafilesMenu="matMenu">
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
@ -212,6 +225,10 @@
</button>
</div>
<div *ngIf="isMultiSelect">
<button mat-menu-item [disabled]="!selectedRows.length" (click)="move(moveDialog, selectedRows)">
<mat-icon>near_me</mat-icon>
<span translate>Move</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="selectAll()">
<mat-icon>done_all</mat-icon>
@ -228,11 +245,11 @@
[disabled]="!selectedRows.length"
(click)="deleteSelected()"
>
<mat-icon>delete</mat-icon>
<mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>-->
</mat-menu>
<!-- File edit dialog -->
<ng-template #fileEditDialog>
@ -310,7 +327,7 @@
<h1 mat-dialog-title>
<span translate>Move into directory</span>
</h1>
<div mat-dialog-content>
<div class="os-form-card-mobile" mat-dialog-content>
<p translate>Please select the directory:</p>
<os-search-value-selector
ngDefaultControl
@ -318,7 +335,7 @@
[includeNone]="true"
[noneTitle]="'Base folder'"
listname="{{ 'Parent directory' | translate }}"
[InputListValues]="directoryBehaviorSubject"
[InputListValues]="filteredDirectoryBehaviorSubject"
></os-search-value-selector>
</div>
<div mat-dialog-actions>

View File

@ -15,8 +15,9 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { columnFactory, createDS, PblDataSource } from '@pebula/ngrid';
import { BehaviorSubject, Subscription } from 'rxjs';
import { columnFactory, createDS, PblColumnDefinition } from '@pebula/ngrid';
import { PblNgridDataMatrixRow } from '@pebula/ngrid/target-events';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { OperatorService } from 'app/core/core-services/operator.service';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
@ -26,7 +27,7 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { BaseViewComponent } from 'app/site/base/base-view';
import { BaseListViewComponent } from 'app/site/base/base-list-view';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { ViewGroup } from 'app/site/users/models/view-group';
import { MediafilesSortListService } from '../../services/mediafiles-sort-list.service';
@ -41,12 +42,7 @@ import { MediafilesSortListService } from '../../services/mediafiles-sort-list.s
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class MediafileListComponent extends BaseViewComponent implements OnInit, OnDestroy {
/**
* Data source for the files
*/
public dataSource: PblDataSource<ViewMediafile>;
export class MediafileListComponent extends BaseListViewComponent<ViewMediafile> implements OnInit, OnDestroy {
/**
* Holds the actions for logos. Updated via an observable
*/
@ -65,15 +61,11 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
public newDirectoryForm: FormGroup;
public moveForm: FormGroup;
public directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
public filteredDirectoryBehaviorSubject: BehaviorSubject<ViewMediafile[]> = new BehaviorSubject<ViewMediafile[]>(
[]
);
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
/**
* @returns true if the user can manage media files
*/
public get canUploadFiles(): boolean {
return this.operator.hasPerms('mediafiles.can_see') && this.operator.hasPerms('mediafiles.can_upload');
}
/**
* @return true if the user can manage media files
*/
@ -113,6 +105,10 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
hidden.push('info');
}
if (!this.isMultiSelect) {
hidden.push('selection');
}
if (!this.canAccessFileMenu) {
hidden.push('menu');
}
@ -120,45 +116,55 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
return hidden;
}
/**
* Define the column definition
*/
public tableColumnDefinition: PblColumnDefinition[] = [
{
prop: 'selection',
width: '40px'
},
{
prop: 'icon',
label: '',
width: '40px'
},
{
prop: 'title',
width: 'auto',
minWidth: 60
},
{
prop: 'info',
width: '20%',
minWidth: 60
},
{
prop: 'indicator',
label: '',
width: '40px'
},
{
prop: 'menu',
label: '',
width: '40px'
}
];
/**
* Create the column set
*/
public columnSet = columnFactory()
.table(
{
prop: 'icon',
label: '',
width: '40px'
},
{
prop: 'title',
width: 'auto',
minWidth: 60
},
{
prop: 'info',
width: '20%',
minWidth: 60
},
{
prop: 'indicator',
label: '',
width: '40px'
},
{
prop: 'menu',
label: '',
width: '40px'
}
)
.table(...this.tableColumnDefinition)
.build();
public isMultiselect = false; // TODO
private folderSubscription: Subscription;
private directorySubscription: Subscription;
public directory: ViewMediafile | null;
public directoryChain: ViewMediafile[];
private directoryObservable: Observable<ViewMediafile[]> = new Observable();
/**
* Constructs the component
*
@ -194,6 +200,7 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
private cd: ChangeDetectorRef
) {
super(titleService, translate, matSnackBar);
this.canMultiSelect = true;
this.newDirectoryForm = this.formBuilder.group({
title: ['', Validators.required],
@ -226,6 +233,8 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
this.mediaManage.getFontActions().subscribe(action => {
this.fontActions = action;
});
this.createDataSource();
}
public ngOnDestroy(): void {
@ -250,25 +259,38 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
return new Date(timestamp).toLocaleString(this.translate.currentLang);
}
/**
* TODO: Swap logic to only create DS once and update on filder change
* @param mediafiles
*/
private createDataSource(): void {
this.dataSource = createDS<ViewMediafile>()
.onTrigger(() => this.directoryObservable)
.create();
this.dataSource.selection.changed.subscribe(selection => {
this.selectedRows = selection.source.selected;
});
}
public changeDirectory(directoryId: number | null): void {
this.clearSubscriptions();
this.folderSubscription = this.repo.getListObservableDirectory(directoryId).subscribe(mediafiles => {
this.directoryObservable = this.repo.getListObservableDirectory(directoryId);
this.folderSubscription = this.directoryObservable.subscribe(mediafiles => {
if (mediafiles) {
this.dataSource = createDS<ViewMediafile>()
.onTrigger(() => mediafiles)
.create();
this.cd.detectChanges();
this.dataSource.refresh();
this.cd.markForCheck();
}
});
if (directoryId) {
this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(d => {
this.directory = d;
if (d) {
this.directoryChain = d.getDirectoryChain();
this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(newDirectory => {
this.directory = newDirectory;
if (newDirectory) {
this.directoryChain = newDirectory.getDirectoryChain();
// Update the URL.
this.router.navigate(['/mediafiles/files/' + d.path], {
this.router.navigate(['/mediafiles/files/' + newDirectory.path], {
replaceUrl: true
});
} else {
@ -287,8 +309,6 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
}
}
/**
*/
public onMainEvent(): void {
const path = '/mediafiles/upload/' + (this.directory ? this.directory.path : '');
this.router.navigate([path]);
@ -300,20 +320,22 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
* @param file the selected file
*/
public onEditFile(file: ViewMediafile): void {
this.fileToEdit = file;
if (!this.isMultiSelect) {
this.fileToEdit = file;
this.fileEditForm = this.fb.group({
title: [file.title, Validators.required],
access_groups_id: [file.access_groups_id]
});
this.fileEditForm = this.fb.group({
title: [file.filename, Validators.required],
access_groups_id: [file.access_groups_id]
});
const dialogRef = this.dialog.open(this.fileEditDialog, infoDialogSettings);
const dialogRef = this.dialog.open(this.fileEditDialog, infoDialogSettings);
dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey && this.fileEditForm.valid) {
this.onSaveEditedFile(this.fileEditForm.value);
}
});
dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey && this.fileEditForm.valid) {
this.onSaveEditedFile(this.fileEditForm.value);
}
});
}
}
/**
@ -338,6 +360,14 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
}
}
public async deleteSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected files and folders?');
if (await this.promptService.open(title)) {
await this.repo.bulkDelete(this.selectedRows);
this.deselectAll();
}
}
/**
* Returns the display name of an action
*
@ -415,22 +445,50 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit,
parent_id: this.directory ? this.directory.id : null,
is_directory: true
});
this.repo.create(mediafile).then(null, this.raiseError);
this.repo.create(mediafile).catch(this.raiseError);
}
});
}
public move(templateRef: TemplateRef<string>, mediafile: ViewMediafile): void {
this.newDirectoryForm.reset();
public move(templateRef: TemplateRef<string>, mediafiles: ViewMediafile[]): void {
this.moveForm.reset();
if (mediafiles.some(file => file.is_directory)) {
this.filteredDirectoryBehaviorSubject.next(
this.directoryBehaviorSubject.value.filter(
dir => !mediafiles.some(file => dir.path.startsWith(file.path))
)
);
} else {
this.filteredDirectoryBehaviorSubject.next(this.directoryBehaviorSubject.value);
}
const dialogRef = this.dialog.open(templateRef, infoDialogSettings);
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.repo.move([mediafile], this.moveForm.value.directory_id).then(null, this.raiseError);
this.repo.move(mediafiles, this.moveForm.value.directory_id).then(() => {
this.dataSource.selection.clear();
this.cd.markForCheck();
}, this.raiseError);
}
});
}
/**
* TODO: This is basically a duplicate of onSelectRow of ListViewTableComponent
*/
public onSelectRow(event: PblNgridDataMatrixRow<ViewMediafile>): void {
if (this.isMultiSelect) {
const clickedModel: ViewMediafile = event.row;
const alreadySelected = this.dataSource.selection.isSelected(clickedModel);
if (alreadySelected) {
this.dataSource.selection.deselect(clickedModel);
} else {
this.dataSource.selection.select(clickedModel);
}
}
}
private clearSubscriptions(): void {
if (this.folderSubscription) {
this.folderSubscription.unsubscribe();

View File

@ -17,7 +17,7 @@ const routes: Routes = [
},
{
path: 'upload',
data: { basePerm: 'mediafiles.can_upload' },
data: { basePerm: 'mediafiles.can_manage' },
children: [{ path: '**', component: MediaUploadComponent }],
pathMatch: 'prefix'
}

View File

@ -50,6 +50,14 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
}
public get title(): string {
if (this.is_directory) {
return this.mediafile.path;
} else {
return this.mediafile.title;
}
}
public get filename(): string {
return this.mediafile.title;
}

View File

@ -32,7 +32,14 @@ class MediafileViewSet(ModelViewSet):
"""
if self.action in ("list", "retrieve", "metadata"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ("create", "partial_update", "update", "move", "destroy"):
elif self.action in (
"create",
"partial_update",
"update",
"move",
"destroy",
"bulk_delete",
):
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_manage"
)
@ -150,6 +157,49 @@ class MediafileViewSet(ModelViewSet):
return Response()
@list_route(methods=["post"])
def bulk_delete(self, request):
"""
Deletes mediafiles *from one directory*. Expected data:
{ ids: [<id>, <id>, ...] }
It is checked, that every id belongs to the same directory.
"""
# Validate data:
if not isinstance(request.data, dict):
raise ValidationError({"detail": "The data must be a dict"})
ids = request.data.get("ids")
if not isinstance(ids, list):
raise ValidationError({"detail": "The ids must be a list"})
for id in ids:
if not isinstance(id, int):
raise ValidationError({"detail": "All ids must be an int"})
# Get mediafiles
mediafiles = []
for id in ids:
try:
mediafiles.append(Mediafile.objects.get(pk=id))
except Mediafile.DoesNotExist:
raise ValidationError(
{"detail": f"The mediafile with id {id} does not exist"}
)
if not mediafiles:
return Response()
# Validate, that they are in the same directory:
directory_id = mediafiles[0].parent_id
for mediafile in mediafiles:
if mediafile.parent_id != directory_id:
raise ValidationError(
{"detail": "All mediafiles must be in the same directory."}
)
with watch_and_update_configs():
for mediafile in mediafiles:
mediafile.delete()
return Response()
def get_mediafile(request, path):
"""