Rework choice service and dialog

- cleans up the choice service and the choice dialogs to make them
simpler and more usable.
- adjusts search-value-selector to also accept lists
- the search value selector allows for better filtering of models in the choice dialog
- fixes an issue where deleting all tags required a selection
This commit is contained in:
Sean Engelhardt 2019-10-23 10:41:58 +02:00
parent 62e5774c8d
commit 62eba77925
7 changed files with 149 additions and 193 deletions

View File

@ -1,12 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; import { Observable } from 'rxjs';
import {
ChoiceAnswer, import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
ChoiceDialogComponent, import { Displayable } from 'app/site/base/displayable';
ChoiceDialogOptions import { ChoiceAnswer, ChoiceDialogComponent } from '../../shared/components/choice-dialog/choice-dialog.component';
} from '../../shared/components/choice-dialog/choice-dialog.component';
/** /**
* A service for prompting the user to select a choice. * A service for prompting the user to select a choice.
@ -37,13 +36,13 @@ export class ChoiceService {
*/ */
public async open( public async open(
title: string, title: string,
choices: ChoiceDialogOptions, choices?: Observable<Displayable[]> | Displayable[],
multiSelect: boolean = false, multiSelect: boolean = false,
actions?: string[], actions?: string[],
clearChoice?: string clearChoice?: string
): Promise<ChoiceAnswer> { ): Promise<ChoiceAnswer> {
const dialogRef = this.dialog.open(ChoiceDialogComponent, { const dialogRef = this.dialog.open(ChoiceDialogComponent, {
...mediumDialogSettings, ...infoDialogSettings,
data: { data: {
title: title, title: title,
choices: choices, choices: choices,

View File

@ -1,56 +1,52 @@
<!-- Title --> <!-- Title -->
<h2 mat-dialog-title>{{ data.title | translate }}</h2> <h2 mat-dialog-title>{{ data.title | translate }}</h2>
<!-- Content --> <form [formGroup]="selectForm">
<mat-dialog-content> <!-- Content -->
<mat-radio-group <div mat-dialog-content *ngIf="data.choices">
#radio <os-search-value-selector
name="choice" ngDefaultControl
*ngIf="!data.multiSelect && data.choices" [multiple]="data.multiSelect"
class="choice-radio-group" [formControl]="selectForm.get('select')"
[(ngModel)]="selectedChoice" [inputListValues]="data.choices"
>
<mat-radio-button class="choice-button" *ngFor="let choice of data.choices" [value]="choice.id">
{{ getChoiceTitle(choice) | translate }}
</mat-radio-button>
<mat-divider *ngIf="data.clearChoice"></mat-divider>
<mat-radio-button *ngIf="data.clearChoice" [value]="null">
{{ data.clearChoice | translate }}
</mat-radio-button>
</mat-radio-group>
<mat-list *ngIf="data.multiSelect && data.choices">
<mat-list-item *ngFor="let choice of data.choices">
<mat-checkbox [checked]="isChosen(choice)" (change)="toggleChoice(choice)">
{{ getChoiceTitle(choice) | translate }}
</mat-checkbox>
</mat-list-item>
</mat-list>
</mat-dialog-content>
<!-- Actions -->
<mat-dialog-actions>
<div *ngIf="data.actionButtons">
<button
*ngFor="let button of data.actionButtons"
mat-button
(click)="closeDialog(true, button)"
[disabled]="isSelectionEmpty"
> >
<span>{{ button | translate }}</span> </os-search-value-selector>
</div>
<!-- Actions -->
<div mat-dialog-actions>
<!-- Custom Actions -->
<div *ngIf="data.actionButtons">
<button
*ngFor="let button of data.actionButtons"
mat-button
(click)="closeDialog(true, button)"
[disabled]="!hasSelection"
>
<span>{{ button | translate }}</span>
</button>
</div>
<!-- Normal okay button -->
<div *ngIf="!data.actionButtons">
<button
*ngIf="!data.multiSelect || data.choices"
mat-button
(click)="closeDialog(true)"
[disabled]="!hasSelection"
>
<span>OK</span>
</button>
</div>
<!-- Clear choice button -->
<button mat-button *ngIf="data.clearChoice" (click)="closeDialog(true, data.clearChoice)">
<span>{{ data.clearChoice }}</span>
</button>
<!-- Cancel -->
<button mat-button (click)="closeDialog(false)">
<span translate>Cancel</span>
</button> </button>
</div> </div>
<div *ngIf="!data.actionButtons"> </form>
<button
*ngIf="!data.multiSelect || data.choices.length"
mat-button
(click)="closeDialog(true)"
[disabled]="isSelectionEmpty"
>
<span>OK</span>
</button>
</div>
<button mat-button (click)="closeDialog(false)"><span translate>Cancel</span></button>
</mat-dialog-actions>

View File

@ -1,17 +1,3 @@
.mat-dialog-content { .mat-form-field {
display: block; width: 100%;
}
.mat-radio-group {
display: inline-flex;
flex-direction: column;
.mat-radio-button {
margin: 5px;
}
}
.mat-divider {
margin-top: 10px;
margin-bottom: 10px;
} }

View File

@ -1,20 +1,11 @@
import { Component, Inject, ViewEncapsulation } from '@angular/core'; import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Observable } from 'rxjs';
import { Displayable } from 'app/site/base/displayable'; import { Displayable } from 'app/site/base/displayable';
/**
* An option needs to be identifiable and should have a strnig to display. Either uses Displayble or
* a label property.
*/
type ChoiceDialogOption = (Identifiable & Displayable) | (Identifiable & { label: string });
/**
* All choices in the array should have the same type.
*/
export type ChoiceDialogOptions = (Identifiable & Displayable)[] | (Identifiable & { label: string })[];
/** /**
* All data needed for this dialog * All data needed for this dialog
*/ */
@ -24,16 +15,16 @@ interface ChoiceDialogData {
*/ */
title: string; title: string;
/**
* The choices to display
*/
choices: ChoiceDialogOptions;
/** /**
* Select if this should be a multiselect choice * Select if this should be a multiselect choice
*/ */
multiSelect: boolean; multiSelect: boolean;
/**
* The choices to display
*/
choices?: Observable<Displayable[]> | Displayable[];
/** /**
* Additional action buttons which will add their value to the * Additional action buttons which will add their value to the
* {@link closeDialog} feedback if chosen * {@link closeDialog} feedback if chosen
@ -72,18 +63,24 @@ export class ChoiceDialogComponent {
public selectedChoice: number; public selectedChoice: number;
/** /**
* Checks if there is nothing selected * Form to hold the selection
*
* @returns true if there is no selection chosen (and the dialog should not
* be closed 'successfully')
*/ */
public get isSelectionEmpty(): boolean { public selectForm: FormGroup;
if (this.data.multiSelect) {
return this.selectedMultiChoices.length === 0; /**
} else if (!this.data.choices) { * Checks if there is something selected
return false; *
* @returns true if there is a selection chosen
*/
public get hasSelection(): boolean {
if (this.data.choices) {
if (this.selectForm.get('select').value) {
return !!this.selectForm.get('select').value || !!this.selectForm.get('select').value.length;
} else {
return false;
}
} else { } else {
return this.selectedChoice === undefined; return true;
} }
} }
@ -94,21 +91,12 @@ export class ChoiceDialogComponent {
public constructor( public constructor(
public dialogRef: MatDialogRef<ChoiceDialogComponent>, public dialogRef: MatDialogRef<ChoiceDialogComponent>,
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: ChoiceDialogData @Inject(MAT_DIALOG_DATA) public data: ChoiceDialogData
) {} ) {
this.selectForm = this.formBuilder.group({
/** select: []
* Get the title from a choice. Maybe saved in a label property or using getTitle(). });
*
* @param choice The choice
* @return the title
*/
public getChoiceTitle(choice: ChoiceDialogOption): string {
if ('label' in choice) {
return choice.label;
} else {
return choice.getTitle();
}
} }
/** /**
@ -121,31 +109,10 @@ export class ChoiceDialogComponent {
if (ok) { if (ok) {
this.dialogRef.close({ this.dialogRef.close({
action: action ? action : null, action: action ? action : null,
items: this.data.multiSelect ? this.selectedMultiChoices : this.selectedChoice items: this.selectForm.get('select').value
}); });
} else { } else {
this.dialogRef.close(); this.dialogRef.close();
} }
} }
/**
* For multiSelect: Determines whether a choice has been activated
* @param choice
*/
public isChosen(choice: Identifiable): boolean {
return this.selectedMultiChoices.indexOf(choice.id) >= 0;
}
/**
* For multiSelect: Activates/deactivates a multi-Choice option
* @param choice
*/
public toggleChoice(choice: Identifiable): void {
const idx = this.selectedMultiChoices.indexOf(choice.id);
if (idx < 0) {
this.selectedMultiChoices.push(choice.id);
} else {
this.selectedMultiChoices.splice(idx, 1);
}
}
} }

View File

@ -79,23 +79,27 @@ export class SearchValueSelectorComponent implements OnDestroy {
* changes its values. * changes its values.
*/ */
@Input() @Input()
public set inputListValues(value: Observable<Selectable[]>) { public set inputListValues(value: Selectable[] | Observable<Selectable[]>) {
if (!value) { if (!value) {
return; return;
} }
// unsubscribe to old subscription.
if (this._inputListSubscription) { if (Array.isArray(value)) {
this._inputListSubscription.unsubscribe(); this.selectableItems = value;
} } else {
// this.inputSubject = value; // unsubscribe to old subscription.
this._inputListSubscription = value.pipe(auditTime(10)).subscribe(items => { if (this._inputListSubscription) {
this.selectableItems = items; this._inputListSubscription.unsubscribe();
if (this.formControl) {
!!items && items.length > 0
? this.formControl.enable({ emitEvent: false })
: this.formControl.disable({ emitEvent: false });
} }
}); this._inputListSubscription = value.pipe(auditTime(10)).subscribe(items => {
this.selectableItems = items;
if (this.formControl) {
!!items && items.length > 0
? this.formControl.enable({ emitEvent: false })
: this.formControl.disable({ emitEvent: false });
}
});
}
} }
/** /**

View File

@ -214,25 +214,23 @@ export class CategoryMotionsSortComponent extends BaseViewComponent implements O
public async moveToPosition(): Promise<void> { public async moveToPosition(): Promise<void> {
if (this.sortSelector.multiSelectedIndex.length) { if (this.sortSelector.multiSelectedIndex.length) {
} const content = this.translate.instant('Move selected items ...');
const content = this.translate.instant('Move selected items ...'); const choices = this.sortSelector.sortedItems.filter(
const choices = this.sortSelector.sortedItems f => !this.sortSelector.multiSelectedIndex.includes(f.id)
.map((item, index) => {
return { id: index, label: item.getTitle() };
})
.filter(f => !this.sortSelector.multiSelectedIndex.includes(f.id));
const actions = [this.translate.instant('Insert before'), this.translate.instant('Insert behind')];
const selectedChoice = await this.choiceService.open(content, choices, false, actions);
if (selectedChoice) {
const newIndex = selectedChoice.items as number;
this.sortSelector.drop(
{
currentIndex: newIndex,
previousIndex: null
},
selectedChoice.action === actions[1] // true if 'insert behind'
); );
const actions = [this.translate.instant('Insert before'), this.translate.instant('Insert behind')];
const selectedChoice = await this.choiceService.open(content, choices, false, actions);
if (selectedChoice) {
const newIndex = selectedChoice.items as number;
this.sortSelector.drop(
{
currentIndex: newIndex,
previousIndex: null
},
selectedChoice.action === actions[1] // true if 'insert behind'
);
}
} }
} }
} }

View File

@ -15,8 +15,6 @@ import { OverlayService } from 'app/core/ui-services/overlay.service';
import { PersonalNoteService } from 'app/core/ui-services/personal-note.service'; import { PersonalNoteService } from 'app/core/ui-services/personal-note.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { TreeService } from 'app/core/ui-services/tree.service'; import { TreeService } from 'app/core/ui-services/tree.service';
import { ChoiceDialogOptions } from 'app/shared/components/choice-dialog/choice-dialog.component';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/site/base/displayable'; import { Displayable } from 'app/site/base/displayable';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';
@ -93,7 +91,7 @@ export class MotionMultiselectService {
*/ */
public async moveToItem(motions: ViewMotion[]): Promise<void> { public async moveToItem(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will move all selected motions as childs to:'); const title = this.translate.instant('This will move all selected motions as childs to:');
const choices: (Displayable & Identifiable)[] = this.agendaRepo.getViewModelList(); const choices = this.agendaRepo.getViewModelListObservable();
const selectedChoice = await this.choiceService.open(title, choices); const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) { if (selectedChoice) {
const requestData = { const requestData = {
@ -112,10 +110,7 @@ export class MotionMultiselectService {
public async setStateOfMultiple(motions: ViewMotion[]): Promise<void> { public async setStateOfMultiple(motions: ViewMotion[]): Promise<void> {
if (motions.every(motion => motion.workflow_id === motions[0].workflow_id)) { if (motions.every(motion => motion.workflow_id === motions[0].workflow_id)) {
const title = this.translate.instant('This will set the following state for all selected motions:'); const title = this.translate.instant('This will set the following state for all selected motions:');
const choices = this.workflowRepo.getWorkflowStatesForMotions(motions).map(workflowState => ({ const choices = this.workflowRepo.getWorkflowStatesForMotions(motions);
id: workflowState.id,
label: workflowState.name
}));
const selectedChoice = await this.choiceService.open(title, choices); const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) { if (selectedChoice) {
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner); const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
@ -137,12 +132,16 @@ export class MotionMultiselectService {
const title = this.translate.instant( const title = this.translate.instant(
'This will set the following recommendation for all selected motions:' 'This will set the following recommendation for all selected motions:'
); );
const choices = this.workflowRepo
// hacks custom Displayables from recommendations
// TODO: Recommendations should be an own class
const choices: Displayable[] = this.workflowRepo
.getWorkflowStatesForMotions(motions) .getWorkflowStatesForMotions(motions)
.filter(workflowState => !!workflowState.recommendation_label) .filter(workflowState => !!workflowState.recommendation_label)
.map(workflowState => ({ .map(workflowState => ({
id: workflowState.id, id: workflowState.id,
label: workflowState.recommendation_label getTitle: () => workflowState.recommendation_label,
getListTitle: () => workflowState.recommendation_label
})); }));
const clearChoice = this.translate.instant('Delete recommendation'); const clearChoice = this.translate.instant('Delete recommendation');
const selectedChoice = await this.choiceService.open(title, choices, false, null, clearChoice); const selectedChoice = await this.choiceService.open(title, choices, false, null, clearChoice);
@ -174,7 +173,7 @@ export class MotionMultiselectService {
const clearChoice = this.translate.instant('No category'); const clearChoice = this.translate.instant('No category');
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(
title, title,
this.categoryRepo.getViewModelList(), this.categoryRepo.getViewModelListObservable(),
false, false,
null, null,
clearChoice clearChoice
@ -196,7 +195,12 @@ export class MotionMultiselectService {
'This will add or remove the following submitters for all selected motions:' 'This will add or remove the following submitters for all selected motions:'
); );
const choices = [this.translate.instant('Add'), this.translate.instant('Remove')]; const choices = [this.translate.instant('Add'), this.translate.instant('Remove')];
const selectedChoice = await this.choiceService.open(title, this.userRepo.getViewModelList(), true, choices); const selectedChoice = await this.choiceService.open(
title,
this.userRepo.getViewModelListObservable(),
true,
choices
);
if (selectedChoice) { if (selectedChoice) {
let requestData = null; let requestData = null;
if (selectedChoice.action === choices[0]) { if (selectedChoice.action === choices[0]) {
@ -232,12 +236,14 @@ export class MotionMultiselectService {
*/ */
public async changeTags(motions: ViewMotion[]): Promise<void> { public async changeTags(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will add or remove the following tags for all selected motions:'); const title = this.translate.instant('This will add or remove the following tags for all selected motions:');
const choices = [ const choices = [this.translate.instant('Add'), this.translate.instant('Remove')];
this.translate.instant('Add'), const selectedChoice = await this.choiceService.open(
this.translate.instant('Remove'), title,
this.tagRepo.getViewModelListObservable(),
true,
choices,
this.translate.instant('Clear tags') this.translate.instant('Clear tags')
]; );
const selectedChoice = await this.choiceService.open(title, this.tagRepo.getViewModelList(), true, choices);
if (selectedChoice) { if (selectedChoice) {
let requestData = null; let requestData = null;
if (selectedChoice.action === choices[0]) { if (selectedChoice.action === choices[0]) {
@ -258,7 +264,7 @@ export class MotionMultiselectService {
tags: tagIds tags: tagIds
}; };
}); });
} else if (selectedChoice.action === choices[2]) { } else {
requestData = motions.map(motion => { requestData = motions.map(motion => {
return { return {
id: motion.id, id: motion.id,
@ -283,7 +289,7 @@ export class MotionMultiselectService {
const clearChoice = this.translate.instant('Clear motion block'); const clearChoice = this.translate.instant('Clear motion block');
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(
title, title,
this.motionBlockRepo.getViewModelList(), this.motionBlockRepo.getViewModelListObservable(),
false, false,
null, null,
clearChoice clearChoice
@ -347,16 +353,16 @@ export class MotionMultiselectService {
*/ */
public async bulkSetFavorite(motions: ViewMotion[]): Promise<void> { public async bulkSetFavorite(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will set the favorite status for all selected motions:'); const title = this.translate.instant('This will set the favorite status for all selected motions:');
const choices: ChoiceDialogOptions = [ const options = [this.translate.instant('Set as favorite'), this.translate.instant('Set as not favorite')];
{ id: 1, label: this.translate.instant('Set as favorite') }, const selectedChoice = await this.choiceService.open(title, null, false, options);
{ id: 2, label: this.translate.instant('Set as not favorite') }
];
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice && motions.length) { if (selectedChoice && motions.length) {
/**
* `bulkSetStar` does imply that "true" sets favorites while "false" unsets favorites
*/
const setOrUnset = selectedChoice.action === options[0];
const message = this.translate.instant(`I have ${motions.length} favorite motions. Please wait ...`); const message = this.translate.instant(`I have ${motions.length} favorite motions. Please wait ...`);
const star = (selectedChoice.items as number) === choices[0].id;
this.overlayService.showSpinner(message, true); this.overlayService.showSpinner(message, true);
await this.personalNoteService.bulkSetStar(motions, star); await this.personalNoteService.bulkSetStar(motions, setOrUnset);
} }
} }
} }