From bc6396066dba4eabcd05b2d8284d01e5b0f5970c Mon Sep 17 00:00:00 2001 From: Juanfran Date: Tue, 14 Nov 2023 09:48:57 +0100 Subject: [PATCH] fix: i##4099 move a story to another wf should not change the wf on screen --- .../project-feature-view-setter.component.ts | 26 +++- .../+state/actions/story-detail.actions.ts | 3 + .../effects/story-detail.effects.spec.ts | 131 +++++++++++++++++- .../+state/effects/story-detail.effects.ts | 63 +++++++-- .../+state/reducers/story-detail.reducer.ts | 7 +- 5 files changed, 203 insertions(+), 27 deletions(-) diff --git a/javascript/apps/taiga/src/app/modules/project/feature-view-setter/project-feature-view-setter.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-view-setter/project-feature-view-setter.component.ts index e9b188c80..a041ca31b 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-view-setter/project-feature-view-setter.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-view-setter/project-feature-view-setter.component.ts @@ -21,6 +21,7 @@ import { Store } from '@ngrx/store'; import { Observable, combineLatest, + debounceTime, distinctUntilChanged, filter, map, @@ -100,6 +101,7 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { ) { this.state.connect('storyView', this.store.select(selectStoryView)); this.state.connect('selectStory', this.store.select(selectStory)); + this.state.connect( 'url', this.routerHistory.urlChanged.pipe( @@ -147,12 +149,7 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { } if (params.storyRef) { - return this.store.select(selectStory).pipe( - filterNil(), - map((story) => { - return story?.workflow.slug ?? 'main'; - }) - ); + return this.storyWorkflow(Number(params.storyRef)); } return of('main'); @@ -165,7 +162,11 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { ); combineLatest([this.route.data, this.route.params]) - .pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()) + .pipe( + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + debounceTime(20) + ) .subscribe(([data, params]) => { const url = this.state.get('url'); const project: Project = data.project as Project; @@ -198,6 +199,17 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { return route.firstChild ? this.getActiveRoute(route.firstChild) : route; } + private storyWorkflow(storyRef: Story['ref']) { + return this.store.select(selectStory).pipe( + filterNil(), + filter((story) => story.ref === storyRef), + map((story) => { + return story?.workflow.slug ?? 'main'; + }), + take(1) + ); + } + private fetchStory(params: StoryParams) { this.store.dispatch( StoryDetailActions.initStory({ diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts index 6597a977b..2ef3ee4cc 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts @@ -167,5 +167,8 @@ export const StoryDetailApiActions = createActionGroup({ 'Delete attachment success': props<{ id: Attachment['id']; }>(), + 'Fetch workflow Success': props<{ + workflow: Workflow; + }>(), }, }); diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.spec.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.spec.ts index e566bbb15..84e91b5c2 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.spec.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.spec.ts @@ -20,7 +20,7 @@ import { WorkflowMockFactory, } from '@taiga/data'; import { cold, hot } from 'jest-marbles'; -import { Observable } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { selectCurrentProject } from '~/app/modules/project/data-access/+state/selectors/project.selectors'; import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; import { AppService } from '~/app/services/app.service'; @@ -32,10 +32,15 @@ import { } from '../actions/story-detail.actions'; import { selectStory, + selectStoryView, selectWorkflow, } from '../selectors/story-detail.selectors'; import { StoryDetailEffects } from './story-detail.effects'; -import { selectCurrentWorkflowSlug } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { + selectCurrentWorkflowSlug, + selectWorkflow as selectKanbanWorkflow, +} from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { HttpErrorResponse } from '@angular/common/http'; describe('StoryDetailEffects', () => { let actions$: Observable; @@ -269,4 +274,126 @@ describe('StoryDetailEffects', () => { expected ); }); + + describe('StoryDetailEffects - loadWorkflow$', () => { + it('should fetch workflow for full-view', () => { + const projectApiService = spectator.inject(ProjectApiService); + const effects = spectator.inject(StoryDetailEffects); + const project = ProjectMockFactory(); + const story = StoryDetailMockFactory(); + const workflow = WorkflowMockFactory(); + + store.overrideSelector(selectCurrentProject, project); + store.overrideSelector(selectStoryView, 'full-view'); + actions$ = hot('-a', { + a: StoryDetailApiActions.fetchStorySuccess({ story }), + }); + + projectApiService.getWorkflow.mockReturnValue( + cold('-b|', { b: workflow }) + ); + + const expected = cold('--a', { + a: StoryDetailApiActions.fetchWorkflowSuccess({ workflow }), + }); + + expect(effects.loadWorkflow$).toSatisfyOnFlush(() => { + expect(effects.loadWorkflow$).toBeObservable(expected); + expect(projectApiService.getWorkflow).toHaveBeenCalledWith( + project.id, + story.workflow.slug + ); + }); + }); + + it('should reuse kanban workflow', () => { + const projectApiService = spectator.inject(ProjectApiService); + const effects = spectator.inject(StoryDetailEffects); + const project = ProjectMockFactory(); + const story = StoryDetailMockFactory(); + const kanbanWorkflow = WorkflowMockFactory(); + story.workflow = kanbanWorkflow; + + store.overrideSelector(selectCurrentProject, project); + store.overrideSelector(selectStoryView, 'modal-view'); + store.overrideSelector(selectKanbanWorkflow, kanbanWorkflow); + actions$ = hot('-a', { + a: StoryDetailApiActions.fetchStorySuccess({ story }), + }); + + projectApiService.getWorkflow.mockReturnValue( + cold('-b|', { b: kanbanWorkflow }) + ); + + const expected = cold('--a', { + a: StoryDetailApiActions.fetchWorkflowSuccess({ + workflow: kanbanWorkflow, + }), + }); + + expect(effects.loadWorkflow$).toSatisfyOnFlush(() => { + expect(effects.loadWorkflow$).toBeObservable(expected); + expect(projectApiService.getWorkflow).not.toHaveBeenCalled(); + }); + }); + + it('should fetch workflow', () => { + const projectApiService = spectator.inject(ProjectApiService); + const effects = spectator.inject(StoryDetailEffects); + const project = ProjectMockFactory(); + const story = StoryDetailMockFactory(); + const workflow = WorkflowMockFactory(); + + store.overrideSelector(selectCurrentProject, project); + store.overrideSelector(selectStoryView, 'full-view'); + store.overrideSelector(selectKanbanWorkflow, null); + actions$ = hot('-a', { + a: StoryDetailApiActions.fetchStorySuccess({ story }), + }); + + projectApiService.getWorkflow.mockReturnValue( + cold('-b|', { b: workflow }) + ); + + const expected = cold('--a', { + a: StoryDetailApiActions.fetchWorkflowSuccess({ workflow }), + }); + + expect(effects.loadWorkflow$).toSatisfyOnFlush(() => { + expect(effects.loadWorkflow$).toBeObservable(expected); + expect(projectApiService.getWorkflow).toHaveBeenCalledWith( + project.id, + story.workflow.slug + ); + }); + }); + + it('should handle error', () => { + const projectApiService = spectator.inject(ProjectApiService); + const appService = spectator.inject(AppService); + const effects = spectator.inject(StoryDetailEffects); + const project = ProjectMockFactory(); + const story = StoryDetailMockFactory(); + const errorResponse = new HttpErrorResponse({ status: 404 }); + + store.overrideSelector(selectCurrentProject, project); + store.overrideSelector(selectStoryView, 'full-view'); + actions$ = hot('-a', { + a: StoryDetailApiActions.fetchStorySuccess({ story }), + }); + + projectApiService.getWorkflow.mockReturnValue( + cold('-#|', {}, errorResponse) + ); + + const expected = cold('--a', { + a: EMPTY, + }); + + expect(effects.loadWorkflow$).toSatisfyOnFlush(() => { + expect(effects.loadWorkflow$).toBeObservable(expected); + expect(appService.errorManagement).toHaveBeenCalledWith(errorResponse); + }); + }); + }); }); diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.ts index 08c6cc547..18353012a 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail.effects.ts @@ -15,15 +15,12 @@ import { fetch, pessimisticUpdate } from '@ngrx/router-store/data-persistence'; import { Store } from '@ngrx/store'; import { TuiNotification } from '@taiga-ui/core'; import { ProjectApiService } from '@taiga/api'; -import { filter, map, tap } from 'rxjs'; -import { - projectApiActions, - projectEventActions, -} from '~/app/modules/project/data-access/+state/actions/project.actions'; +import { EMPTY, catchError, map, of, switchMap, tap } from 'rxjs'; +import { projectEventActions } from '~/app/modules/project/data-access/+state/actions/project.actions'; import { selectCurrentProject } from '~/app/modules/project/data-access/+state/selectors/project.selectors'; import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; import { - selectWorkflow, + selectWorkflow as selectKanbanWorkflow, selectCurrentWorkflowSlug, } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; import { AppService } from '~/app/services/app.service'; @@ -33,7 +30,11 @@ import { StoryDetailActions, StoryDetailApiActions, } from '../actions/story-detail.actions'; -import { selectStory } from '../selectors/story-detail.selectors'; +import { + selectStory, + selectStoryView, + selectWorkflow, +} from '../selectors/story-detail.selectors'; @Injectable() export class StoryDetailEffects { @@ -64,13 +65,49 @@ export class StoryDetailEffects { StoryDetailApiActions.fetchStorySuccess, StoryDetailApiActions.updateStoryWorkflowSuccess ), - concatLatestFrom(() => [this.store.select(selectWorkflow)]), - filter(([action, workflow]) => { - return action.story?.workflow?.slug !== workflow?.slug; + concatLatestFrom(() => [ + this.store.select(selectCurrentProject).pipe(filterNil()), + ]), + switchMap(([action, project]) => { + return this.store.select(selectStoryView).pipe( + switchMap((view) => { + const fetchProject = (workflowSlug: string) => { + return this.projectApiService + .getWorkflow(project.id, workflowSlug) + .pipe( + map((workflow) => { + return StoryDetailApiActions.fetchWorkflowSuccess({ + workflow, + }); + }) + ); + }; + + if (view === 'full-view') { + return fetchProject(action.story.workflow.slug); + } else { + return this.store.select(selectKanbanWorkflow).pipe( + filterNil(), + switchMap((kanbanWorkflow) => { + if (kanbanWorkflow.id === action.story.workflow.id) { + return of( + StoryDetailApiActions.fetchWorkflowSuccess({ + workflow: kanbanWorkflow, + }) + ); + } + + return fetchProject(action.story.workflow.slug); + }) + ); + } + }) + ); }), - map(([action]) => { - const workflow = action.story?.workflow; - return projectApiActions.fetchWorkflow({ workflow: workflow?.slug }); + catchError((httpResponse: HttpErrorResponse) => { + this.appService.errorManagement(httpResponse); + + return EMPTY; }) ); }); diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts index 6beb5fb01..03f347a37 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts @@ -15,10 +15,7 @@ import { UserComment, Workflow, } from '@taiga/data'; -import { - projectApiActions, - projectEventActions, -} from '~/app/modules/project/data-access/+state/actions/project.actions'; +import { projectEventActions } from '~/app/modules/project/data-access/+state/actions/project.actions'; import { KanbanActions, KanbanApiActions, @@ -90,7 +87,7 @@ export const reducer = createImmerReducer( } ), on( - projectApiActions.fetchWorkflowSuccess, + StoryDetailApiActions.fetchWorkflowSuccess, (state, { workflow }): StoryDetailState => { state.loadingWorkflow = false; state.workflow = workflow;