diff --git a/frontend/cypress/e2e/objective.cy.ts b/frontend/cypress/e2e/objective.cy.ts index 83eb98496e..afca9c4c76 100644 --- a/frontend/cypress/e2e/objective.cy.ts +++ b/frontend/cypress/e2e/objective.cy.ts @@ -129,6 +129,10 @@ describe('OKR Objective e2e tests', () => { .contains('Objective wiedereröffnen') .click(); + cy.contains('Objective wiedereröffnen'); + cy.contains('Soll dieses Objective wiedereröffnet werden?'); + cy.getByTestId('confirm-yes').click(); + cy.getByTestId('objective') .filter(':contains("This objective will be reopened after")') .last() @@ -154,7 +158,7 @@ describe('OKR Objective e2e tests', () => { .tabForward(); cy.contains('Objective als Draft speichern'); cy.contains('Soll dieses Objective als Draft gespeichert werden?'); - cy.focused().click().wait(500); + cy.getByTestId('confirm-yes').click(); cy.getByTestId('objective') .filter(':contains("This objective will be returned to draft state")') diff --git a/frontend/cypress/e2e/tab.cy.ts b/frontend/cypress/e2e/tab.cy.ts index 7909c48142..343f1b979d 100644 --- a/frontend/cypress/e2e/tab.cy.ts +++ b/frontend/cypress/e2e/tab.cy.ts @@ -72,7 +72,7 @@ describe('Tab workflow tests', () => { } function openKeyresultDetail() { - cy.get('.objective').first().focus(); + cy.get("[src='assets/icons/ongoing-icon.svg']").parentsUntil('#objective-column').last().focus(); cy.tabForwardUntil('[data-testId="key-result"]'); cy.focused().contains('Fail'); cy.focused().contains('Commit'); @@ -212,13 +212,17 @@ describe('Tab workflow tests', () => { editInputFields('Edited by Cypress too'); cy.tabForward(); cy.tabForward(); + cy.focused().contains('Speichern'); cy.realPress('Enter'); - cy.contains('Edited by Cypress'); + cy.focused().invoke('attr', 'data-testid').should('contain', 'three-dot-menu'); + cy.focused().parentsUntil('#objective-column').last().contains('Edited by Cypress'); }); it('Duplicate objective with tab', () => { openThreeDotMenu(); cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); cy.focused().contains('Objective duplizieren'); cy.realPress('Enter'); cy.wait(500); @@ -242,7 +246,6 @@ describe('Tab workflow tests', () => { it('Complete objective dialog with tab', () => { openThreeDotMenu(); cy.realPress('ArrowDown'); - cy.realPress('ArrowDown'); cy.focused().contains('Objective abschliessen'); cy.realPress('Enter'); cy.wait(500); diff --git a/frontend/src/app/components/objective/ObjectiveMenuActions.ts b/frontend/src/app/components/objective/ObjectiveMenuActions.ts new file mode 100644 index 0000000000..a33b090e23 --- /dev/null +++ b/frontend/src/app/components/objective/ObjectiveMenuActions.ts @@ -0,0 +1,77 @@ +import { DialogService } from '../../services/dialog.service'; +import { Objective } from '../../shared/types/model/Objective'; +import { RefreshDataService } from '../../services/refresh-data.service'; +import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; +import { ObjectiveFormComponent } from '../../shared/dialog/objective-dialog/objective-form.component'; +import { CompleteDialogComponent } from '../../shared/dialog/complete-dialog/complete-dialog.component'; +import { + ObjectiveMenuAction, + ObjectiveMenuAfterAction, + ObjectiveMenuEntry, +} from '../../services/objective-menu-actions.service'; +import { ObjectiveMenuAfterActions } from './ObjectiveMenuAfterActions'; + +export class ObjectiveMenuActions { + constructor( + private readonly dialogService: DialogService, + private readonly refreshDataService: RefreshDataService, + private readonly afterActions: ObjectiveMenuAfterActions, + ) {} + + releaseFromQuarterAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.RELEASE'); + const afterAction: ObjectiveMenuAfterAction = (objective, dialogResult) => + this.afterActions.releaseFromQuarter(objective); + return { displayName: 'Objective veröffentlichen', action: action, afterAction: afterAction }; + } + + releaseFromBacklogAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id }, action: 'releaseBacklog' } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => this.refreshDataService.markDataRefresh(); + return { displayName: 'Objective veröffentlichen', action: action, afterAction }; + } + + editObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id } } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => { + this.refreshDataService.markDataRefresh(); + }; + return { displayName: 'Objective bearbeiten', action: action, afterAction: afterAction }; + } + + duplicateObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id }, action: 'duplicate' } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => this.refreshDataService.markDataRefresh(); + return { displayName: 'Objective duplizieren', action: action, afterAction: afterAction }; + } + + completeObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { + data: { objectiveTitle: objective.title }, + }; + const action: ObjectiveMenuAction = () => this.dialogService.open(CompleteDialogComponent, config); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.completeObjective(obj, result); + + return { displayName: 'Objective abschliessen', action: action, afterAction: afterAction }; + } + + objectiveBackToDraft(): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.TO_DRAFT'); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.objectiveBackToDraft(obj); + + return { displayName: 'Objective als Draft speichern', action: action, afterAction: afterAction }; + } + + objectiveReopen(): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.REOPEN'); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.objectiveReopen(obj); + + return { displayName: 'Objective wiedereröffnen', action: action, afterAction: afterAction }; + } +} diff --git a/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts b/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts new file mode 100644 index 0000000000..5691305fa9 --- /dev/null +++ b/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts @@ -0,0 +1,50 @@ +import { Objective } from '../../shared/types/model/Objective'; +import { State } from '../../shared/types/enums/State'; +import { Completed } from '../../shared/types/model/Completed'; +import { ObjectiveService } from '../../services/objective.service'; +import { RefreshDataService } from '../../services/refresh-data.service'; + +export class ObjectiveMenuAfterActions { + constructor( + private readonly objectiveService: ObjectiveService, + private readonly refreshDataService: RefreshDataService, + ) {} + + completeObjective(objective: Objective, result: { endState: string; comment: string | null; objective: any }) { + objective.state = result.endState as State; + const completed: Completed = { + id: null, + version: objective.version, + objective: objective, + comment: result.comment, + }; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.objectiveService.createCompleted(completed).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + }); + } + + releaseFromQuarter(objective: Objective) { + objective.state = 'ONGOING' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + } + + objectiveBackToDraft(objective: Objective) { + objective.state = 'DRAFT' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + } + + objectiveReopen(objective: Objective) { + objective.state = 'ONGOING' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.objectiveService.deleteCompleted(objective.id).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + }); + } +} diff --git a/frontend/src/app/components/objective/objective.component.html b/frontend/src/app/components/objective/objective.component.html index 48fd01a194..71cd720191 100644 --- a/frontend/src/app/components/objective/objective.component.html +++ b/frontend/src/app/components/objective/objective.component.html @@ -1,81 +1,82 @@ -
-
-
-
- +
+
+
+
+ The objectives state +

{{ objective.title }}

+
+
- -
-
- -
+
+ +
-
- -
+
+ +
+
- - - - + + + diff --git a/frontend/src/app/components/objective/objective.component.spec.ts b/frontend/src/app/components/objective/objective.component.spec.ts index c429f95f77..79385ab23c 100644 --- a/frontend/src/app/components/objective/objective.component.spec.ts +++ b/frontend/src/app/components/objective/objective.component.spec.ts @@ -7,23 +7,19 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { State } from '../../shared/types/enums/State'; -import { RouterTestingModule } from '@angular/router/testing'; import { OverviewService } from '../../services/overview.service'; -import { objective, objectiveMin } from '../../shared/testData'; +import { objectiveMin } from '../../shared/testData'; import { MatMenuHarness } from '@angular/material/menu/testing'; import { KeyresultComponent } from '../keyresult/keyresult.component'; import { MatDialogModule } from '@angular/material/dialog'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ScoringComponent } from '../../shared/custom/scoring/scoring.component'; import { ConfidenceComponent } from '../confidence/confidence.component'; import { ReactiveFormsModule } from '@angular/forms'; +// @ts-ignore import * as de from '../../../assets/i18n/de.json'; import { TranslateTestingModule } from 'ngx-translate-testing'; -import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; -import { MenuEntry } from '../../shared/types/menu-entry'; -import { of } from 'rxjs'; import { ObjectiveService } from '../../services/objective.service'; const overviewServiceMock = { @@ -31,12 +27,9 @@ const overviewServiceMock = { }; const objectiveServiceMock = { - getFullObjective(objectiveMin: ObjectiveMin) { - let ongoingObjective = objective; - ongoingObjective.state = State.ONGOING; - return of(ongoingObjective); - }, + getFullObjective: jest.fn(), }; + describe('ObjectiveColumnComponent', () => { let component: ObjectiveComponent; let fixture: ComponentFixture; @@ -50,9 +43,7 @@ describe('ObjectiveColumnComponent', () => { MatMenuModule, MatCardModule, NoopAnimationsModule, - RouterTestingModule, MatDialogModule, - HttpClientTestingModule, MatIconModule, MatTooltipModule, ReactiveFormsModule, @@ -114,46 +105,4 @@ describe('ObjectiveColumnComponent', () => { const button = fixture.debugElement.query(By.css('[data-testId="add-keyResult"]')); expect(button).toBeFalsy(); }); - - it('Correct method should be called when back to draft is clicked', () => { - jest.spyOn(component, 'objectiveBackToDraft'); - component.objective$.next(objectiveMin); - fixture.detectChanges(); - const menuEntry: MenuEntry = - component.getOngoingMenuActions()[ - component - .getOngoingMenuActions() - .map((menuAction) => menuAction.action) - .indexOf('todraft') - ]; - component.handleDialogResult(menuEntry, { endState: '', comment: null, objective: objective }); - fixture.detectChanges(); - expect(component.objectiveBackToDraft).toHaveBeenCalled(); - }); - - test('Should set isBacklogQuarter right', async () => { - expect(component.isBacklogQuarter).toBeFalsy(); - - objectiveMin.quarter.label = 'Backlog'; - - component.objective = objectiveMin; - fixture.detectChanges(); - component.ngOnInit(); - - expect(component.isBacklogQuarter).toBeTruthy(); - }); - - test('Should return correct menu entries when backlog', async () => { - objectiveMin.quarter.label = 'Backlog'; - component.objective = objectiveMin; - fixture.detectChanges(); - component.ngOnInit(); - - let menuActions = component.getDraftMenuActions(); - - expect(menuActions.length).toEqual(3); - expect(menuActions[0].displayName).toEqual('Objective bearbeiten'); - expect(menuActions[1].displayName).toEqual('Objective duplizieren'); - expect(menuActions[2].displayName).toEqual('Objective veröffentlichen'); - }); }); diff --git a/frontend/src/app/components/objective/objective.component.ts b/frontend/src/app/components/objective/objective.component.ts index 94ea0f3b01..d67984a57b 100644 --- a/frontend/src/app/components/objective/objective.component.ts +++ b/frontend/src/app/components/objective/objective.component.ts @@ -1,264 +1,86 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { MenuEntry } from '../../shared/types/menu-entry'; +import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; import { Router } from '@angular/router'; -import { ObjectiveFormComponent } from '../../shared/dialog/objective-dialog/objective-form.component'; -import { BehaviorSubject } from 'rxjs'; +import { map, ReplaySubject, take } from 'rxjs'; import { RefreshDataService } from '../../services/refresh-data.service'; -import { State } from '../../shared/types/enums/State'; import { ObjectiveService } from '../../services/objective.service'; -import { ConfirmDialogComponent } from '../../shared/dialog/confirm-dialog/confirm-dialog.component'; -import { CompleteDialogComponent } from '../../shared/dialog/complete-dialog/complete-dialog.component'; -import { Completed } from '../../shared/types/model/Completed'; -import { Objective } from '../../shared/types/model/Objective'; import { trackByFn } from '../../shared/common'; import { KeyresultDialogComponent } from '../keyresult-dialog/keyresult-dialog.component'; import { TranslateService } from '@ngx-translate/core'; -import { GJ_REGEX_PATTERN } from '../../shared/constantLibary'; import { DialogService } from '../../services/dialog.service'; +import { ObjectiveMenuActionsService, ObjectiveMenuEntry } from '../../services/objective-menu-actions.service'; +import { State } from '../../shared/types/enums/State'; +import { MatMenuTrigger } from '@angular/material/menu'; @Component({ selector: 'app-objective-column', templateUrl: './objective.component.html', styleUrls: ['./objective.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ObjectiveComponent implements OnInit { - @Input() - isWritable!: boolean; - - menuEntries: MenuEntry[] = []; - isComplete: boolean = false; - isBacklogQuarter: boolean = false; +export class ObjectiveComponent { + @Input() isWritable!: boolean; + public objective$ = new ReplaySubject(); + menuEntries = this.objective$.pipe(map((objective) => this.objectiveMenuActionsService.getMenu(objective))); protected readonly trackByFn = trackByFn; - @ViewChild('menuButton') private menuButton!: ElementRef; + @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger | undefined; constructor( - private dialogService: DialogService, - private router: Router, - private refreshDataService: RefreshDataService, - private objectiveService: ObjectiveService, - private translate: TranslateService, + private readonly dialogService: DialogService, + private readonly router: Router, + private readonly refreshDataService: RefreshDataService, + private readonly objectiveService: ObjectiveService, + private readonly translate: TranslateService, + private readonly objectiveMenuActionsService: ObjectiveMenuActionsService, ) {} - @Input() - set objective(objective: ObjectiveMin) { + @Input() set objective(objective: ObjectiveMin) { this.objective$.next(objective); } - public objective$ = new BehaviorSubject({} as ObjectiveMin); - - ngOnInit() { - if (this.objective$.value.state.includes('successful') || this.objective$.value.state.includes('not-successful')) { - this.isComplete = true; - } - if (!GJ_REGEX_PATTERN.test(this.objective$.value.quarter.label)) { - this.isBacklogQuarter = true; - } + getStateTooltip(stateString: string): string { + const state = this.getStateByValue(stateString); + return this.translate.instant('INFORMATION.OBJECTIVE_STATE_TOOLTIP', { state: state }); } - formatObjectiveState(state: string): string { - const lastIndex = state.lastIndexOf('-'); - if (lastIndex !== -1) { - return state.substring(0, lastIndex).toUpperCase(); - } else { - return state.toUpperCase(); - } - } - - getStateTooltip(): string { - return this.translate.instant('INFORMATION.OBJECTIVE_STATE_TOOLTIP'); - } - - getMenu(): void { - if (this.objective$.value.state.includes('successful') || this.objective$.value.state.includes('not-successful')) { - this.menuEntries = this.getCompletedMenuActions(); - } else { - if (this.objective$.value.state === State.ONGOING) { - this.menuEntries = this.getOngoingMenuActions(); - } else { - this.menuEntries = this.getDraftMenuActions(); - } - } - } - - getOngoingMenuActions() { - return [ - ...this.getDefaultMenuActions(), - ...[ - { - displayName: 'Objective abschliessen', - action: 'complete', - dialog: { dialog: CompleteDialogComponent, data: { objectiveTitle: this.objective$.value.title } }, - }, - { - displayName: 'Objective als Draft speichern', - action: 'todraft', - dialog: { - dialog: ConfirmDialogComponent, - data: { - title: this.translate.instant('CONFIRMATION.TO_DRAFT.TITLE'), - text: this.translate.instant('CONFIRMATION.TO_DRAFT.TEXT'), - }, - }, - }, - ], - ]; - } - - getDraftMenuActions() { - const action = this.isBacklogQuarter ? 'releaseBacklog' : 'release'; - let menuEntries = { - displayName: 'Objective veröffentlichen', - action: action, - dialog: { - dialog: this.isBacklogQuarter ? ObjectiveFormComponent : ConfirmDialogComponent, - data: { - title: this.translate.instant('CONFIRMATION.RELEASE.TITLE'), - text: this.translate.instant('CONFIRMATION.RELEASE.TEXT'), - action: action, - objectiveId: this.isBacklogQuarter ? this.objective$.value.id : undefined, - }, - }, - }; - - return [...this.getDefaultMenuActions(), menuEntries]; - } - - getDefaultMenuActions() { - return [ - { - displayName: 'Objective bearbeiten', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - { - displayName: 'Objective duplizieren', - action: 'duplicate', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - ]; - } - - getCompletedMenuActions() { - return [ - { displayName: 'Objective wiedereröffnen', action: 'reopen' }, - { - displayName: 'Objective duplizieren', - action: 'duplicate', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - ]; - } - - redirect(menuEntry: MenuEntry) { - if (menuEntry.dialog) { - const matDialogRef = this.dialogService.open(menuEntry.dialog.dialog, { - data: { - title: menuEntry.dialog.data.title, - action: menuEntry.action, - text: menuEntry.dialog.data.text, - objective: menuEntry.dialog.data, - objectiveTitle: menuEntry.dialog.data.objectiveTitle, - }, - ...((menuEntry.action == 'release' || menuEntry.action == 'todraft') && { width: 'auto' }), - }); - matDialogRef.afterClosed().subscribe((result) => { - this.menuButton.nativeElement.focus(); - if (result) { - this.handleDialogResult(menuEntry, result); - } - }); - } else { - this.reopenRedirect(menuEntry); - } - } - - handleDialogResult(menuEntry: MenuEntry, result: { endState: string; comment: string | null; objective: any }) { - if (menuEntry.action) { - this.objectiveService.getFullObjective(this.objective$.value.id).subscribe((objective) => { - if (menuEntry.action == 'complete') { - this.completeObjective(objective, result); - } else if (menuEntry.action == 'release') { - this.releaseObjective(objective); - } else if (menuEntry.action == 'duplicate') { - this.refreshDataService.markDataRefresh(); - } else if (menuEntry.action == 'releaseBacklog') { - this.refreshDataService.markDataRefresh(); - } else if (menuEntry.action == 'todraft') { - this.objectiveBackToDraft(objective); - } - }); - } else { - if (result?.objective) { - this.refreshDataService.markDataRefresh(); - } - } - } - - completeObjective(objective: Objective, result: { endState: string; comment: string | null; objective: any }) { - objective.state = result.endState as State; - const completed: Completed = { - id: null, - version: objective.version, - objective: objective, - comment: result.comment, - }; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.objectiveService.createCompleted(completed).subscribe(() => { - this.isComplete = true; - this.refreshDataService.markDataRefresh(); - }); - }); - } - - releaseObjective(objective: Objective) { - objective.state = 'ONGOING' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.refreshDataService.markDataRefresh(); - }); - } - - objectiveBackToDraft(objective: Objective) { - objective.state = 'DRAFT' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.refreshDataService.markDataRefresh(); - }); - } - - reopenRedirect(menuEntry: MenuEntry) { - if (menuEntry.action === 'reopen') { - this.objectiveService.getFullObjective(this.objective$.value.id).subscribe((objective) => { - objective.state = 'ONGOING' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.objectiveService.deleteCompleted(objective.id).subscribe(() => { - this.isComplete = false; - this.refreshDataService.markDataRefresh(); - }); + redirect(menuEntry: ObjectiveMenuEntry, objectiveMin: ObjectiveMin) { + const matDialogRef = menuEntry.action(); + matDialogRef + .afterClosed() + .pipe(take(1)) + .subscribe((result) => { + this.objectiveService.getFullObjective(objectiveMin.id).subscribe((objective) => { + menuEntry.afterAction(objective, result); + this.trigger?.focus(); }); }); - } else { - this.router.navigate([menuEntry.route!]); - } } - openObjectiveDetail() { - this.router.navigate(['details/objective', this.objective$.value.id]); + openObjectiveDetail(objectiveId: number) { + this.router.navigate(['details/objective', objectiveId]); } - openAddKeyResultDialog() { + openAddKeyResultDialog(objective: ObjectiveMin) { this.dialogService .open(KeyresultDialogComponent, { data: { - objective: this.objective$.value, + objective: objective, keyResult: null, }, }) .afterClosed() .subscribe((result) => { if (result?.openNew) { - this.openAddKeyResultDialog(); + this.openAddKeyResultDialog(objective); } this.refreshDataService.markDataRefresh(); }); } + + isObjectiveComplete(objective: ObjectiveMin): boolean { + return objective.state == State.SUCCESSFUL || objective.state == State.NOTSUCCESSFUL; + } + + getStateByValue(value: string): string { + return Object.keys(State).find((key) => State[key as keyof typeof State] === value) ?? ''; + } } diff --git a/frontend/src/app/services/dialog.service.ts b/frontend/src/app/services/dialog.service.ts index a1236133ea..964ce85571 100644 --- a/frontend/src/app/services/dialog.service.ts +++ b/frontend/src/app/services/dialog.service.ts @@ -18,6 +18,7 @@ export class DialogService { DIALOG_CONFIG: MatDialogConfig = { maxWidth: '100vw', // Used to override the default maxWidth of angular material dialog + restoreFocus: true, autoFocus: 'first-tabbable', }; diff --git a/frontend/src/app/services/objective-menu-actions.service.spec.ts b/frontend/src/app/services/objective-menu-actions.service.spec.ts new file mode 100644 index 0000000000..75124354cc --- /dev/null +++ b/frontend/src/app/services/objective-menu-actions.service.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; + +import { ObjectiveMenuActionsService } from './objective-menu-actions.service'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ObjectiveMin } from '../shared/types/model/ObjectiveMin'; +import { State } from '../shared/types/enums/State'; +import { objectiveMin } from '../shared/testData'; + +describe('ObjectiveMenuActionsService', () => { + let service: ObjectiveMenuActionsService; + let specificMenuEntriesSpy: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [TranslateService, provideRouter([]), provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ObjectiveMenuActionsService); + + specificMenuEntriesSpy = jest.spyOn(service as any, 'getSpecificMenuEntries'); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getMenu', () => { + it('should return default and specific menu entries for an ongoing objective', () => { + let spyOn = jest.spyOn(service as any, 'getOngoingMenuActions'); + + let objectiveMinLocal: ObjectiveMin = objectiveMin; + objectiveMinLocal.state = State.ONGOING; + service.getMenu(objectiveMinLocal); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + + it('should return draft menu entries for a draft objective', () => { + let spyOn = jest.spyOn(service as any, 'getDraftMenuActions'); + + let objectiveMinLocal: ObjectiveMin = objectiveMin; + objectiveMinLocal.state = State.DRAFT; + service.getMenu(objectiveMinLocal); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + + it('should return completed menu entries for a successful objective', () => { + let spyOn = jest.spyOn(service as any, 'getCompletedMenuActions'); + + let objectiveMinLocal: ObjectiveMin = objectiveMin; + objectiveMinLocal.state = State.SUCCESSFUL; + service.getMenu(objectiveMinLocal); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + + it('should return completed menu entries for a non-successful objective', () => { + let spyOn = jest.spyOn(service as any, 'getCompletedMenuActions'); + + let objectiveMinLocal: ObjectiveMin = objectiveMin; + objectiveMinLocal.state = State.NOTSUCCESSFUL; + service.getMenu(objectiveMinLocal); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + afterEach(() => { + expect(specificMenuEntriesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getReleaseAction', () => { + it('should return release from backlog action for an objective in backlog quarter', () => { + jest.spyOn(service as any, 'isInBacklogQuarter').mockReturnValue(true); + let spyOn = jest.spyOn(service as any, 'isInBacklogQuarter').mockReturnValue(true); + // @ts-expect-error + service.getReleaseAction(objectiveMin); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + + it('should return release from quarter action for an objective in non-backlog quarter', () => { + let spyOn = jest.spyOn(service as any, 'isInBacklogQuarter').mockReturnValue(false); + // @ts-expect-error + service.getReleaseAction(objectiveMin); + expect(spyOn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/app/services/objective-menu-actions.service.ts b/frontend/src/app/services/objective-menu-actions.service.ts new file mode 100644 index 0000000000..5dd1c2b285 --- /dev/null +++ b/frontend/src/app/services/objective-menu-actions.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import { DialogService } from './dialog.service'; +import { MatDialogRef } from '@angular/material/dialog'; +import { ObjectiveMin } from '../shared/types/model/ObjectiveMin'; +import { State } from '../shared/types/enums/State'; +import { ObjectiveMenuAfterActions } from '../components/objective/ObjectiveMenuAfterActions'; +import { ObjectiveService } from './objective.service'; +import { RefreshDataService } from './refresh-data.service'; +import { Objective } from '../shared/types/model/Objective'; +import { ObjectiveMenuActions } from '../components/objective/ObjectiveMenuActions'; +import { GJ_REGEX_PATTERN } from '../shared/constantLibary'; + +export type ObjectiveMenuAction = () => MatDialogRef; +export type ObjectiveMenuAfterAction = (objective: Objective, dialogResult: any) => any; + +export interface ObjectiveMenuEntry { + displayName: string; + action: ObjectiveMenuAction; + afterAction: ObjectiveMenuAfterAction; +} + +@Injectable({ + providedIn: 'root', +}) +export class ObjectiveMenuActionsService { + afterActions: ObjectiveMenuAfterActions; + actions: ObjectiveMenuActions; + constructor( + dialogService: DialogService, + objectiveService: ObjectiveService, + refreshDataService: RefreshDataService, + ) { + this.afterActions = new ObjectiveMenuAfterActions(objectiveService, refreshDataService); + this.actions = new ObjectiveMenuActions(dialogService, refreshDataService, this.afterActions); + } + + getMenu(objective: ObjectiveMin): ObjectiveMenuEntry[] { + return [...this.getSpecificMenuEntries(objective), ...this.getDefaultActions(objective)]; + } + + private getSpecificMenuEntries(objective: ObjectiveMin): ObjectiveMenuEntry[] { + if (this.isObjectiveComplete(objective)) { + return this.getCompletedMenuActions(objective); + } else if (objective.state === State.ONGOING) { + return this.getOngoingMenuActions(objective); + } else if (objective.state === State.DRAFT) { + return this.getDraftMenuActions(objective); + } + throw new Error('Objective invalid'); + } + + private getDefaultActions(objective: ObjectiveMin): ObjectiveMenuEntry[] { + return [this.actions.duplicateObjectiveAction(objective)]; + } + + private getDraftMenuActions(objective: ObjectiveMin): ObjectiveMenuEntry[] { + return [this.actions.editObjectiveAction(objective), this.getReleaseAction(objective)]; + } + + private getOngoingMenuActions(objective: ObjectiveMin): ObjectiveMenuEntry[] { + return [ + this.actions.editObjectiveAction(objective), + this.actions.completeObjectiveAction(objective), + this.actions.objectiveBackToDraft(), + ]; + } + + private getCompletedMenuActions(objective: ObjectiveMin): ObjectiveMenuEntry[] { + return [this.actions.objectiveReopen()]; + } + + private getReleaseAction(objective: ObjectiveMin): ObjectiveMenuEntry { + return this.isInBacklogQuarter(objective) + ? this.actions.releaseFromBacklogAction(objective) + : this.actions.releaseFromQuarterAction(objective); + } + + private isObjectiveComplete(objective: ObjectiveMin): boolean { + return objective.state == State.SUCCESSFUL || objective.state == State.NOTSUCCESSFUL; + } + + private isInBacklogQuarter(objective: ObjectiveMin) { + return !GJ_REGEX_PATTERN.test(objective.quarter.label); + } +} diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index f723866d85..be9c8d61a4 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -23,16 +23,17 @@ "ordinal": "Ordinal" }, "INFORMATION": { - "OBJECTIVE_STATE_TOOLTIP": "Der Status dieses Objectives ist", - "DELETE_TEAM": "Möchtest du dieses Team wirklich löschen? Zugehörige Objectives werden dadurch in allen Quartalen ebenfalls gelöscht!", - "DELETE_OBJECTIVE": "Möchtest du dieses Objective wirklich löschen? Zugehörige Key Results werden dadurch ebenfalls gelöscht!", - "DELETE_KEY_RESULT": "Möchtest du dieses Key Result wirklich löschen? Zugehörige Check-ins werden dadurch ebenfalls gelöscht!" + "OBJECTIVE_STATE_TOOLTIP": "Der Status dieses Objectives ist: {{state}}" }, "CONFIRMATION": { "DRAFT_CREATE": { "TITLE": "Check-in im Draft-Status", "TEXT": "Dein Objective befindet sich noch im DRAFT Status. Möchtest du das Check-in trotzdem erfassen?" }, + "REOPEN": { + "TITLE": "Objective wiedereröffnen", + "TEXT": "Soll dieses Objective wiedereröffnet werden?" + }, "RELEASE": { "TITLE": "Objective veröffentlichen", "TEXT": "Soll dieses Objective veröffentlicht werden?"