diff --git a/client/src/app/shared/models/agenda/item.ts b/client/src/app/shared/models/agenda/item.ts index 0bf57ee3f..29aab42f1 100644 --- a/client/src/app/shared/models/agenda/item.ts +++ b/client/src/app/shared/models/agenda/item.ts @@ -61,6 +61,17 @@ export class Item extends ProjectableBaseModel { return this.speakers.filter(speaker => speaker.state === SpeakerState.WAITING).length; } + /** + * Return the type as string + */ + public get verboseType(): string { + if (this.type) { + return itemVisibilityChoices.find(visibilityType => visibilityType.key === this.type).name; + } else { + return ''; + } + } + public getTitle(): string { return this.title; } diff --git a/client/src/app/site/agenda/agenda.module.ts b/client/src/app/site/agenda/agenda.module.ts index 5db2cc74d..bf49114ff 100644 --- a/client/src/app/site/agenda/agenda.module.ts +++ b/client/src/app/site/agenda/agenda.module.ts @@ -5,12 +5,14 @@ import { AgendaRoutingModule } from './agenda-routing.module'; import { SharedModule } from '../../shared/shared.module'; import { AgendaListComponent } from './components/agenda-list/agenda-list.component'; import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; +import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info-dialog.component'; /** * AppModule for the agenda and it's children. */ @NgModule({ imports: [CommonModule, AgendaRoutingModule, SharedModule], - declarations: [AgendaListComponent, TopicDetailComponent] + entryComponents: [ ItemInfoDialogComponent ], + declarations: [AgendaListComponent, TopicDetailComponent, ItemInfoDialogComponent] }) export class AgendaModule {} diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html index cf15cd376..7fb15ed2a 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html @@ -21,7 +21,7 @@ - + {{ isSelected(item) ? 'check_circle' : '' }} @@ -29,13 +29,32 @@ Topic - {{ item.getListTitle() }} + + + check + {{ item.getListTitle() }} + - - - Duration - {{ item.duration }} + + + Info + +
+
+ visibility + {{ item.verboseType | translate }} +
+
+ access_time + {{ durationService.durationToString(item.duration) }} +
+
+ comment + {{ item.comment }} +
+
+
@@ -50,10 +69,20 @@
+ + + Menu + + + + + @@ -61,46 +90,75 @@
- +
+ + + + + +
+ + + + +
+ + + - - + + + + +
+ + + + + + + + + + + + + + + + diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.scss b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.scss index fcfed1618..160ddf7c7 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.scss +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.scss @@ -3,17 +3,42 @@ /** Title */ .mat-column-title { padding-left: 26px; - flex: 1 0 200px; + flex: 2 0 0; + + .done-check { + margin-right: 10px; + } } /** Duration */ - .mat-column-duration { - flex: 0 0 100px; + .mat-column-info { + flex: 2 0 0; + + .info-col-items { + display: inline-block; + white-space: nowrap; + + font-size: 14px; + .mat-icon { + display: inline-flex; + vertical-align: middle; + $icon-size: 18px; + font-size: $icon-size; + height: $icon-size; + width: $icon-size; + } + + } } /** Speakers indicator */ .mat-column-speakers { - flex: 0 0 100px; + flex: 0 0 50px; + } + + /** menu indicator */ + .mat-column-menu { + flex: 0 0 50px; justify-content: flex-end !important; } } diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts index 1a3209411..e6d134581 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts @@ -1,13 +1,17 @@ import { Component, OnInit } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; -import { MatSnackBar } from '@angular/material'; +import { MatSnackBar, MatDialog } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; import { ViewItem } from '../../models/view-item'; import { ListViewBaseComponent } from 'app/site/base/list-view-base'; import { AgendaRepositoryService } from '../../services/agenda-repository.service'; import { PromptService } from '../../../../core/services/prompt.service'; +import { ItemInfoDialogComponent } from '../item-info-dialog/item-info-dialog.component'; +import { ViewportService } from 'app/core/services/viewport.service'; +import { DurationService } from 'app/site/core/services/duration.service'; +import { ConfigService } from 'app/core/services/config.service'; /** * List view for the agenda. @@ -18,6 +22,18 @@ import { PromptService } from '../../../../core/services/prompt.service'; styleUrls: ['./agenda-list.component.scss'] }) export class AgendaListComponent extends ListViewBaseComponent implements OnInit { + /** + * Determine the display columns in desktop view + */ + public displayedColumnsDesktop: string[] = ['title', 'info', 'speakers', 'menu']; + + /** + * Determine the display columns in mobile view + */ + public displayedColumnsMobile: string[] = ['title', 'menu']; + + public isNumberingAllowed: boolean; + /** * The usual constructor for components * @param titleService Setting the browser tab title @@ -26,8 +42,11 @@ export class AgendaListComponent extends ListViewBaseComponent impleme * @param route Angulars ActivatedRoute * @param router Angulars router * @param repo the agenda repository, - * promptService: - * + * @param promptService the delete prompt + * @param dialog to change info values + * @param config read out config values + * @param vp determine the viewport + * @param durationService Converts numbers to readable duration strings */ public constructor( titleService: Title, @@ -36,7 +55,11 @@ export class AgendaListComponent extends ListViewBaseComponent impleme private route: ActivatedRoute, private router: Router, private repo: AgendaRepositoryService, - private promptService: PromptService + private promptService: PromptService, + private dialog: MatDialog, + private config: ConfigService, + public vp: ViewportService, + public durationService: DurationService ) { super(titleService, translate, matSnackBar); @@ -55,12 +78,17 @@ export class AgendaListComponent extends ListViewBaseComponent impleme this.dataSource.data = newAgendaItem; this.checkSelection(); }); + + this.config + .get('agenda_enable_numbering') + .subscribe(autoNumbering => (this.isNumberingAllowed = autoNumbering)); } /** - * Handler for click events on an agenda item row. Links to the content object + * Links to the content object. * Gets content object from the repository rather than from the model * to avoid race conditions + * * @param item the item that was selected from the list view */ public singleSelectAction(item: ViewItem): void { @@ -68,8 +96,48 @@ export class AgendaListComponent extends ListViewBaseComponent impleme this.router.navigate([contentObject.getDetailStateURL()]); } + /** + * Opens the item-info-dialog. + * Enable direct changing of various information + * + * @param item The view item that was clicked + */ + public openEditInfo(item: ViewItem): void { + const dialogRef = this.dialog.open(ItemInfoDialogComponent, { + width: '400px', + data: item + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + if (result.durationText) { + result.duration = this.durationService.stringToDuration(result.durationText); + } + this.repo.update(result, item); + } + }); + } + + /** + * Click handler for the numbering button to enable auto numbering + */ + public async onAutoNumbering(): Promise { + const content = this.translate.instant('Are you sure you want to number all agenda items?'); + if (await this.promptService.open('', content)) { + await this.repo.autoNumbering().then(null, this.raiseError); + } + } + + /** + * Click handler for the done button in the dot-menu + */ + public async onDoneSingleButton(item: ViewItem): Promise { + await this.repo.update({ closed: !item.closed }, item).then(null, this.raiseError); + } + /** * Handler for the speakers button + * * @param item indicates the row that was clicked on */ public onSpeakerIcon(item: ViewItem): void { @@ -84,6 +152,18 @@ export class AgendaListComponent extends ListViewBaseComponent impleme this.router.navigate(['topics/new'], { relativeTo: this.route }); } + /** + * Delete handler for a single item + * + * @param item The item to delete + */ + public async onDelete(item: ViewItem): Promise { + const content = this.translate.instant('Delete') + ` ${item.getTitle()}?`; + if (await this.promptService.open('Are you sure?', content)) { + await this.repo.delete(item).then(null, this.raiseError); + } + } + /** * Handler for deleting multiple entries. Needs items in selectedRows, which * is only filled with any data in multiSelect mode @@ -110,19 +190,24 @@ export class AgendaListComponent extends ListViewBaseComponent impleme } /** - * Sets multiple entries' visibility. Needs items in selectedRows, which + * Sets multiple entries' agenda type. Needs items in selectedRows, which * is only filled with any data in multiSelect mode. * * @param visible true if the item is to be shown */ - public async setVisibilitySelected(visible: boolean): Promise { + public async setAgendaType(agendaType: number): Promise { for (const agenda of this.selectedRows) { - await this.repo.update({ is_hidden: visible }, agenda); + await this.repo.update({ type: agendaType }, agenda).then(null, this.raiseError); } } + /** + * Determine what columns to show + * + * @returns an array of strings with the dialogs to show + */ public getColumnDefinition(): string[] { - const list = ['title', 'duration', 'speakers']; + const list = this.vp.isMobile ? this.displayedColumnsMobile : this.displayedColumnsDesktop; if (this.isMultiSelect) { return ['selector'].concat(list); } diff --git a/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.html b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.html new file mode 100644 index 000000000..cbad5b191 --- /dev/null +++ b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.html @@ -0,0 +1,39 @@ +

{{ 'Change values for' | translate }} {{ item.getTitle() }}

+
+
+ + + + + {{ type.name | translate }} + + + + + + + + + + + + + + + + + + +
+
+
+ + +
diff --git a/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.scss b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.scss new file mode 100644 index 000000000..fe0684637 --- /dev/null +++ b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.scss @@ -0,0 +1,8 @@ +.itemDialogForm { + display: inline-block; + ::ng-deep { + .mat-form-field { + width: 100%; + } + } +} diff --git a/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.spec.ts b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.spec.ts new file mode 100644 index 000000000..347ecf7fc --- /dev/null +++ b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { async, TestBed } from '@angular/core/testing'; + +// import { ItemInfoDialogComponent } from './item-info-dialog.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ItemInfoDialogComponent', () => { + // let component: ItemInfoDialogComponent; + // let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + // TODO: You cannot create this component in the standard way. Needs different testing. + beforeEach(() => { + /*fixture = TestBed.createComponent(ItemInfoDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges();*/ + }); + + /*it('should create', () => { + expect(component).toBeTruthy(); + });*/ +}); diff --git a/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.ts b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.ts new file mode 100644 index 000000000..23008664b --- /dev/null +++ b/client/src/app/site/agenda/components/item-info-dialog/item-info-dialog.component.ts @@ -0,0 +1,81 @@ +import { Component, Inject } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; + +import { ViewItem } from '../../models/view-item'; +import { itemVisibilityChoices } from 'app/shared/models/agenda/item'; +import { DurationService } from 'app/site/core/services/duration.service'; + +/** + * Dialog component to change agenda item details + */ +@Component({ + selector: 'os-item-info-dialog', + templateUrl: './item-info-dialog.component.html', + styleUrls: ['./item-info-dialog.component.scss'] +}) +export class ItemInfoDialogComponent { + /** + * Holds the agenda item form + */ + public agendaInfoForm: FormGroup; + + /** + * Hold item visibility + */ + public itemVisibility = itemVisibilityChoices; + + /** + * Constructor + * + * @param formBuilder construct the form + * @param durationService Converts numbers to readable duration strings + * @param dialogRef the dialog reference + * @param item the item that was selected + */ + public constructor( + public formBuilder: FormBuilder, + public durationService: DurationService, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public item: ViewItem + ) { + this.agendaInfoForm = this.formBuilder.group({ + type: [''], + durationText: [''], + item_number: [''], + comment: [''] + }); + + // load current values + this.agendaInfoForm.get('type').setValue(item.type); + this.agendaInfoForm.get('durationText').setValue(this.durationService.durationToString(item.duration)); + this.agendaInfoForm.get('item_number').setValue(item.itemNumber); + this.agendaInfoForm.get('comment').setValue(item.comment); + } + + /** + * Function to save the item + */ + public saveItemInfo(): void { + this.dialogRef.close(this.agendaInfoForm.value); + } + + /** + * Click on cancel button + */ + public onCancelButton(): void { + this.dialogRef.close(); + } + + /** + * clicking Shift and Enter will save the form + * + * @param event the key that was clicked + */ + public onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && event.shiftKey) { + this.saveItemInfo(); + } + } +} diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html index 07f291995..925730d82 100644 --- a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html @@ -1,4 +1,6 @@ {{ topicForm.get('title').value }} - +
+
-
+

Attachments: + + + {{ file.title }} + +

@@ -52,14 +60,49 @@ osAutofocus required formControlName="title" - placeholder="{{ 'Title' | translate}}" + placeholder="{{ 'Title' | translate }}" /> A name is required
-
- + + + + + +
+ +
+ + + + {{ type.name | translate }} + + + +
+ + +
+ +
+
diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts index 827e37524..716c1e54d 100644 --- a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts @@ -11,6 +11,11 @@ import { BaseViewComponent } from 'app/site/base/base-view'; import { PromptService } from 'app/core/services/prompt.service'; import { TopicRepositoryService } from '../../services/topic-repository.service'; import { ViewTopic } from '../../models/view-topic'; +import { OperatorService } from 'app/core/services/operator.service'; +import { BehaviorSubject } from 'rxjs'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item'; /** * Detail page for topics. @@ -41,8 +46,24 @@ export class TopicDetailComponent extends BaseViewComponent { */ public topicForm: FormGroup; + /** + * Subject for mediafiles + */ + public mediafilesObserver: BehaviorSubject; + + /** + * Subject for agenda items + */ + public agendaItemObserver: BehaviorSubject; + + /** + * Determine visibility states for the agenda that will be created implicitly + */ + public itemVisibility = itemVisibilityChoices; + /** * Constructor for the topic detail page. + * * @param title Setting the browsers title * @param matSnackBar display errors and other messages * @param translate Handles translations @@ -52,6 +73,8 @@ export class TopicDetailComponent extends BaseViewComponent { * @param formBuilder Angulars FormBuilder * @param repo The topic repository * @param promptService Allows warning before deletion attempts + * @param operator The current user + * @param DS Data Store */ public constructor( title: Title, @@ -62,15 +85,29 @@ export class TopicDetailComponent extends BaseViewComponent { private location: Location, private formBuilder: FormBuilder, private repo: TopicRepositoryService, - private promptService: PromptService + private promptService: PromptService, + private operator: OperatorService, + private DS: DataStoreService ) { super(title, translate, matSnackBar); this.getTopicByUrl(); this.createForm(); + + this.mediafilesObserver = new BehaviorSubject(this.DS.getAll(Mediafile)); + this.agendaItemObserver = new BehaviorSubject(this.DS.getAll(Item)); + + this.DS.changeObservable.subscribe(newModel => { + if (newModel instanceof Item) { + this.agendaItemObserver.next(DS.getAll(Item)); + } else if (newModel instanceof Mediafile) { + this.mediafilesObserver.next(DS.getAll(Mediafile)); + } + }); } /** * Set the edit mode to the given Status + * * @param mode */ public setEditMode(mode: boolean): void { @@ -88,13 +125,17 @@ export class TopicDetailComponent extends BaseViewComponent { */ public async saveTopic(): Promise { if (this.newTopic && this.topicForm.valid) { + if (!this.topicForm.value.agenda_parent_id) { + delete this.topicForm.value.agenda_parent_id; + } + const response = await this.repo.create(this.topicForm.value); this.router.navigate([`/agenda/topics/${response.id}`]); // after creating a new topic, go "back" to agenda list view this.location.replaceState('/agenda/'); } else { - await this.repo.update(this.topicForm.value, this.topic); this.setEditMode(false); + await this.repo.update(this.topicForm.value, this.topic); } } @@ -103,9 +144,14 @@ export class TopicDetailComponent extends BaseViewComponent { */ public createForm(): void { this.topicForm = this.formBuilder.group({ - title: ['', Validators.required], - text: [''] + agenda_type: [], + agenda_parent_id: [], + attachments_id: [[]], + text: [''], + title: ['', Validators.required] }); + + this.topicForm.get('agenda_type').setValue(1); } /** @@ -116,6 +162,7 @@ export class TopicDetailComponent extends BaseViewComponent { Object.keys(this.topicForm.controls).forEach(ctrl => { topicPatch[ctrl] = this.topic[ctrl]; }); + this.topicForm.patchValue(topicPatch); } @@ -139,6 +186,7 @@ export class TopicDetailComponent extends BaseViewComponent { /** * Loads a top from the repository + * * @param id the id of the required topic */ public loadTopic(id: number): void { @@ -172,14 +220,31 @@ export class TopicDetailComponent extends BaseViewComponent { /** * Handler for the delete button. Uses the PromptService */ - public async onDeleteButton(): Promise { + public async onDeleteButton(): Promise { const content = this.translate.instant('Delete') + ` ${this.topic.title}?`; if (await this.promptService.open('Are you sure?', content)) { - await this.repo.delete(this.topic); + await this.repo.delete(this.topic).then(null, this.raiseError); this.router.navigate(['/agenda']); } } + /** + * Checks if the operator is allowed to perform one of the given actions + * + * @param action the desired action + * @returns true if the operator has the correct permissions, false of not + */ + public isAllowed(action: string): boolean { + switch (action) { + case 'see': + return this.operator.hasPerms('agenda.can_manage'); + case 'edit': + return this.operator.hasPerms('agenda.can_see'); + case 'default': + return false; + } + } + /** * clicking Shift and Enter will save automatically * Hitting escape while in topicForm should cancel editing diff --git a/client/src/app/site/agenda/models/view-item.ts b/client/src/app/site/agenda/models/view-item.ts index 7ebb60f85..b82dc8c4f 100644 --- a/client/src/app/site/agenda/models/view-item.ts +++ b/client/src/app/site/agenda/models/view-item.ts @@ -18,6 +18,10 @@ export class ViewItem extends BaseViewModel { return this.item ? this.item.id : null; } + public get itemNumber(): string { + return this.item ? this.item.item_number : null; + } + public get duration(): number { return this.item ? this.item.duration : null; } @@ -26,6 +30,22 @@ export class ViewItem extends BaseViewModel { return this.item ? this.item.speakerAmount : null; } + public get type(): number { + return this.item ? this.item.type : null; + } + + public get verboseType(): string { + return this.item.verboseType; + } + + public get comment(): string { + return this.item ? this.item.comment : null; + } + + public get closed(): boolean { + return this.item ? this.item.closed : null; + } + public constructor(item: Item, contentObject: AgendaBaseModel) { super(); this._item = item; @@ -40,12 +60,20 @@ export class ViewItem extends BaseViewModel { } } + /** + * Create the list view title. + * If a number was given, 'whitespac-dot-whitespace' will be added to the prefix number + * + * @returns the agenda list title as string + */ public getListTitle(): string { const contentObject: AgendaBaseModel = this.contentObject; + const numberPrefix = this.itemNumber ? `${this.itemNumber} ยท ` : ''; + if (contentObject) { - return contentObject.getAgendaTitleWithType(); + return numberPrefix + contentObject.getAgendaTitleWithType(); } else { - return this.item ? this.item.title_with_type : null; + return this.item ? numberPrefix + this.item.title_with_type : null; } } diff --git a/client/src/app/site/agenda/models/view-topic.ts b/client/src/app/site/agenda/models/view-topic.ts index 257f8adf4..743f6f1b9 100644 --- a/client/src/app/site/agenda/models/view-topic.ts +++ b/client/src/app/site/agenda/models/view-topic.ts @@ -2,9 +2,11 @@ import { BaseViewModel } from '../../base/base-view-model'; import { Topic } from 'app/shared/models/topics/topic'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Item } from 'app/shared/models/agenda/item'; +import { BaseModel } from 'app/shared/models/base/base-model'; /** * Provides "safe" access to topic with all it's components + * @ignore */ export class ViewTopic extends BaseViewModel { private _topic: Topic; @@ -31,6 +33,10 @@ export class ViewTopic extends BaseViewModel { return this.topic ? this.topic.agenda_item_id : null; } + public get attachments_id(): number[] { + return this.topic ? this.topic.attachments_id : null; + } + public get title(): string { return this.topic ? this.topic.title : null; } @@ -50,9 +56,16 @@ export class ViewTopic extends BaseViewModel { return this.title; } - public updateValues(update: Topic): void { - if (this.id === update.id) { - this._topic = update; + public hasAttachments(): boolean { + return this.attachments && this.attachments.length > 0; + } + + public updateValues(update: BaseModel): void { + if (update instanceof Mediafile) { + if (this.topic && this.attachments_id && this.attachments_id.includes(update.id)) { + const attachmentIndex = this.attachments.findIndex(mediafile => mediafile.id === update.id); + this.attachments[attachmentIndex] = update as Mediafile; + } } } } diff --git a/client/src/app/site/agenda/services/agenda-repository.service.ts b/client/src/app/site/agenda/services/agenda-repository.service.ts index cb2a4d5c9..3624bb3b1 100644 --- a/client/src/app/site/agenda/services/agenda-repository.service.ts +++ b/client/src/app/site/agenda/services/agenda-repository.service.ts @@ -15,6 +15,7 @@ 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 { DataSendService } from 'app/core/services/data-send.service'; /** * Repository service for users @@ -27,16 +28,19 @@ import { ConfigService } from 'app/core/services/config.service'; export class AgendaRepositoryService extends BaseRepository { /** * Contructor for agenda repository. + * * @param DS The DataStore * @param httpService OpenSlides own HttpService * @param mapperService OpenSlides mapping service for collection strings * @param config Read config variables + * @param dataSend send models to the server */ public constructor( protected DS: DataStoreService, private httpService: HttpService, mapperService: CollectionStringModelMapperService, - private config: ConfigService + private config: ConfigService, + private dataSend: DataSendService ) { super(DS, mapperService, Item); } @@ -44,6 +48,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Returns the corresponding content object to a given {@link Item} as an {@link AgendaBaseModel} * Used dynamically because of heavy race conditions + * * @param agendaItem the target agenda Item * @returns the content object of the given item. Might be null if it was not found. */ @@ -68,6 +73,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Generate viewSpeaker objects from a given agenda Item + * * @param item agenda Item holding speakers * @returns the list of view speakers corresponding to the given item */ @@ -88,8 +94,8 @@ export class AgendaRepositoryService extends BaseRepository { /** * Add a new speaker to an agenda item. * Sends the users ID to the server - * * Might need another repo + * * @param id {@link User} id of the new speaker * @param agenda the target agenda item */ @@ -100,6 +106,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Sets the given speaker ID to Speak + * * @param id the speakers id * @param agenda the target agenda item */ @@ -110,6 +117,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Stops the current speaker + * * @param agenda the target agenda item */ public async stopSpeaker(agenda: Item): Promise { @@ -119,6 +127,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Marks the current speaker + * * @param id {@link User} id of the new speaker * @param mark determine if the user was marked or not * @param agenda the target agenda item @@ -130,6 +139,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Deletes the given speaker for the agenda + * * @param id the speakers id * @param agenda the target agenda item */ @@ -140,6 +150,7 @@ export class AgendaRepositoryService extends BaseRepository { /** * Posts an (manually) sorted speaker list to the server + * * @param ids array of speaker id numbers * @param Item the target agenda item */ @@ -149,34 +160,48 @@ export class AgendaRepositoryService extends BaseRepository { } /** - * @ignore + * Updates an agenda item * - * TODO: used over not-yet-existing detail view + * @param update contains the update data + * @param viewItem the item to update */ - public async update(item: Partial, viewUser: ViewItem): Promise { - return null; + public async update(update: Partial, viewItem: ViewItem): Promise { + const updateItem = viewItem.item; + updateItem.patchValues(update); + return await this.dataSend.partialUpdateModel(updateItem); + } + + /** + * Trigger the automatic numbering sequence on the server + */ + public async autoNumbering(): Promise { + await this.httpService.post('/rest/agenda/item/numbering/'); } /** * @ignore * - * TODO: used over not-yet-existing detail view + * TODO: Usually, agenda items are deleted with their corresponding content object + * However, deleting an agenda item might be interpretet with "removing an item + * from the agenda" permanently. Usually, items might juse be hidden but not + * deleted (right now) */ - public async delete(item: ViewItem): Promise { - return null; + public delete(item: ViewItem): Promise { + throw new Error("Method not implemented."); } /** * @ignore * - * TODO: used over not-yet-existing detail view + * Agenda items are created implicitly and do not have on create functions */ public async create(item: Item): Promise { - return null; + throw new Error("Method not implemented."); } /** * Creates the viewItem out of a given item + * * @param item the item that should be converted to view item * @returns a new view item */ diff --git a/client/src/app/site/agenda/services/topic-repository.service.ts b/client/src/app/site/agenda/services/topic-repository.service.ts index f86869871..29630e257 100644 --- a/client/src/app/site/agenda/services/topic-repository.service.ts +++ b/client/src/app/site/agenda/services/topic-repository.service.ts @@ -34,6 +34,7 @@ export class TopicRepositoryService extends BaseRepository { /** * Creates a new viewModel out of the given model + * * @param topic The topic that shall be converted into a view topic * @returns a new view topic */ @@ -56,6 +57,7 @@ export class TopicRepositoryService extends BaseRepository { /** * Save a new topic + * * @param topicData Partial topic data to be created * @returns an Identifiable (usually id) as promise */ @@ -81,6 +83,7 @@ export class TopicRepositoryService extends BaseRepository { /** * Delete a topic + * * @param viewTopic the topic that should be removed */ public async delete(viewTopic: ViewTopic): Promise { diff --git a/client/src/app/site/base/list-view-base.ts b/client/src/app/site/base/list-view-base.ts index f9ead09a8..17cfcfa8f 100644 --- a/client/src/app/site/base/list-view-base.ts +++ b/client/src/app/site/base/list-view-base.ts @@ -7,7 +7,7 @@ import { BaseViewComponent } from './base-view'; export abstract class ListViewBaseComponent extends BaseViewComponent { /** - * The data source for a table. Requires to be initialised with a BaseViewModel + * The data source for a table. Requires to be initialized with a BaseViewModel */ public dataSource: MatTableDataSource; @@ -17,12 +17,12 @@ export abstract class ListViewBaseComponent extends Bas protected canMultiSelect = false; /** - * Current state of the multiSelect mode. TODO Could be merged with edit mode? + * Current state of the multi select mode. TODO Could be merged with edit mode? */ - private _multiSelectModus = false; + private _multiSelectMode = false; /** - * An array of currently selected items, upon which multiselect actions can be performed + * An array of currently selected items, upon which multi select actions can be performed * see {@link selectItem}. */ public selectedRows: V[]; @@ -75,7 +75,7 @@ export abstract class ListViewBaseComponent extends Bas */ public selectItem(row: V, event: MouseEvent): void { event.stopPropagation(); - if (!this._multiSelectModus) { + if (!this._multiSelectMode) { this.singleSelectAction(row); } else { const idx = this.selectedRows.indexOf(row); @@ -99,10 +99,10 @@ export abstract class ListViewBaseComponent extends Bas */ public toggleMultiSelect(): void { if (!this.canMultiSelect || this.isMultiSelect) { - this._multiSelectModus = false; + this._multiSelectMode = false; this.clearSelection(); } else { - this._multiSelectModus = true; + this._multiSelectMode = true; } } @@ -121,7 +121,7 @@ export abstract class ListViewBaseComponent extends Bas * Returns the current state of the multiSelect modus */ public get isMultiSelect(): boolean { - return this._multiSelectModus; + return this._multiSelectMode; } /** @@ -129,7 +129,7 @@ export abstract class ListViewBaseComponent extends Bas * @param item The row's entry */ public isSelected(item: V): boolean { - if (!this._multiSelectModus) { + if (!this._multiSelectMode) { return false; } return this.selectedRows.indexOf(item) >= 0; diff --git a/client/src/app/site/core/services/duration.service.spec.ts b/client/src/app/site/core/services/duration.service.spec.ts new file mode 100644 index 000000000..cc3818a50 --- /dev/null +++ b/client/src/app/site/core/services/duration.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed } from '@angular/core/testing'; + +import { DurationService } from './duration.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('DurationService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [E2EImportsModule] + })); + + it('should be created', () => { + const service: DurationService = TestBed.get(DurationService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/core/services/duration.service.ts b/client/src/app/site/core/services/duration.service.ts new file mode 100644 index 000000000..b62f8eeaa --- /dev/null +++ b/client/src/app/site/core/services/duration.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; + +/** + * Helper service to convert numbers to time representation + * and vice versa + * + * @example + * ```ts + * // will result in 70 + * const a = this.durationService.stringToDuration('01:10h'); + * + * // will also result in 70 + * const b = this.durationService.stringToDuration('01:10'); + * + * // will result in 0 + * const c = this.durationService.stringToDuration('01:10b'); + * ``` + * + * @example + * ```ts + * // will result in 01:10 h + * const a = this.durationService.durationToString(70); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class DurationService { + /** + * Constructor + */ + public constructor() {} + + /** + * Transform a duration string to duration in minutes. + * + * @param durationText the text to be transformed into a duration + * @returns time in minutes or 0 if values are below 0 or no parsable numbers + */ + public stringToDuration(durationText: string): number { + const splitDuration = durationText.replace('h', '').split(':'); + let time: number; + if (splitDuration.length > 1 && !isNaN(+splitDuration[0]) && !isNaN(+splitDuration[1])) { + time = +splitDuration[0] * 60 + +splitDuration[1]; + } else if (splitDuration.length === 1 && !isNaN(+splitDuration[0])) { + time = +splitDuration[0]; + } + + if (!time || time < 0) { + time = 0; + } + + return time; + } + + /** + * Converts a duration number (given in minutes) + * To a string in HH:MM format + * + * @param duration value in minutes + * @returns a more human readable time representation + */ + public durationToString(duration: number): string { + const hours = Math.floor(duration / 60); + const minutes = `0${Math.floor(duration - hours * 60)}`.slice(-2); + if (!isNaN(+hours) && !isNaN(+minutes)) { + return `${hours}:${minutes} h`; + } else { + return ''; + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index e6b17a0e8..d0fd094fd 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -309,7 +309,6 @@
{ const newMotionValues = { ...this.contentForm.value }; + + if (!newMotionValues.agenda_parent_id) { + delete newMotionValues.agenda_parent_id; + } + const motion = this.prepareMotionForSave(newMotionValues, CreateMotion); try { diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.html b/client/src/app/site/users/components/user-detail/user-detail.component.html index 91c24a8b5..9d5f3ced2 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.html +++ b/client/src/app/site/users/components/user-detail/user-detail.component.html @@ -1,5 +1,5 @@