Merge pull request #5069 from jsangmeister/los-edit-mode

Edit mode for List of Speakers
This commit is contained in:
Finn Stutzenstein 2019-10-15 16:08:10 +03:00 committed by GitHub
commit 7282c541dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 226 additions and 63 deletions

View File

@ -168,6 +168,16 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
await this.httpService.post(restUrl, { speakers: speakerIds });
}
/**
* Readds the last speaker to the list of speakers
*
* @param listOfSpeakers the list of speakers to modify
*/
public async readdLastSpeaker(listOfSpeakers: ViewListOfSpeakers): Promise<void> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'readd_last_speaker');
await this.httpService.post(restUrl);
}
/**
* Marks all speakers for a given user
*
@ -207,7 +217,10 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
* @param listOfSpeakersId id of the list of speakers
* @param method the desired speaker action
*/
private getRestUrl(listOfSpeakersId: number, method: 'manage_speaker' | 'sort_speakers' | 'speak'): string {
private getRestUrl(
listOfSpeakersId: number,
method: 'manage_speaker' | 'sort_speakers' | 'speak' | 'readd_last_speaker'
): string {
return `/rest/agenda/list-of-speakers/${listOfSpeakersId}/${method}/`;
}
}

View File

@ -1,10 +1,10 @@
<div cdkDropList [cdkDropListDisabled]="!enable" (cdkDropListDropped)="drop($event)">
<div class="line" *ngIf="!array.length">
<div class="line" *ngIf="!sortedItems.length">
<span translate>No data</span>
</div>
<div
[ngClass]="isSelectedRow(i) ? 'backgroundColorSelected line' : 'backgroundColorLight line'"
*ngFor="let item of array; let i = index"
*ngFor="let item of sortedItems; let i = index"
cdkDrag
(click)="onItemClick($event, i)"
(cdkDragStarted)="dragStarted(i)"

View File

@ -34,7 +34,7 @@ export class SortingListComponent implements OnInit, OnDestroy {
/**
* Sorted and returned
*/
public array: Selectable[];
public sortedItems: Selectable[];
/**
* The index of multiple selected elements. Allows for multiple items to be
@ -104,6 +104,11 @@ export class SortingListComponent implements OnInit, OnDestroy {
*/
private inputSubscription: Subscription | null;
/**
* Always stores the current items from the last update. Needed for restore and changing between live=true/false
*/
private currentItems: Selectable[];
/**
* Inform the parent view about sorting.
* Alternative approach to submit a new order of elements
@ -118,7 +123,7 @@ export class SortingListComponent implements OnInit, OnDestroy {
* @param translate the translation service
*/
public constructor(protected translate: TranslateService) {
this.array = [];
this.sortedItems = [];
}
/**
@ -141,14 +146,21 @@ export class SortingListComponent implements OnInit, OnDestroy {
* @param newValues The new values to set.
*/
private updateArray(newValues: Selectable[]): void {
if (this.array.length !== newValues.length || this.live) {
this.array = [];
this.array = newValues.map(val => val);
this.currentItems = newValues.map(val => val);
if (this.sortedItems.length !== newValues.length || this.live) {
this.sortedItems = newValues.map(val => val);
} else {
this.array = this.array.map(arrayValue => newValues.find(val => val.id === arrayValue.id));
this.sortedItems = this.sortedItems.map(arrayValue => newValues.find(val => val.id === arrayValue.id));
}
}
/**
* Restore the old order from the last update
*/
public restore(): void {
this.sortedItems = this.currentItems.map(val => val);
}
/**
* Handles the start of a dragDrop event and clears multiSelect if the ittem dragged
* is not part of the selected items
@ -171,35 +183,35 @@ export class SortingListComponent implements OnInit, OnDestroy {
dropBehind?: boolean
): void {
if (!this.multiSelectedIndex.length) {
moveItemInArray(this.array, event.previousIndex, event.currentIndex);
moveItemInArray(this.sortedItems, event.previousIndex, event.currentIndex);
} else {
const before: Selectable[] = [];
const insertions: Selectable[] = [];
const behind: Selectable[] = [];
for (let i = 0; i < this.array.length; i++) {
for (let i = 0; i < this.sortedItems.length; i++) {
if (!this.multiSelectedIndex.includes(i)) {
if (i < event.currentIndex) {
before.push(this.array[i]);
before.push(this.sortedItems[i]);
} else if (i > event.currentIndex) {
behind.push(this.array[i]);
behind.push(this.sortedItems[i]);
} else {
if (dropBehind === false) {
behind.push(this.array[i]);
behind.push(this.sortedItems[i]);
} else if (dropBehind === true) {
before.push(this.array[i]);
before.push(this.sortedItems[i]);
} else {
Math.min(...this.multiSelectedIndex) < i
? before.push(this.array[i])
: behind.push(this.array[i]);
? before.push(this.sortedItems[i])
: behind.push(this.sortedItems[i]);
}
}
} else {
insertions.push(this.array[i]);
insertions.push(this.sortedItems[i]);
}
}
this.array = [...before, ...insertions, ...behind];
this.sortedItems = [...before, ...insertions, ...behind];
}
this.sortEvent.emit(this.array);
this.sortEvent.emit(this.sortedItems);
this.multiSelectedIndex = [];
}

View File

@ -1,12 +1,14 @@
<os-head-bar [nav]="false" [goBack]="true">
<os-head-bar [nav]="false" [goBack]="true" [editMode]="isSortMode" (cancelEditEvent)="onCancelSorting()" (saveEvent)="onSaveSorting()">
<!-- Title -->
<div class="title-slot">
<h2>
<span *ngIf="!isCurrentListOfSpeakers" translate>List of speakers</span>
<span *ngIf="isCurrentListOfSpeakers" translate>Current list of speakers</span>
<span *ngIf="!isCurrentListOfSpeakers && !isSortMode" translate>List of speakers</span>
<span *ngIf="isCurrentListOfSpeakers && !isSortMode" translate>Current list of speakers</span>
<span *ngIf="isSortMode" translate>Sort mode</span>
</h2>
</div>
<div class="menu-slot" *osPerms="['agenda.can_manage_list_of_speakers', 'core.can_manage_projector']">
<button type="button" mat-icon-button matTooltip="{{ 'Re-add last speaker' | translate }}" (click)="readdLastSpeaker()" [disabled]="!finishedSpeakers || !finishedSpeakers.length"><mat-icon>undo</mat-icon></button>
<button type="button" mat-icon-button [matMenuTriggerFor]="speakerMenu"><mat-icon>more_vert</mat-icon></button>
</div>
</os-head-bar>
@ -77,10 +79,9 @@
<div class="waiting-list" *ngIf="speakers && speakers.length > 0">
<os-sorting-list
[input]="speakers"
[live]="true"
[live]="!isSortMode"
[count]="true"
[enable]="opCanManage()"
(sortEvent)="onSortingChange($event)"
[enable]="opCanManage() && isSortMode"
>
<!-- implicit speaker references into the component using ng-template slot -->
<ng-template let-speaker>
@ -154,6 +155,11 @@
</mat-card>
<mat-menu #speakerMenu="matMenu">
<button mat-menu-item (click)="isSortMode = true">
<mat-icon>sort</mat-icon>
<span translate>Enable sorting</span>
</button>
<os-projector-button
*ngIf="viewListOfSpeakers && projectors && projectors.length > 1"
[object]="getClosSlide()"

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
@ -15,6 +15,7 @@ import { UserRepositoryService } from 'app/core/repositories/users/user-reposito
import { ConfigService } from 'app/core/ui-services/config.service';
import { DurationService } from 'app/core/ui-services/duration.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewProjector } from 'app/site/projector/models/view-projector';
@ -33,11 +34,19 @@ import { SpeakerState, ViewSpeaker } from '../../models/view-speaker';
styleUrls: ['./list-of-speakers.component.scss']
})
export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit {
@ViewChild(SortingListComponent, { static: false })
public listElement: SortingListComponent;
/**
* Determine if the user is viewing the current list if speakers
*/
public isCurrentListOfSpeakers = false;
/**
* Holds whether the list is in sort mode or not
*/
public isSortMode = false;
/**
* Holds the view item to the given topic
*/
@ -297,14 +306,25 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
}
/**
* React to manual in the sorting order.
* Informs the repo about changes in the order
* @param listInNewOrder Contains the newly ordered list of ViewSpeakers
* send the current order of the sorting list to the server
*/
public onSortingChange(listInNewOrder: ViewSpeaker[]): void {
// extract the ids from the ViewSpeaker array
const userIds = listInNewOrder.map(speaker => speaker.id);
this.listOfSpeakersRepo.sortSpeakers(this.viewListOfSpeakers, userIds).then(null, this.raiseError);
public onSaveSorting(): void {
if (this.isSortMode) {
this.isSortMode = false;
this.listOfSpeakersRepo
.sortSpeakers(this.viewListOfSpeakers, this.listElement.sortedItems.map(el => el.id))
.catch(this.raiseError);
}
}
/**
* Restore old order on cancel
*/
public onCancelSorting(): void {
if (this.isSortMode) {
this.isSortMode = false;
this.listElement.restore();
}
}
/**
@ -339,9 +359,14 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
* @param speaker The speaker clicked on.
*/
public onMarkButton(speaker: ViewSpeaker): void {
this.listOfSpeakersRepo
.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked)
.then(null, this.raiseError);
this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError);
}
/**
* Removes the last finished speaker from the list an re-adds him on pole position
*/
public readdLastSpeaker(): void {
this.listOfSpeakersRepo.readdLastSpeaker(this.viewListOfSpeakers).catch(this.raiseError);
}
/**
@ -351,6 +376,10 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
* the operator themself is removed
*/
public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> {
const title = this.translate.instant(
'Are you sure you want to delete this speaker from this list of speakers?'
);
if (await this.promptService.open(title)) {
try {
await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null);
this.filterUsers();
@ -358,6 +387,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
this.raiseError(e);
}
}
}
/**
* Returns true if the operator is in the list of (waiting) speakers

View File

@ -216,7 +216,7 @@ export class CategoryMotionsSortComponent extends BaseViewComponent implements O
if (this.sortSelector.multiSelectedIndex.length) {
}
const content = this.translate.instant('Move selected items ...');
const choices = this.sortSelector.array
const choices = this.sortSelector.sortedItems
.map((item, index) => {
return { id: index, label: item.getTitle() };
})

View File

@ -290,7 +290,13 @@ class ListOfSpeakersViewSet(
result = has_perm(self.request.user, "agenda.can_see_list_of_speakers")
# For manage_speaker requests the rest of the check is
# done in the specific method. See below.
elif self.action in ("update", "partial_update", "speak", "sort_speakers"):
elif self.action in (
"update",
"partial_update",
"speak",
"sort_speakers",
"readd_last_speaker",
):
result = has_perm(
self.request.user, "agenda.can_see_list_of_speakers"
) and has_perm(self.request.user, "agenda.can_manage_list_of_speakers")
@ -344,7 +350,7 @@ class ListOfSpeakersViewSet(
# Try to add the user. This ensurse that a user is not twice in the
# list of coming speakers.
try:
Speaker.objects.add(user, list_of_speakers)
speaker = Speaker.objects.add(user, list_of_speakers)
except OpenSlidesError as e:
raise ValidationError({"detail": str(e)})
@ -520,3 +526,32 @@ class ListOfSpeakersViewSet(
# Initiate response.
return Response({"detail": "List of speakers successfully sorted."})
@detail_route(methods=["POST"])
def readd_last_speaker(self, request, pk=None):
"""
Special view endpoint to re-add the last finished speaker to the list of speakers.
"""
list_of_speakers = self.get_object()
# Retrieve speaker which spoke last and next speaker
ordered_speakers = list_of_speakers.speakers.order_by("-end_time")
if len(ordered_speakers) == 0:
raise ValidationError({"detail": "There is no last speaker at the moment."})
last_speaker = ordered_speakers[0]
if last_speaker.end_time is None:
raise ValidationError({"detail": "There is no last speaker at the moment."})
next_speaker = list_of_speakers.get_next_speaker()
new_weight = 1
# if there is a next speaker, insert last speaker before it
if next_speaker:
new_weight = next_speaker.weight - 1
# reset times of last speaker and prepend it to the list of active speakers
last_speaker.begin_time = last_speaker.end_time = None
last_speaker.weight = new_weight
last_speaker.save()
return Response()

View File

@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient
@ -295,6 +296,14 @@ class ManageSpeaker(TestCase):
password="test_password_e6paev4zeeh9n",
)
def revoke_admin_rights(self):
admin = get_user_model().objects.get(username="admin")
group_admin = admin.groups.get(name="Admin")
group_delegates = type(group_admin).objects.get(name="Delegates")
admin.groups.add(group_delegates)
admin.groups.remove(group_admin)
inform_changed_data(admin)
def test_add_oneself_once(self):
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk])
@ -374,12 +383,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 400)
def test_add_someone_else_non_admin(self):
admin = get_user_model().objects.get(username="admin")
group_admin = admin.groups.get(name="Admin")
group_delegates = type(group_admin).objects.get(name="Delegates")
admin.groups.add(group_delegates)
admin.groups.remove(group_admin)
inform_changed_data(admin)
self.revoke_admin_rights()
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
@ -415,12 +419,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 200)
def test_remove_someone_else_non_admin(self):
admin = get_user_model().objects.get(username="admin")
group_admin = admin.groups.get(name="Admin")
group_delegates = type(group_admin).objects.get(name="Delegates")
admin.groups.add(group_delegates)
admin.groups.remove(group_admin)
inform_changed_data(admin)
self.revoke_admin_rights()
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.delete(
@ -441,12 +440,7 @@ class ManageSpeaker(TestCase):
self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_non_admin(self):
admin = get_user_model().objects.get(username="admin")
group_admin = admin.groups.get(name="Admin")
group_delegates = type(group_admin).objects.get(name="Delegates")
admin.groups.add(group_delegates)
admin.groups.remove(group_admin)
inform_changed_data(admin)
self.revoke_admin_rights()
Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
@ -456,6 +450,79 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 403)
# re-add last speaker
def util_add_user_as_last_speaker(self):
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
speaker.begin_time = timezone.now()
speaker.end_time = timezone.now()
speaker.weight = None
speaker.save()
def test_readd_last_speaker_no_speaker(self):
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 400)
def test_readd_last_speaker_no_last_speaker(self):
Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 400)
def test_readd_last_speaker_has_last_speaker_no_next_speaker(self):
self.util_add_user_as_last_speaker()
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 200)
speaker = Speaker.objects.get()
self.assertTrue(
speaker.begin_time is None
and speaker.end_time is None
and speaker.weight is not None
)
def test_readd_last_speaker_has_last_speaker_and_next_speaker(self):
self.util_add_user_as_last_speaker()
user2 = get_user_model().objects.create_user(
username="test_user_KLGHjkHJKBhjJHGGJKJn",
password="test_password_JHt678VbhjuGhj76hjGA",
)
Speaker.objects.add(user2, self.list_of_speakers)
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 200)
speaker = Speaker.objects.get(user__pk=self.user.pk)
self.assertTrue(
speaker.begin_time is None
and speaker.end_time is None
and speaker.weight is not None
)
def test_readd_last_speaker_no_admin(self):
self.util_add_user_as_last_speaker()
self.revoke_admin_rights()
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 403)
class Speak(TestCase):
"""