Merge pull request #4188 from MaximilianKrambach/hierarchicAgenda

agenda hierarchies
This commit is contained in:
Emanuel Schütze 2019-01-29 16:08:03 +01:00 committed by GitHub
commit c52b82e77d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 229 additions and 23 deletions

View File

@ -3,13 +3,15 @@ import { Routes, RouterModule } from '@angular/router';
import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component'; import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component';
import { AgendaListComponent } from './components/agenda-list/agenda-list.component'; import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component';
import { SpeakerListComponent } from './components/speaker-list/speaker-list.component'; import { SpeakerListComponent } from './components/speaker-list/speaker-list.component';
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AgendaListComponent }, { path: '', component: AgendaListComponent },
{ path: 'import', component: AgendaImportListComponent }, { path: 'import', component: AgendaImportListComponent },
{ path: 'topics/new', component: TopicDetailComponent }, { path: 'topics/new', component: TopicDetailComponent },
{ path: 'sort-agenda', component: AgendaSortComponent },
{ path: 'topics/:id', component: TopicDetailComponent }, { path: 'topics/:id', component: TopicDetailComponent },
{ path: ':id/speakers', component: SpeakerListComponent } { path: ':id/speakers', component: SpeakerListComponent }
]; ];

View File

@ -7,6 +7,7 @@ import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info
import { AgendaRoutingModule } from './agenda-routing.module'; import { AgendaRoutingModule } from './agenda-routing.module';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component';
/** /**
* AppModule for the agenda and it's children. * AppModule for the agenda and it's children.
@ -14,6 +15,12 @@ import { TopicDetailComponent } from './components/topic-detail/topic-detail.com
@NgModule({ @NgModule({
imports: [CommonModule, AgendaRoutingModule, SharedModule], imports: [CommonModule, AgendaRoutingModule, SharedModule],
entryComponents: [ItemInfoDialogComponent], entryComponents: [ItemInfoDialogComponent],
declarations: [AgendaListComponent, TopicDetailComponent, ItemInfoDialogComponent, AgendaImportListComponent] declarations: [
AgendaListComponent,
TopicDetailComponent,
ItemInfoDialogComponent,
AgendaImportListComponent,
AgendaSortComponent
]
}) })
export class AgendaModule {} export class AgendaModule {}

View File

@ -11,10 +11,9 @@
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button> <button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span> <span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div> </div>
</os-head-bar> </os-head-bar>
<mat-drawer-container class="on-transition-fade"> <mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [filterService] = "filterService" (searchFieldChange)="searchFilter($event)"></os-sort-filter-bar> <os-sort-filter-bar [filterService]="filterService" (searchFieldChange)="searchFilter($event)"></os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort> <mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- selector column --> <!-- selector column -->
@ -30,8 +29,10 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Topic</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Topic</mat-header-cell>
<!-- <mat-cell (click)="onTitleColumn(item)" *matCellDef="let item"> --> <!-- <mat-cell (click)="onTitleColumn(item)" *matCellDef="let item"> -->
<mat-cell (click)="selectItem(item, $event)" *matCellDef="let item"> <mat-cell (click)="selectItem(item, $event)" *matCellDef="let item">
<span *ngIf="item.closed"> <mat-icon class="done-check">check</mat-icon> </span> <div [ngStyle]="{'margin-left': item.agendaListLevel * 25 + 'px' }">
<span> {{ item.getListTitle() }} </span> <span *ngIf="item.closed"> <mat-icon class="done-check">check</mat-icon> </span>
<span class="table-view-list-title">{{ item.getListTitle() }}</span>
</div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
@ -61,7 +62,10 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Speakers</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Speakers</mat-header-cell>
<mat-cell *matCellDef="let item"> <mat-cell *matCellDef="let item">
<button mat-icon-button (click)="onSpeakerIcon(item)"> <button mat-icon-button (click)="onSpeakerIcon(item)">
<mat-icon [matBadge]="item.waitingSpeakerAmount > 0 ? item.waitingSpeakerAmount : null" matBadgeColor="accent"> <mat-icon
[matBadge]="item.waitingSpeakerAmount > 0 ? item.waitingSpeakerAmount : null"
matBadgeColor="accent"
>
mic mic
</mat-icon> </mat-icon>
</button> </button>
@ -102,14 +106,18 @@
<mat-icon>format_list_numbered</mat-icon> <mat-icon>format_list_numbered</mat-icon>
<span translate>Numbering</span> <span translate>Numbering</span>
</button> </button>
<button mat-menu-item routerLink="sort-agenda">
<mat-icon>sort</mat-icon>
<span translate>Sort</span>
</button>
</div> </div>
<button mat-menu-item (click)="csvExportItemList();"> <button mat-menu-item (click)="csvExportItemList()">
<mat-icon>archive</mat-icon> <mat-icon>archive</mat-icon>
<span translate>Export as CSV</span> <span translate>Export as CSV</span>
</button> </button>
<button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import"> <button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
<span translate>Import</span><span>&nbsp;...</span> <span translate>Import</span><span>&nbsp;...</span>
</button> </button>
</div> </div>
@ -179,7 +187,10 @@
<!-- List of speakers for mobile --> <!-- List of speakers for mobile -->
<button mat-menu-item (click)="onSpeakerIcon(item)" *ngIf="vp.isMobile"> <button mat-menu-item (click)="onSpeakerIcon(item)" *ngIf="vp.isMobile">
<mat-icon [matBadge]="item.waitingSpeakerAmount > 0 ? item.waitingSpeakerAmount : null" matBadgeColor="accent"> <mat-icon
[matBadge]="item.waitingSpeakerAmount > 0 ? item.waitingSpeakerAmount : null"
matBadgeColor="accent"
>
mic mic
</mat-icon> </mat-icon>
<span translate>List of speakers</span> <span translate>List of speakers</span>

View File

@ -81,8 +81,9 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
public ngOnInit(): void { public ngOnInit(): void {
super.setTitle('Agenda'); super.setTitle('Agenda');
this.initTable(); this.initTable();
this.filterService.filter().subscribe(newAgendaItem => { this.filterService.filter().subscribe(newAgendaItems => {
this.dataSource.data = newAgendaItem; newAgendaItems.sort((a, b) => a.agendaListWeight - b.agendaListWeight);
this.dataSource.data = newAgendaItems;
this.checkSelection(); this.checkSelection();
}); });
this.config this.config

View File

@ -0,0 +1,23 @@
<os-head-bar [nav]="false">
<!-- Title -->
<div class="title-slot"><h2 translate>Sort agenda</h2></div>
</os-head-bar>
<mat-card>
<div>
<span translate>
Drag and drop items to change the order of the agenda. Your modification will be saved immediately.
</span>
</div>
<button mat-button (click)="expandCollapseAll(true)">{{ 'Expand all' | translate }}</button>
<button mat-button (click)="expandCollapseAll(false)">{{ 'Collapse all' | translate }}</button>
<os-sorting-tree
#sorter
(sort)="sort($event)"
parentIdKey="parent_id"
weightKey="weight"
[modelsObservable]="itemsObservable"
[expandCollapseAll]="expandCollapse"
>
</os-sorting-tree>
</mat-card>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AgendaSortComponent } from './agenda-sort.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('AgendaSortComponent', () => {
let component: AgendaSortComponent;
let fixture: ComponentFixture<AgendaSortComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [AgendaSortComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AgendaSortComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,66 @@
import { Component, EventEmitter } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { AgendaRepositoryService } from '../../services/agenda-repository.service';
import { BaseViewComponent } from '../../../base/base-view';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { ViewItem } from '../../models/view-item';
/**
* Sort view for the agenda.
*/
@Component({
selector: 'os-agenda-sort',
templateUrl: './agenda-sort.component.html'
})
export class AgendaSortComponent extends BaseViewComponent {
/**
* All agendaItems sorted by their virtual weight {@link ViewItem.agendaListWeight}
*/
public itemsObservable: Observable<ViewItem[]>;
/**
* Emits true for expand and false for collapse. Informs the sorter component about this actions.
*/
public readonly expandCollapse: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* Updates the incoming/changing agenda items.
* @param title
* @param translate
* @param matSnackBar
* @param agendaRepo
*/
public constructor(
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private agendaRepo: AgendaRepositoryService
) {
super(title, translate, matSnackBar);
this.itemsObservable = this.agendaRepo.getViewModelListObservable();
}
/**
* Handler for the sort event. The data to change is given to the repo, sending it to the server.
*
* @param data The event data. The representation fits the servers requirements, so it can directly
* be send to the server via the repository.
*/
public sort(data: OSTreeSortEvent<ViewItem>): void {
this.agendaRepo.sortItems(data).then(null, this.raiseError);
}
/**
* Fires the expandCollapse event emitter.
*
* @param expand True, if the tree should be expanded. Otherwise collapsed
*/
public expandCollapseAll(expand: boolean): void {
this.expandCollapse.emit(expand);
}
}

View File

@ -7,6 +7,19 @@ export class ViewItem extends BaseViewModel {
private _item: Item; private _item: Item;
private _contentObject: AgendaBaseModel; private _contentObject: AgendaBaseModel;
/**
* virtual weight defined by the order in the agenda tree, representing a shortcut to sorting by
* weight, parent_id and the parents' weight(s)
* TODO will be accurate if the viewMotion is observed via {@link getViewModelListObservable}, else, it will be undefined
*/
public agendaListWeight: number;
/**
* The amount of parents in the agenda list tree.
* TODO will be accurate if the viewMotion is observed via {@link getViewModelListObservable}, else, it will be undefined
*/
public agendaListLevel: number;
public get item(): Item { public get item(): Item {
return this._item; return this._item;
} }
@ -67,6 +80,21 @@ export class ViewItem extends BaseViewModel {
return this.item ? this.item.speakers : []; return this.item ? this.item.speakers : [];
} }
/**
* @returns the weight the server assigns to that item. Mostly useful for sorting within
* it's own hierarchy level (items sharing a parent)
*/
public get weight(): number {
return this.item ? this.item.weight : null;
}
/**
* @returns the parent's id of that item (0 if no parent is set).
*/
public get parent_id(): number {
return this.item ? this.item.parent_id : null;
}
public constructor(item: Item, contentObject: AgendaBaseModel) { public constructor(item: Item, contentObject: AgendaBaseModel) {
super(); super();
this._item = item; this._item = item;

View File

@ -1,21 +1,23 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators'; import { tap, map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BaseRepository } from '../../base/base-repository'; import { BaseRepository } from '../../base/base-repository';
import { DataStoreService } from '../../../core/services/data-store.service';
import { Item } from '../../../shared/models/agenda/item';
import { ViewItem } from '../models/view-item';
import { AgendaBaseModel } from '../../../shared/models/base/agenda-base-model'; import { AgendaBaseModel } from '../../../shared/models/base/agenda-base-model';
import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseModel } from '../../../shared/models/base/base-model';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service'; import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
import { ViewSpeaker } from '../models/view-speaker';
import { Speaker } from 'app/shared/models/agenda/speaker';
import { User } from 'app/shared/models/users/user';
import { HttpService } from 'app/core/services/http.service';
import { ConfigService } from 'app/core/services/config.service'; import { ConfigService } from 'app/core/services/config.service';
import { DataSendService } from 'app/core/services/data-send.service'; import { DataSendService } from 'app/core/services/data-send.service';
import { DataStoreService } from '../../../core/services/data-store.service';
import { HttpService } from 'app/core/services/http.service';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { Item } from '../../../shared/models/agenda/item';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { Speaker } from 'app/shared/models/agenda/speaker';
import { User } from 'app/shared/models/users/user';
import { ViewItem } from '../models/view-item';
import { ViewSpeaker } from '../models/view-speaker';
import { TreeService } from 'app/core/services/tree.service';
/** /**
* Repository service for users * Repository service for users
@ -34,13 +36,15 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
* @param mapperService OpenSlides mapping service for collection strings * @param mapperService OpenSlides mapping service for collection strings
* @param config Read config variables * @param config Read config variables
* @param dataSend send models to the server * @param dataSend send models to the server
* @param treeService sort the data according to weight and parents
*/ */
public constructor( public constructor(
protected DS: DataStoreService, protected DS: DataStoreService,
private httpService: HttpService, private httpService: HttpService,
mapperService: CollectionStringModelMapperService, mapperService: CollectionStringModelMapperService,
private config: ConfigService, private config: ConfigService,
private dataSend: DataSendService private dataSend: DataSendService,
private treeService: TreeService
) { ) {
super(DS, mapperService, Item); super(DS, mapperService, Item);
} }
@ -229,4 +233,37 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
public getDefaultAgendaVisibility(): Observable<number> { public getDefaultAgendaVisibility(): Observable<number> {
return this.config.get('agenda_new_items_default_visibility').pipe(map(key => +key)); return this.config.get('agenda_new_items_default_visibility').pipe(map(key => +key));
} }
/**
* Sends the changed nodes to the server.
*
* @param data The reordered data from the sorting
*/
public async sortItems(data: OSTreeSortEvent<ViewItem>): Promise<void> {
const url = '/rest/agenda/item/sort/';
await this.httpService.post(url, data);
}
/**
* Add custom hook into the observables. The ViewItems get a virtual agendaListWeight (a sequential number)
* for the agenda topic order, and a virtual level for the hierarchy in the agenda list tree. Both values can be used
* for sorting and ordering instead of dealing with the sort parent id and weight.
*
* @override
*/
public getViewModelListObservable(): Observable<ViewItem[]> {
return super.getViewModelListObservable().pipe(
tap(items => {
const iterator = this.treeService.traverseItems(items, 'weight', 'parent_id');
let m: IteratorResult<ViewItem>;
let virtualWeightCounter = 0;
while (!(m = iterator.next()).done) {
m.value.agendaListWeight = virtualWeightCounter++;
m.value.agendaListLevel = m.value.parent_id
? this.getViewModel(m.value.parent_id).agendaListLevel + 1
: 0;
}
})
);
}
} }

View File

@ -560,3 +560,8 @@ button.mat-menu-item.selected {
max-width: 50%; max-width: 50%;
} }
} }
.table-view-list-title {
font-weight: 500;
font-size: 16px;
}