diff --git a/javascript/apps/taiga/src/app/modules/project/data-access/+state/actions/project.actions.ts b/javascript/apps/taiga/src/app/modules/project/data-access/+state/actions/project.actions.ts index 3bc15d2ff..b52ce1af1 100644 --- a/javascript/apps/taiga/src/app/modules/project/data-access/+state/actions/project.actions.ts +++ b/javascript/apps/taiga/src/app/modules/project/data-access/+state/actions/project.actions.ts @@ -6,7 +6,12 @@ * Copyright (c) 2023-present Kaleidos INC */ -import { createAction, createActionGroup, props } from '@ngrx/store'; +import { + createAction, + createActionGroup, + emptyProps, + props, +} from '@ngrx/store'; import { Attachment, Membership, @@ -16,6 +21,7 @@ import { StoryDetail, User, UserComment, + Workflow, } from '@taiga/data'; import { DropCandidate } from '@taiga/ui/drag/drag.model'; @@ -67,6 +73,21 @@ export const updateStoryShowView = createAction( }>() ); +export const createWorkflow = createAction( + '[Project] create workflow', + props<{ + name: Workflow['name']; + }>() +); + +export const updateWorkflow = createAction( + '[Project] update workflow', + props<{ + name: Workflow['name']; + slug: Workflow['slug']; + }>() +); + export const newProjectMembers = createAction( '[Project][ws] New Project Members' ); @@ -104,6 +125,20 @@ export const deleteProjectSuccess = createAction( }>() ); +export const projectApiActions = createActionGroup({ + source: 'Project Api', + events: { + 'Create Workflow Success': props<{ + workflow: Workflow; + }>(), + 'create Workflow Error': emptyProps(), + 'Update Workflow Success': props<{ + workflow: Workflow; + oldSlug: Workflow['slug']; + }>(), + }, +}); + export const projectEventActions = createActionGroup({ source: 'Project ws', events: { @@ -133,6 +168,7 @@ export const projectEventActions = createActionGroup({ }>(), 'Remove Member': props<{ membership: Membership; workspace: string }>(), 'Update Member': props<{ membership: Membership }>(), + 'Create Workflow': props<{ workflow: Workflow }>(), 'Create comment': props<{ storyRef: Story['ref']; comment: UserComment }>(), 'Status reorder': props<{ id: Status['id']; diff --git a/javascript/apps/taiga/src/app/modules/project/data-access/+state/effects/project.effects.ts b/javascript/apps/taiga/src/app/modules/project/data-access/+state/effects/project.effects.ts index 07b79d70e..d3d1f4b31 100644 --- a/javascript/apps/taiga/src/app/modules/project/data-access/+state/effects/project.effects.ts +++ b/javascript/apps/taiga/src/app/modules/project/data-access/+state/effects/project.effects.ts @@ -6,12 +6,13 @@ * Copyright (c) 2023-present Kaleidos INC */ +import { Location } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; 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 { EMPTY, of } from 'rxjs'; @@ -23,7 +24,9 @@ import { switchMap, tap, } from 'rxjs/operators'; +import { selectUser } from '~/app/modules/auth/data-access/+state/selectors/auth.selectors'; import * as ProjectOverviewActions from '~/app/modules/project/feature-overview/data-access/+state/actions/project-overview.actions'; +import { selectUrl } from '~/app/router-selectors'; import { AppService } from '~/app/services/app.service'; import { RevokeInvitationService } from '~/app/services/revoke-invitation.service'; import { invitationProjectActions } from '~/app/shared/invite-user-modal/data-access/+state/actions/invitation.action'; @@ -35,8 +38,6 @@ import { selectCurrentProject, selectMembers, } from '../selectors/project.selectors'; -import { selectUser } from '~/app/modules/auth/data-access/+state/selectors/auth.selectors'; -import { selectUrl } from '~/app/router-selectors'; @Injectable() export class ProjectEffects { public loadProject$ = createEffect(() => { @@ -169,6 +170,81 @@ export class ProjectEffects { { dispatch: false } ); + public createWorkflow$ = createEffect(() => { + return this.actions$.pipe( + ofType(ProjectActions.createWorkflow), + concatLatestFrom(() => [ + this.store.select(selectCurrentProject).pipe(filterNil()), + ]), + pessimisticUpdate({ + run: (action, project) => { + return this.projectApiService + .createWorkflow(action.name, project.id) + .pipe( + map((newWorkflow) => { + void this.router.navigate([ + '/project', + project.id, + project.slug, + 'kanban', + newWorkflow.slug, + ]); + return ProjectActions.projectApiActions.createWorkflowSuccess({ + workflow: newWorkflow, + }); + }) + ); + }, + onError: (_, httpResponse: HttpErrorResponse) => { + if (httpResponse.status === 400) { + this.appService.toastNotification({ + message: 'create_workflow.max_workflow_created', + status: TuiNotification.Error, + scope: 'kanban', + closeOnNavigation: false, + }); + return ProjectActions.projectApiActions.createWorkflowError(); + } else { + return this.appService.errorManagement(httpResponse); + } + }, + }) + ); + }); + + public updateWorkflow$ = createEffect(() => { + return this.actions$.pipe( + ofType(ProjectActions.updateWorkflow), + concatLatestFrom(() => [ + this.store.select(selectCurrentProject).pipe(filterNil()), + ]), + pessimisticUpdate({ + run: (action, project) => { + return this.projectApiService + .updateWorkflow(action.name, action.slug, project.id) + .pipe( + map((updatedWorkflow) => { + // void this.router.navigate( + // [`project/${project.id}/${project.slug}/kanban/${updatedWorkflow.slug}`], + // { replaceUrl: true } + // ); + this.location.go( + `project/${project.id}/${project.slug}/kanban/${updatedWorkflow.slug}` + ); + return ProjectActions.projectApiActions.updateWorkflowSuccess({ + workflow: updatedWorkflow, + oldSlug: action.slug, + }); + }) + ); + }, + onError: (_, httpResponse: HttpErrorResponse) => { + return this.appService.errorManagement(httpResponse); + }, + }) + ); + }); + public initAssignUser$ = createEffect(() => { return this.actions$.pipe( ofType(ProjectActions.initAssignUser), @@ -421,6 +497,7 @@ export class ProjectEffects { private appService: AppService, private router: Router, private revokeInvitationService: RevokeInvitationService, - private store: Store + private store: Store, + private location: Location ) {} } diff --git a/javascript/apps/taiga/src/app/modules/project/data-access/+state/reducers/project.reducer.ts b/javascript/apps/taiga/src/app/modules/project/data-access/+state/reducers/project.reducer.ts index 159fe223a..84d595eab 100644 --- a/javascript/apps/taiga/src/app/modules/project/data-access/+state/reducers/project.reducer.ts +++ b/javascript/apps/taiga/src/app/modules/project/data-access/+state/reducers/project.reducer.ts @@ -16,7 +16,6 @@ import * as RolesPermissionsActions from '~/app/modules/project/settings/feature import { invitationProjectActions } from '~/app/shared/invite-user-modal/data-access/+state/actions/invitation.action'; import { createImmerReducer } from '~/app/shared/utils/store'; import * as ProjectActions from '../actions/project.actions'; -import { projectEventActions } from '../actions/project.actions'; export const projectFeatureKey = 'project'; @@ -102,6 +101,29 @@ export const reducer = createImmerReducer( return state; }), + on( + ProjectActions.projectApiActions.createWorkflowSuccess, + ProjectActions.projectEventActions.createWorkflow, + (state, { workflow }): ProjectState => { + if (state.currentProjectId) { + state.projects[state.currentProjectId].workflows.push(workflow); + } + + return state; + } + ), + on( + ProjectActions.projectApiActions.updateWorkflowSuccess, + (state, { workflow, oldSlug }): ProjectState => { + if (state.currentProjectId) { + const workflows = state.projects[state.currentProjectId].workflows; + const workflowIndex = workflows.findIndex((it) => it.slug === oldSlug); + workflows[workflowIndex].name = workflow.name; + workflows[workflowIndex].slug = workflow.slug; + } + return state; + } + ), on( ProjectActions.permissionsUpdateSuccess, (state, { project }): ProjectState => { @@ -135,7 +157,7 @@ export const reducer = createImmerReducer( } ), on( - projectEventActions.removeMember, + ProjectActions.projectEventActions.removeMember, (state, { membership }): ProjectState => { state.members = state.members.filter( (members) => members.user.username !== membership.user.username @@ -145,7 +167,7 @@ export const reducer = createImmerReducer( } ), on( - projectEventActions.updateMember, + ProjectActions.projectEventActions.updateMember, (state, { membership }): ProjectState => { state.members = state.members.map((member) => { if (member.user.username === membership.user.username) { diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/create-status/kanban-create-status.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/create-status/kanban-create-status.component.ts index 51ad1d2a2..1899322d2 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/create-status/kanban-create-status.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/create-status/kanban-create-status.component.ts @@ -6,24 +6,24 @@ * Copyright (c) 2023-present Kaleidos INC */ +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, - Output, Input, OnInit, + Output, } from '@angular/core'; +import { TranslocoDirective } from '@ngneat/transloco'; import { Store } from '@ngrx/store'; +import { TuiButtonModule } from '@taiga-ui/core'; import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; -import { selectCurrentWorkflow } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { selectWorkflow } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; import { EditStatus } from '~/app/modules/project/feature-kanban/models/edit-status.model'; -import { TuiButtonModule } from '@taiga-ui/core'; -import { EditStatusComponent } from '../edit-status/edit-status.component'; -import { CommonModule } from '@angular/common'; -import { TranslocoDirective } from '@ngneat/transloco'; import { RestoreFocusTargetDirective } from '~/app/shared/directives/restore-focus/restore-focus-target.directive'; +import { EditStatusComponent } from '../edit-status/edit-status.component'; @Component({ selector: 'tg-kanban-create-status', @@ -49,7 +49,7 @@ export class KanbanCreateStatusComponent implements OnInit { @Output() public closeForm = new EventEmitter(); - public workflow = this.store.selectSignal(selectCurrentWorkflow); + public workflow = this.store.selectSignal(selectWorkflow); public showAddForm = false; public columnSize = 292; diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-status/delete-status.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-status/delete-status.component.ts index dee9f7a51..1a5b20001 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-status/delete-status.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-status/delete-status.component.ts @@ -27,7 +27,7 @@ import { Status } from '@taiga/data'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { Observable, map } from 'rxjs'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ContextNotificationComponent } from '@taiga/ui/context-notification/context-notification.component'; import { InputsModule } from '@taiga/ui/inputs'; import { ModalComponent } from '@taiga/ui/modal/components'; @@ -90,6 +90,7 @@ export class DeleteStatusComponent implements OnInit { }); const valueContent$ = this.form.get('status')?.valueChanges.pipe( + takeUntilDestroyed(), map((value) => { return this.statusesList().find((it) => it.id === value)?.name ?? ''; }) diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.css b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.css new file mode 100644 index 000000000..7b468cc2f --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.css @@ -0,0 +1,69 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +@import url("tools/typography.css"); + +.actions { + display: flex; + gap: 2rem; + justify-content: flex-end; + margin-block-start: var(--spacing-24); +} + +.title { + @mixin font-heading-3; + + margin-block: 0 var(--spacing-24); +} + +.project-form { + display: flex; + flex-direction: column; + gap: var(--spacing-24); +} + +.confirm-close-description { + margin-block-end: var(--spacing-32); +} + +.warning-notification { + display: block; + + &::ng-deep .wrapper { + inline-size: 100%; + } + + &::ng-deep .bold { + font-weight: var(--font-weight-medium); + } +} + +.text { + @mixin font-paragraph; + + color: var(--color-gray80); + margin-block-end: 0; + padding-block: var(--spacing-24); + padding-block-end: var(--spacing-16); + padding-inline: 0; + + &.is-last-workflow { + padding-block-end: 0; + } +} + +.select-workflow { + margin-block: var(--spacing-8); + margin-inline-start: var(--spacing-24); +} + +.select-info { + display: flex; + margin-block-end: var(--spacing-24); + margin-inline-start: var(--spacing-24); +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.html b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.html new file mode 100644 index 000000000..cb668f511 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.html @@ -0,0 +1,111 @@ + + + + +
+

+ {{ + t('kanban.delete_workflow_modal.title', { + name: currentWorkflow.name + }) + }} +

+ + + +

+ {{ + isLastWorkflow() + ? t('kanban.delete_workflow_modal.stories_will_be_deleted') + : t('kanban.delete_workflow_modal.what_to_do_statuses') + }} +

+
+ + + + + + + + + + + {{ t('kanban.delete_workflow_modal.statuses_placed_after') }} + + + +
+ +
+ + +
+
+
+
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.ts new file mode 100644 index 000000000..17086a0e7 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/delete-workflow/delete-workflow.component.ts @@ -0,0 +1,127 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + OnInit, + signal, + WritableSignal, + computed, + Signal, +} from '@angular/core'; +import { TranslocoModule } from '@ngneat/transloco'; +import { TuiAutoFocusModule } from '@taiga-ui/cdk'; +import { TuiButtonModule, TuiLinkModule } from '@taiga-ui/core'; +import { Workflow } from '@taiga/data'; + +import { FormControl, FormGroup } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { map } from 'rxjs'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { ContextNotificationComponent } from '@taiga/ui/context-notification/context-notification.component'; +import { InputsModule } from '@taiga/ui/inputs'; +import { ModalComponent } from '@taiga/ui/modal/components'; +import { trackByProp } from '~/app/shared/utils/track-by-prop'; + +/* TODO: Workflout shoud have id?, if not, change the type */ +@Component({ + selector: 'tg-delete-workflow', + templateUrl: './delete-workflow.component.html', + styleUrls: ['./delete-workflow.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + TranslocoModule, + TuiButtonModule, + TuiLinkModule, + TuiAutoFocusModule, + CommonModule, + ContextNotificationComponent, + InputsModule, + ModalComponent, + ], +}) +export class DeleteWorkflowComponent implements OnInit { + @Input({ required: true }) + public currentWorkflow!: Workflow; + + @Input() + public show = false; + + @Output() + public closeModal = new EventEmitter(); + + @Output() + public submitDelete = new EventEmitter(); + + @Input({ required: true }) + public set workflows(workflows: Workflow[]) { + this.workflowsList.set(workflows); + } + + public form = new FormGroup({ + statuses: new FormControl('move', { nonNullable: true }), + workflow: new FormControl('', { nonNullable: true }), + }); + public workflowsList: WritableSignal = signal([]); + public filteredWorkflows = computed(() => { + return this.workflowsList().filter( + (it) => it.slug !== this.currentWorkflow.slug + ); + }); + public isLastWorkflow = computed(() => { + return this.workflowsList().length === 1; + }); + + public valueContent!: Signal; + public trackById = trackByProp('id'); + + public get statusesFormControl() { + return this.form.get('statuses') as FormControl; + } + + constructor() { + if (!this.isLastWorkflow()) { + this.valueContent = toSignal( + this.statusesFormControl.valueChanges.pipe( + takeUntilDestroyed(), + map((value) => { + return ( + this.workflowsList().find((it) => it.id === value)?.name ?? '' + ); + }), + map((value) => value ?? '') + ), + { initialValue: '' } + ); + } + } + + public ngOnInit() { + if (this.filteredWorkflows().length) { + this.form.get('workflow')?.setValue(this.filteredWorkflows()[0].slug); + } + } + + public submit() { + this.close(); + const moveToWorkflow: Workflow['id'] | undefined = + !this.isLastWorkflow() && this.form.get('statuses')!.value === 'move' + ? this.form.value.workflow + : undefined; + this.submitDelete.next(moveToWorkflow); + } + + public close() { + this.closeModal.next(); + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/edit-status/edit-status.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/edit-status/edit-status.component.ts index 1a5b2f2ea..392910985 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/edit-status/edit-status.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/edit-status/edit-status.component.ts @@ -6,6 +6,7 @@ * Copyright (c) 2023-present Kaleidos INC */ +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,21 +21,20 @@ import { import { FormBuilder, FormGroup, - Validators, ReactiveFormsModule, + Validators, } from '@angular/forms'; +import { TranslocoDirective } from '@ngneat/transloco'; import { Store } from '@ngrx/store'; -import { Status } from '@taiga/data'; -import { selectCurrentWorkflow } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; -import { EditStatus } from '~/app/modules/project/feature-kanban/models/edit-status.model'; -import { UtilsService } from '~/app/shared/utils/utils-service.service'; import { TuiButtonModule } from '@taiga-ui/core'; -import { CommonModule } from '@angular/common'; -import { TranslocoDirective } from '@ngneat/transloco'; +import { Status } from '@taiga/data'; import { InputsModule } from '@taiga/ui/inputs'; -import { RestoreFocusDirective } from '~/app/shared/directives/restore-focus/restore-focus.directive'; -import { OutsideClickDirective } from '~/app/shared/directives/outside-click/outside-click.directive'; +import { selectWorkflow } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { EditStatus } from '~/app/modules/project/feature-kanban/models/edit-status.model'; import { AutoFocusDirective } from '~/app/shared/directives/auto-focus/auto-focus.directive'; +import { OutsideClickDirective } from '~/app/shared/directives/outside-click/outside-click.directive'; +import { RestoreFocusDirective } from '~/app/shared/directives/restore-focus/restore-focus.directive'; +import { UtilsService } from '~/app/shared/utils/utils-service.service'; @Component({ selector: 'tg-edit-status', @@ -76,7 +76,7 @@ export class EditStatusComponent implements OnInit { this.cancelEdit(); } - public workflow = this.store.selectSignal(selectCurrentWorkflow); + public workflow = this.store.selectSignal(selectWorkflow); public statusForm!: FormGroup; public statusMaxLength = 30; diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.css b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.css new file mode 100644 index 000000000..ce5b490e8 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.css @@ -0,0 +1,46 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +@import url("tools/typography.css"); +@import url("shared/option-list.css"); + +:host { + align-items: center; + display: flex; + gap: var(--spacing-4); +} + +.kanban-title { + @mixin font-inline; + + color: var(--color-gray100); + font-weight: 500; + margin: 0; +} + +.view-options-list { + @mixin option-list; +} + +.separator { + @mixin separator; +} + +.workflow-form { + &::ng-deep { + background: transparent; + border: 0; + box-shadow: none; + margin-block-start: var(--spacing-4); + padding: 0; + + &.input-wrapper { + max-inline-size: 210px; + } + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.html b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.html new file mode 100644 index 000000000..c23886ec5 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.html @@ -0,0 +1,97 @@ + + + +

+ {{ t('kanban.title') }} +

+ + + + + + + + + + + +
+ +
+
+ + + + + +
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.ts new file mode 100644 index 000000000..8b6894d1a --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/components/kanban-header/kanban-header.component.ts @@ -0,0 +1,76 @@ +/**. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { TranslocoDirective } from '@ngneat/transloco'; +import { + TuiButtonModule, + TuiDataListModule, + TuiHostedDropdownModule, + TuiSvgModule, +} from '@taiga-ui/core'; +import { Project, Workflow } from '@taiga/data'; +import { BreadcrumbComponent } from '@taiga/ui/breadcrumb/breadcrumb.component'; +import { DeleteWorkflowComponent } from '../delete-workflow/delete-workflow.component'; +import { NewWorkflowFormComponent } from '~/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component'; +import { updateWorkflow } from '~/app/modules/project/data-access/+state/actions/project.actions'; +import { Store } from '@ngrx/store'; + +@Component({ + selector: 'tg-kanban-header', + templateUrl: './kanban-header.component.html', + styleUrls: ['./kanban-header.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TranslocoDirective, + TuiHostedDropdownModule, + TuiButtonModule, + TuiDataListModule, + TuiSvgModule, + DeleteWorkflowComponent, + BreadcrumbComponent, + NewWorkflowFormComponent, + ], +}) +export class KanbanHeaderComponent { + @Input({ required: true }) public project!: Project; + @Input({ required: true }) public workflows: Workflow[] = []; + @Input({ required: true }) public workflow!: Workflow; + + public openWorkflowOptions = false; + public deleteWorkflowModal = false; + public editStatusFormOpened = false; + + constructor(private store: Store) {} + + public openDeleteWorkflowModal() { + this.deleteWorkflowModal = true; + } + + public submitDeleteWorkflow(event: Workflow['id'] | undefined) { + console.log(event); + } + + public toggleEditWorkflowForm() { + this.editStatusFormOpened = !this.editStatusFormOpened; + } + + public editWorkflowName(workflow: Workflow['name']) { + this.openWorkflowOptions = false; + this.toggleEditWorkflowForm(); + this.store.dispatch( + updateWorkflow({ + name: workflow, + slug: this.workflow.slug, + }) + ); + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/custom-scroll-strategy/kanban-scroll-manager.service.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/custom-scroll-strategy/kanban-scroll-manager.service.ts index 4d6380e9f..0fdc25582 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/custom-scroll-strategy/kanban-scroll-manager.service.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/custom-scroll-strategy/kanban-scroll-manager.service.ts @@ -24,8 +24,8 @@ import { filterNil } from '~/app/shared/utils/operators'; import { KanbanStatusComponent } from '../components/status/kanban-status.component'; import { KanbanWorkflowComponent } from '../components/workflow/kanban-workflow.component'; import { - selectCurrentWorkflow, selectStory, + selectWorkflow, } from '../data-access/+state/selectors/kanban.selectors'; import { KanbanStory } from '../kanban.model'; @@ -131,7 +131,7 @@ export class KanbanScrollManagerService { private moveToStatus(status: Status) { return new Observable((subscriber) => { this.store - .select(selectCurrentWorkflow) + .select(selectWorkflow) .pipe( filterNil(), take(1), diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions.ts index 5db1323f9..16a66b670 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions.ts @@ -8,18 +8,19 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store'; import { Membership, Status, Story, Workflow } from '@taiga/data'; +import { DropCandidate } from '@taiga/ui/drag/drag.model'; import { KanbanReorderEvent, KanbanStory, KanbanStoryA11y, PartialStory, } from '~/app/modules/project/feature-kanban/kanban.model'; -import { DropCandidate } from '@taiga/ui/drag/drag.model'; export const KanbanActions = createActionGroup({ source: 'Kanban', events: { - 'Init Kanban': emptyProps(), + 'Init Kanban': props<{ workflow: Workflow['slug'] }>(), + 'Load Workflow kanban': props<{ workflow: Workflow['slug'] }>(), 'Open Create Story form': props<{ status: Status['id'] }>(), 'Close Create Story form': emptyProps(), 'Create Story': props<{ @@ -120,12 +121,13 @@ export const KanbanActions = createActionGroup({ export const KanbanApiActions = createActionGroup({ source: 'Kanban Api', events: { - 'Fetch Workflows Success': props<{ workflows: Workflow[] }>(), + 'Fetch Workflow Success': props<{ workflow: Workflow }>(), 'Fetch Stories Success': props<{ stories: Story[]; offset: number; complete: boolean; }>(), + 'Create Story Success': props<{ story: Story; tmpId: PartialStory['tmpId']; diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.spec.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.spec.ts index e3623ecf7..07b8a5ca0 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.spec.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.spec.ts @@ -6,7 +6,7 @@ * Copyright (c) 2023-present Kaleidos INC */ import { randFirstName, randNumber, randUserName } from '@ngneat/falso'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; @@ -64,18 +64,18 @@ describe('ProjectEffects', () => { const project = ProjectMockFactory(); const projectApiService = spectator.inject(ProjectApiService); const effects = spectator.inject(KanbanEffects); - const workflows = [WorkflowMockFactory(3)]; + const workflow = WorkflowMockFactory(3); store.overrideSelector(selectCurrentProject, project); - projectApiService.getWorkflows.mockReturnValue( - cold('-b|', { b: workflows }) - ); + projectApiService.getWorkflow.mockReturnValue(cold('-b|', { b: workflow })); - actions$ = hot('-a', { a: KanbanActions.initKanban() }); + actions$ = hot('-a', { + a: KanbanActions.initKanban({ workflow: workflow.slug }), + }); const expected = cold('--a', { - a: KanbanApiActions.fetchWorkflowsSuccess({ workflows }), + a: KanbanApiActions.fetchWorkflowSuccess({ workflow }), }); expect(effects.loadKanbanWorkflows$).toBeObservable(expected); @@ -85,12 +85,15 @@ describe('ProjectEffects', () => { const projectApiService = spectator.inject(ProjectApiService); const effects = spectator.inject(KanbanEffects); const stories = [StoryMockFactory()]; + const workflow = WorkflowMockFactory(3); projectApiService.getAllStories.mockReturnValue( cold('-b|', { b: { stories, offset: 0, complete: false } }) ); - actions$ = hot('-a', { a: KanbanActions.initKanban() }); + actions$ = hot('-a', { + a: KanbanActions.initKanban({ workflow: workflow.slug }), + }); const expected = cold('--a', { a: KanbanApiActions.fetchStoriesSuccess({ diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.ts index 10ca94e7c..2bfc41aa4 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/effects/kanban.effects.ts @@ -8,6 +8,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { TranslocoService } from '@ngneat/transloco'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; import { @@ -36,26 +37,27 @@ import { KanbanEventsActions, } from '../actions/kanban.actions'; import { - selectCurrentWorkflow, selectCurrentWorkflowSlug, - selectWorkflows, + selectWorkflow, } from '../selectors/kanban.selectors'; @Injectable() export class KanbanEffects { public loadKanbanWorkflows$ = createEffect(() => { return this.actions$.pipe( - ofType(KanbanActions.initKanban), + ofType(KanbanActions.initKanban, KanbanActions.loadWorkflowKanban), concatLatestFrom(() => [ this.store.select(selectCurrentProject).pipe(filterNil()), ]), fetch({ run: (action, project) => { - return this.projectApiService.getWorkflows(project.id).pipe( - map((workflows) => { - return KanbanApiActions.fetchWorkflowsSuccess({ workflows }); - }) - ); + return this.projectApiService + .getWorkflow(project.id, action.workflow) + .pipe( + map((workflow) => { + return KanbanApiActions.fetchWorkflowSuccess({ workflow }); + }) + ); }, onError: (action, error: HttpErrorResponse) => { return this.appService.errorManagement(error); @@ -66,25 +68,27 @@ export class KanbanEffects { public loadKanbanStories$ = createEffect(() => { return this.actions$.pipe( - ofType(KanbanActions.initKanban), + ofType(KanbanActions.initKanban, KanbanActions.loadWorkflowKanban), concatLatestFrom(() => [ this.store.select(selectCurrentProject).pipe(filterNil()), - this.store.select(selectWorkflows), + this.store.select(selectWorkflow), ]), fetch({ run: (action, project) => { - return this.projectApiService.getAllStories(project.id, 'main').pipe( - map(({ stories, offset, complete }) => { - return KanbanApiActions.fetchStoriesSuccess({ - stories, - offset, - complete, - }); - }), - finalize(() => { - return KanbanActions.loadStoriesComplete(); - }) - ); + return this.projectApiService + .getAllStories(project.id, action.workflow) + .pipe( + map(({ stories, offset, complete }) => { + return KanbanApiActions.fetchStoriesSuccess({ + stories, + offset, + complete, + }); + }), + finalize(() => { + return KanbanActions.loadStoriesComplete(); + }) + ); }, onError: (action, error: HttpErrorResponse) => { return this.appService.errorManagement(error); @@ -437,7 +441,7 @@ export class KanbanEffects { filter(({ candidate }) => !!candidate), concatLatestFrom(() => [ this.store.select(selectCurrentProjectId).pipe(filterNil()), - this.store.select(selectCurrentWorkflow).pipe(filterNil()), + this.store.select(selectWorkflow).pipe(filterNil()), ]), pessimisticUpdate({ run: (action, project, workflow) => { @@ -477,6 +481,7 @@ export class KanbanEffects { private actions$: Actions, private store: Store, private projectApiService: ProjectApiService, + private router: Router, private kanbanScrollManagerService: KanbanScrollManagerService, private translocoService: TranslocoService ) {} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.spec.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.spec.ts index 40d558be9..e55138320 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.spec.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.spec.ts @@ -34,17 +34,17 @@ describe('Kanban reducer', () => { { ...initialKanbanState, empty: true, - loadingWorkflows: true, - workflows: [workflow], + loadingWorkflow: true, + workflow, }, - KanbanApiActions.fetchWorkflowsSuccess({ - workflows: [workflow], + KanbanApiActions.fetchWorkflowSuccess({ + workflow, }) ); expect(state.stories[workflow.statuses[0].id]).toEqual([]); expect(state.createStoryForm).toEqual(workflow.statuses[0].id); - expect(state.loadingWorkflows).toEqual(false); + expect(state.loadingWorkflow).toEqual(false); }); it('load stories', () => { @@ -76,7 +76,7 @@ describe('Kanban reducer', () => { { ...initialKanbanState, loadingStories: true, - workflows: [workflow], + workflow, }, KanbanApiActions.fetchStoriesSuccess({ stories: [], @@ -301,7 +301,7 @@ describe('Kanban reducer', () => { [status.id]: stories, }, dragging: [stories[0]], - workflows: [workflow], + workflow, }, KanbanActions.storyDropped({ ref: stories[0].ref!, @@ -396,7 +396,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: stories2, }, - workflows: [workflow], + workflow, }, KanbanEventsActions.reorderStory({ stories: [stories[0].ref!], @@ -453,7 +453,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: stories2, }, - workflows: [workflow], + workflow, }, StoryDetailActions.updateStory({ projectId: randUuid(), @@ -490,7 +490,7 @@ describe('Kanban reducer', () => { stories: { [status.id]: stories, }, - workflows: [workflow], + workflow, }, StoryDetailActions.updateStory({ projectId: randUuid(), @@ -539,7 +539,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: stories2, }, - workflows: [workflow], + workflow, }, projectEventActions.updateStory({ story: { @@ -604,7 +604,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: stories2, }, - workflows: [workflow], + workflow, }, KanbanActions.removeMembers({ members: [assignee2] }) ); @@ -651,7 +651,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: stories2, }, - workflows: [workflow], + workflow, }, projectEventActions.updateStory({ story: { @@ -688,7 +688,7 @@ describe('Kanban reducer', () => { [status.id]: stories, [status2.id]: [], }, - workflows: [workflow], + workflow, }, KanbanApiActions.deleteStatusSuccess({ status: status.id, @@ -698,7 +698,7 @@ describe('Kanban reducer', () => { ); expect(state.stories[status.id]).toBeUndefined(); expect(state.stories[status2.id].length).toEqual(1); - expect(state.workflows![0].statuses.length).toEqual(11); + expect(state.workflow?.statuses.length).toEqual(11); }); it('delete status and stories', () => { @@ -722,7 +722,7 @@ describe('Kanban reducer', () => { stories: { [status.id]: stories, }, - workflows: [workflow], + workflow, }, KanbanApiActions.deleteStatusSuccess({ status: status.id, @@ -731,7 +731,7 @@ describe('Kanban reducer', () => { }) ); expect(state.stories[status.id]).toBeUndefined(); - expect(state.workflows![0].statuses.length).toEqual(10); + expect(state.workflow?.statuses.length).toEqual(10); }); describe('status dropped', () => { @@ -742,7 +742,7 @@ describe('Kanban reducer', () => { const state = kanbanReducer.kanbanFeature.reducer( { ...initialKanbanState, - workflows: [workflow], + workflow, }, KanbanActions.statusDropped({ @@ -754,15 +754,9 @@ describe('Kanban reducer', () => { }) ); - expect(state.workflows![0].statuses[0].id).toEqual( - workflow.statuses[1].id - ); - expect(state.workflows![0].statuses[1].id).toEqual( - workflow.statuses[2].id - ); - expect(state.workflows![0].statuses[2].id).toEqual( - workflow.statuses[0].id - ); + expect(state.workflow?.statuses[0].id).toEqual(workflow.statuses[1].id); + expect(state.workflow?.statuses[1].id).toEqual(workflow.statuses[2].id); + expect(state.workflow?.statuses[2].id).toEqual(workflow.statuses[0].id); }); it('sleft', () => { @@ -772,7 +766,7 @@ describe('Kanban reducer', () => { const state = kanbanReducer.kanbanFeature.reducer( { ...initialKanbanState, - workflows: [workflow], + workflow, }, KanbanActions.statusDropped({ @@ -784,22 +778,16 @@ describe('Kanban reducer', () => { }) ); - expect(state.workflows![0].statuses[0].id).toEqual( - workflow.statuses[1].id - ); - expect(state.workflows![0].statuses[1].id).toEqual( - workflow.statuses[0].id - ); - expect(state.workflows![0].statuses[2].id).toEqual( - workflow.statuses[2].id - ); + expect(state.workflow?.statuses[0].id).toEqual(workflow.statuses[1].id); + expect(state.workflow?.statuses[1].id).toEqual(workflow.statuses[0].id); + expect(state.workflow?.statuses[2].id).toEqual(workflow.statuses[2].id); }); }); describe('select columns', () => { it('drag right', () => { - const workflows = [WorkflowMockFactory(3)]; - const statuses = workflows[0].statuses; + const workflow = WorkflowMockFactory(3); + const statuses = workflow.statuses; const draggingStatus = statuses[0]; const statusDropCandidate = { id: draggingStatus.id, @@ -810,7 +798,7 @@ describe('Kanban reducer', () => { } as KanbanState['statusDropCandidate']; const columns = kanbanReducer.kanbanFeature.selectColums.projector( - workflows, + workflow, draggingStatus, statusDropCandidate ); @@ -832,8 +820,8 @@ describe('Kanban reducer', () => { }); it('drag left', () => { - const workflows = [WorkflowMockFactory(3)]; - const statuses = workflows[0].statuses; + const workflow = WorkflowMockFactory(3); + const statuses = workflow.statuses; const draggingStatus = statuses[1]; const statusDropCandidate = { id: draggingStatus.id, @@ -844,7 +832,7 @@ describe('Kanban reducer', () => { } as KanbanState['statusDropCandidate']; const columns = kanbanReducer.kanbanFeature.selectColums.projector( - workflows, + workflow, draggingStatus, statusDropCandidate ); diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.ts index a3e6d290d..6f902e630 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/reducers/kanban.reducer.ts @@ -8,6 +8,7 @@ import { createFeature, createSelector, on } from '@ngrx/store'; import { Status, Story, Workflow } from '@taiga/data'; +import { DropCandidate } from '@taiga/ui/drag/drag.model'; import { projectEventActions } from '~/app/modules/project/data-access/+state/actions/project.actions'; import { KanbanStory, @@ -15,7 +16,7 @@ import { PartialStory, } from '~/app/modules/project/feature-kanban/kanban.model'; import { StoryDetailActions } from '~/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions'; -import { DropCandidate } from '@taiga/ui/drag/drag.model'; +import { moveItemArray } from '~/app/shared/utils/move-item-array'; import { pick } from '~/app/shared/utils/pick'; import { createImmerReducer } from '~/app/shared/utils/store'; import { @@ -31,12 +32,12 @@ import { replaceStory, setIntialPosition, } from './kanban.reducer.helpers'; -import { moveItemArray } from '~/app/shared/utils/move-item-array'; +import { projectApiActions } from '~/app/modules/project/data-access/+state/actions/project.actions'; export interface KanbanState { - loadingWorkflows: boolean; + loadingWorkflow: boolean; loadingStories: boolean; - workflows: null | Workflow[]; + workflow: null | Workflow; currentWorkflowSlug: Workflow['slug']; stories: Record; createStoryForm: Status['id']; @@ -67,9 +68,9 @@ export interface KanbanState { } export const initialKanbanState: KanbanState = { - loadingWorkflows: false, + loadingWorkflow: false, loadingStories: false, - workflows: null, + workflow: null, currentWorkflowSlug: 'main', stories: {}, createStoryForm: '', @@ -272,22 +273,21 @@ export const reducer = createImmerReducer( } ), on( - KanbanApiActions.fetchWorkflowsSuccess, - (state, { workflows }): KanbanState => { - state.workflows = workflows; - state.loadingWorkflows = false; - - workflows.forEach((workflow) => { - workflow.statuses.forEach((status) => { - if (!state.stories[status.id]) { - state.stories[status.id] = []; - } - }); + KanbanApiActions.fetchWorkflowSuccess, + (state, { workflow }): KanbanState => { + state.workflow = workflow; + state.currentWorkflowSlug = workflow.slug; + state.loadingWorkflow = false; + + workflow.statuses.forEach((status) => { + if (!state.stories[status.id]) { + state.stories[status.id] = []; + } }); if (state.empty !== null && state.empty) { // open the first form if the kanban is empty and there is at least one status - state.createStoryForm = state.workflows[0].statuses?.[0]?.id; + state.createStoryForm = state.workflow.statuses?.[0]?.id; } return state; @@ -310,9 +310,9 @@ export const reducer = createImmerReducer( state.empty = !stories.length; } - if (state.empty && state.workflows) { + if (state.empty && state.workflow) { // open the first form if the kanban is empty and there is at least one status - state.createStoryForm = state.workflows[0].statuses?.[0]?.id; + state.createStoryForm = state.workflow.statuses?.[0]?.id; } return state; @@ -448,8 +448,7 @@ export const reducer = createImmerReducer( state = removeStory(state, (it) => !!it._shadow || !!it._dragging); if (story) { - // TODO: current workflow - const statusObj = state.workflows![0].statuses.find( + const statusObj = state.workflow?.statuses.find( (it) => it.id === status ); @@ -533,8 +532,8 @@ export const reducer = createImmerReducer( const oldStory = findStory(state, (it) => it.ref === story.ref); // TODO: current workflow - if (oldStory?.status.id !== story.status && state.workflows) { - const status = state.workflows[0].statuses.find( + if (oldStory?.status.id !== story.status && state.workflow) { + const status = state.workflow.statuses.find( (it) => it.id === story.status ); @@ -669,12 +668,10 @@ export const reducer = createImmerReducer( on( KanbanApiActions.createStatusSuccess, KanbanEventsActions.updateStatus, - (state, { status, workflow }): KanbanState => { + (state, { status }): KanbanState => { state.loadingStatus = false; - const workflowIndex = state.workflows!.findIndex( - (it) => it.slug === workflow - ); - state.workflows![workflowIndex].statuses.push(status); + + state.workflow?.statuses.push(status); state.stories[status.id] = []; return state; @@ -683,29 +680,22 @@ export const reducer = createImmerReducer( on( KanbanActions.editStatus, KanbanEventsActions.editStatus, - (state, { status, workflow }): KanbanState => { - const workflowIndex = state.workflows!.findIndex( - (it) => it.slug === workflow - ); - const statusIndex = state.workflows![workflowIndex].statuses.findIndex( + (state, { status }): KanbanState => { + const statusIndex = state.workflow!.statuses.findIndex( (it) => it.id === status.id ); - state.workflows![workflowIndex].statuses[statusIndex].name = status.name; + state.workflow!.statuses[statusIndex].name = status.name; return state; } ), on( KanbanApiActions.editStatusError, - (state, { undo, status, workflow }): KanbanState => { - const workflowIndex = state.workflows!.findIndex( - (it) => it.slug === workflow - ); - const statusIndex = state.workflows![workflowIndex].statuses.findIndex( + (state, { undo, status }): KanbanState => { + const statusIndex = state.workflow!.statuses.findIndex( (it) => it.id === status.id ); - state.workflows![workflowIndex].statuses[statusIndex].name = - undo.status.name; + state.workflow!.statuses[statusIndex].name = undo.status.name; return state; } @@ -713,11 +703,8 @@ export const reducer = createImmerReducer( on( KanbanApiActions.deleteStatusSuccess, KanbanEventsActions.statusDeleted, - (state, { status, workflow, moveToStatus }): KanbanState => { - const workflowIndex = state.workflows!.findIndex( - (it) => it.slug === workflow - ); - const statuses = state.workflows![workflowIndex].statuses; + (state, { status, moveToStatus }): KanbanState => { + const statuses = state.workflow!.statuses; const statusIndex = statuses.findIndex((it) => it.id === status); if (moveToStatus) { const storiesToMove = state.stories[status]; @@ -733,7 +720,7 @@ export const reducer = createImmerReducer( } ), on(KanbanActions.statusDragStart, (state, { id }): KanbanState => { - const currentWorkflow = state.workflows ? state.workflows[0] : undefined; + const currentWorkflow = state.workflow; if (currentWorkflow) { state.dragType = 'status'; @@ -759,7 +746,7 @@ export const reducer = createImmerReducer( KanbanActions.statusDropped, projectEventActions.statusReorder, (state, { id, candidate }): KanbanState => { - const currentWorkflow = state.workflows ? state.workflows[0] : undefined; + const currentWorkflow = state.workflow; if (!currentWorkflow) { return state; @@ -802,6 +789,21 @@ export const reducer = createImmerReducer( state.draggingStatus = null; state.statusDropCandidate = null; + return state; + } + ), + on( + projectApiActions.updateWorkflowSuccess, + (state, { workflow, oldSlug }): KanbanState => { + if (state.currentWorkflowSlug === oldSlug) { + state.currentWorkflowSlug = workflow.slug; + } + + if (state.workflow && state.workflow.slug === oldSlug) { + state.workflow.name = workflow.name; + state.workflow.slug = workflow.slug; + } + return state; } ) @@ -811,19 +813,18 @@ export const kanbanFeature = createFeature({ name: 'kanban', reducer, extraSelectors: ({ - selectWorkflows, + selectWorkflow, selectDraggingStatus, selectStatusDropCandidate, }) => ({ selectColums: createSelector( - selectWorkflows, + selectWorkflow, selectDraggingStatus, selectStatusDropCandidate, - (workflows, currentStatus, statusDropCandidate) => { - if (!workflows || !workflows.length) { + (workflow, currentStatus, statusDropCandidate) => { + if (!workflow) { return []; } - const workflow = workflows[0]; if (!statusDropCandidate) { return workflow.statuses.map((it) => { diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors.ts index b7e2e81e7..0b73db39a 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors.ts @@ -13,9 +13,9 @@ import { findStory } from '../reducers/kanban.reducer.helpers'; export const { selectKanbanState, - selectLoadingWorkflows, + selectLoadingWorkflow, selectLoadingStories, - selectWorkflows, + selectWorkflow, selectStories, selectCreateStoryForm, selectScrollToStory, @@ -57,11 +57,3 @@ export const selectStory = (ref: number) => { return findStory(state, (it) => it.ref === ref); }); }; - -export const selectCurrentWorkflow = createSelector( - selectWorkflows, - selectCurrentWorkflowSlug, - (wokflows, slug) => { - return wokflows?.find((it) => it.slug === slug); - } -); diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.css b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.css index 86504a3c3..f185acf29 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.css +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.css @@ -14,11 +14,6 @@ Copyright (c) 2023-present Kaleidos INC padding: 0; } -h1 { - margin: 0; - margin-block-end: var(--spacing-20); -} - .empty { border: 1px solid var(--color-gray20); color: var(--color-gray70); @@ -41,3 +36,8 @@ h1 { padding-block-start: var(--spacing-16); padding-inline-start: var(--spacing-16); } + +tg-kanban-header { + margin-block-end: var(--spacing-20); + min-block-size: var(--spacing-40); +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.html b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.html index 260a598d3..661635335 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.html +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.html @@ -18,25 +18,22 @@ (resized)="onResized($event)" class="kanban-wrapper kanban-cdk-area">
-

- {{ t('kanban.title') }} -

+
- - - - + +
{{ t('kanban.empty') }}
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.ts index 8a1779a81..bf89725ac 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/project-feature-kanban.component.ts @@ -6,10 +6,11 @@ * Copyright (c) 2023-present Kaleidos INC */ -import { Location, CommonModule } from '@angular/common'; +import { CommonModule, Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { TranslocoDirective } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { concatLatestFrom } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -21,14 +22,24 @@ import { Permissions, Project, Role, + Status, Story, StoryDetail, StoryView, - WorkflowStatus, - Status, Workflow, + WorkflowStatus, } from '@taiga/data'; -import { combineLatest, filter, map, merge, pairwise, take } from 'rxjs'; +import { ModalComponent } from '@taiga/ui/modal/components'; +import { + combineLatest, + distinctUntilChanged, + filter, + map, + merge, + pairwise, + skip, + take, +} from 'rxjs'; import * as ProjectActions from '~/app/modules/project/data-access/+state/actions/project.actions'; import { selectCurrentProject, @@ -37,9 +48,12 @@ import { import { AppService } from '~/app/services/app.service'; import { PermissionsService } from '~/app/services/permissions.service'; import { WsService } from '~/app/services/ws'; +import { InviteUserModalComponent } from '~/app/shared/invite-user-modal/invite-user-modal.component'; import { PermissionUpdateNotificationService } from '~/app/shared/permission-update-notification/permission-update-notification.service'; +import { ResizedDirective } from '~/app/shared/resize/resize.directive'; import { ResizedEvent } from '~/app/shared/resize/resize.model'; import { RouteHistoryService } from '~/app/shared/route-history/route-history.service'; +import { TitleComponent } from '~/app/shared/title/title.component'; import { filterNil } from '~/app/shared/utils/operators'; import { pick } from '~/app/shared/utils/pick'; import { ProjectFeatureStoryWrapperModalViewComponent } from '../feature-story-wrapper-modal-view/project-feature-story-wrapper-modal-view.component'; @@ -48,6 +62,7 @@ import { selectStory, selectStoryView, } from '../story-detail/data-access/+state/selectors/story-detail.selectors'; +import { KanbanWorkflowComponent } from './components/workflow/kanban-workflow.component'; import { KanbanActions, KanbanEventsActions, @@ -57,20 +72,16 @@ import { kanbanFeature, } from './data-access/+state/reducers/kanban.reducer'; import { - selectLoadingWorkflows, - selectWorkflows, + selectLoadingWorkflow, + selectWorkflow, } from './data-access/+state/selectors/kanban.selectors'; import { KanbanReorderEvent } from './kanban.model'; -import { KanbanWorkflowComponent } from './components/workflow/kanban-workflow.component'; -import { TranslocoDirective } from '@ngneat/transloco'; -import { ModalComponent } from '@taiga/ui/modal/components'; -import { InviteUserModalComponent } from '~/app/shared/invite-user-modal/invite-user-modal.component'; -import { ResizedDirective } from '~/app/shared/resize/resize.directive'; -import { TitleComponent } from '~/app/shared/title/title.component'; +import { KanbanHeaderComponent } from './components/kanban-header/kanban-header.component'; interface ComponentState { - loadingWorkflows: KanbanState['loadingWorkflows']; - workflows: KanbanState['workflows']; + loadingWorkflow: KanbanState['loadingWorkflow']; + workflow: KanbanState['workflow']; + workflows: Workflow[]; invitePeopleModal: boolean; showStoryDetail: boolean; storyView: StoryView; @@ -98,6 +109,7 @@ interface ComponentState { ModalComponent, ProjectFeatureStoryWrapperModalViewComponent, InviteUserModalComponent, + KanbanHeaderComponent, ], }) export class ProjectFeatureKanbanComponent { @@ -109,19 +121,7 @@ export class ProjectFeatureKanbanComponent { public invitePeopleModal = false; public kanbanWidth = 0; - public model$ = this.state.select().pipe( - map((state) => { - const hasStatuses = - state.workflows?.find((workflow) => { - return workflow.statuses.length; - }) ?? true; - - return { - ...state, - isEmpty: !hasStatuses, - }; - }) - ); + public model$ = this.state.select(); public project$ = this.store.select(selectCurrentProject); constructor( @@ -129,8 +129,8 @@ export class ProjectFeatureKanbanComponent { private state: RxState, private wsService: WsService, private permissionService: PermissionsService, - private router: Router, private route: ActivatedRoute, + private router: Router, private appService: AppService, private location: Location, public shortcutsService: ShortcutsService, @@ -145,11 +145,31 @@ export class ProjectFeatureKanbanComponent { void this.router.navigate(['403']); return; } - this.store.dispatch(KanbanActions.initKanban()); + + // Load on init kanban page. Not on every reload + const workflow: Workflow['slug'] = this.route.snapshot.params[ + 'workflow' + ] as Workflow['slug']; + if (workflow) { + this.store.dispatch(KanbanActions.initKanban({ workflow })); + } + + this.route.paramMap + .pipe( + map((params) => { + return params.get('workflow') ?? 'main'; + }), + distinctUntilChanged(), + skip(1) + ) + .subscribe((workflow: Workflow['slug']) => { + this.store.dispatch(KanbanActions.loadWorkflowKanban({ workflow })); + }); + this.checkInviteModalStatus(); this.state.connect( - 'loadingWorkflows', - this.store.select(selectLoadingWorkflows) + 'loadingWorkflow', + this.store.select(selectLoadingWorkflow) ); this.state.connect( @@ -170,6 +190,18 @@ export class ProjectFeatureKanbanComponent { this.store.select(selectStory).pipe(filterNil()) ); + this.state.connect( + 'workflows', + this.store + .select(selectCurrentProject) + .pipe(filterNil()) + .pipe( + map((project) => { + return project.workflows; + }) + ) + ); + this.state.hold( combineLatest([ this.state.select('storyView'), @@ -185,7 +217,7 @@ export class ProjectFeatureKanbanComponent { } ); this.state.connect('storyView', this.store.select(selectStoryView)); - this.state.connect('workflows', this.store.select(selectWorkflows)); + this.state.connect('workflow', this.store.select(selectWorkflow)); this.state.connect( 'columns', this.store.select(kanbanFeature.selectColums) diff --git a/javascript/apps/taiga/src/app/modules/project/feature-kanban/services/a11yDrag.service.ts b/javascript/apps/taiga/src/app/modules/project/feature-kanban/services/a11yDrag.service.ts index 77920bb71..f6c7f816d 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-kanban/services/a11yDrag.service.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-kanban/services/a11yDrag.service.ts @@ -28,6 +28,7 @@ import { KanbanStory, KanbanStoryA11y, } from '~/app/modules/project/feature-kanban/kanban.model'; +import { KanbanState } from '../data-access/+state/reducers/kanban.reducer'; @Injectable({ providedIn: 'root', @@ -166,11 +167,9 @@ export class A11yDragService { this.store .select(selectKanbanState) .pipe(take(1)) - .subscribe((state) => { + .subscribe((state: KanbanState) => { const story = findStory(state, (it) => it.ref === this.storyRef); - const workflow = state.workflows?.find((it) => { - return it.slug === state.currentWorkflowSlug; - }); + const workflow = state.workflow; if (!workflow || !story) { return; diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu-styles.directive.ts b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu-styles.directive.ts new file mode 100644 index 000000000..5e52808b4 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu-styles.directive.ts @@ -0,0 +1,36 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { Directive, ElementRef } from '@angular/core'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[kanbanWorkflowMenuStyles]', + standalone: true, +}) +export class ProjectNavWorkflowMenuStylesDirective { + constructor(private el: ElementRef) { + this.setStyles(); + } + + private setStyles() { + requestAnimationFrame(() => { + const tuiDropDown = (this.el.nativeElement as HTMLElement).closest( + 'tui-dropdown' + ) as HTMLElement; + if (tuiDropDown) { + tuiDropDown.style.setProperty('--tui-radius-m', '0 3px 3px 0'); + tuiDropDown.style.setProperty('--tui-base-04', 'var(--color-gray100)'); + tuiDropDown.style.setProperty( + '--tui-elevation-01', + 'var(--color-gray100)' + ); + } + }); + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.css b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.css index 087198e18..528d1688a 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.css +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.css @@ -9,6 +9,7 @@ Copyright (c) 2023-present Kaleidos INC /* WORKSPACE */ @import url("tools/typography.css"); +@import url("taiga-ui/mixins/wrapper.css"); :host { --menu-size: 200px; @@ -133,7 +134,7 @@ ul { color: var(--color-gray40); display: flex; font-weight: var(--font-weight-regular); - padding: var(--spacing-4); + padding: var(--spacing-8); transition: padding var(--transition); } @@ -141,7 +142,7 @@ ul { aspect-ratio: 1/1; block-size: var(--spacing-24); inline-size: var(--spacing-24); - margin-inline-end: var(--spacing-16); + margin-inline-end: var(--spacing-8); } .menu-option-icon { @@ -152,11 +153,20 @@ ul { @mixin menu-option-item; &:hover, + &.active-section, &.active-dialog { background: var(--color-gray90); - color: var(--color-primary); + color: var(--color-gray20); font-weight: var(--font-weight-regular); } + + &:focus-visible { + outline: solid 2px var(--color-secondary50); + } +} + +.has-add-workflow-button { + position: relative; } .menu-option { @@ -169,125 +179,56 @@ ul { font-weight: var(--font-weight-medium); } } - - & .menu-option-icon { - aspect-ratio: 1/1; - block-size: var(--spacing-24); - inline-size: var(--spacing-24); - margin-inline-end: var(--spacing-16); - } } -.menu-option-scrum { - & .scrum-button { - @mixin menu-option-item; +/* Submenu of a menu option */ - align-items: center; - appearance: none; - background: none; - border: none; - cursor: pointer; - display: flex; - inline-size: 100%; - - &:focus-visible { - outline: solid 2px var(--color-secondary); - } - - &:hover, - &.active-dialog { - background: var(--color-gray90); - color: var(--color-primary); - } +.submenu { + padding-block: var(--spacing-8); +} - & .scrum-button-icon { - @mixin menu-option-icon; - } +.submenu-option { + border-inline-start: 1px solid var(--color-gray90); + margin-block: 0; + margin-inline-end: var(--spacing-8); + margin-inline-start: var(--spacing-16); + padding-block: var(--spacing-4); + padding-inline-start: var(--spacing-8); +} - & .chevron { - block-size: var(--spacing-16); - color: var(--color-primary); - inline-size: var(--spacing-16); - margin-inline-start: auto; - transform: rotate(180deg); - transition: all 0.2s linear; - - &.active { - transform: rotate(0); - transition: all 0.2s linear; - } - } - } +.submenu-option-item { + @mixin menu-option-item; + @mixin ellipsis; - &.scrum-active { - & .scrum-button { - color: var(--color-white); + block-size: var(--spacing-28); + display: inline-block; + inline-size: 100%; - &:hover { - color: var(--color-primary); - } - } + &:hover { + background: var(--color-gray90); + color: var(--color-gray20); } -} - -.menu-child-scrum { - background: var(--color-gray90); - padding: var(--spacing-8); - & .menu-child-option { - &:last-child { - margin-block-end: 0; - } + &:active, + &.submenu-option-item-active { + background: var(--color-secondary90); + color: var(--color-white); } - & .menu-child-option-item { - @mixin menu-option-item; - - padding-inline-start: var(--spacing-16); - - &:hover, - &.active { - color: var(--color-primary); - } - - &:hover { - background: var(--color-gray80); - } - - &.active { - background: var(--color-secondary80); - } + &:focus-visible { + outline: solid 2px var(--color-secondary50); } } -.secondary-menu { - & .menu-option-item { - &:hover { - color: var(--color-white); - - & .arrow { - opacity: 1; - } - } - - &:focus { - color: var(--color-gray40); - - & .arrow { - opacity: 1; - } - } - } - - & .arrow { - block-size: 1rem; - color: var(--color-gray60); - inline-size: 1rem; - margin-inline-start: auto; - opacity: 0; - } +/* Create workflow button */ +.create-workflow { + inset-block-start: var(--spacing-4); + inset-inline-end: var(--spacing-4); + position: absolute; } +/* Bottom area */ + .bottom-menu { & .project-settings { background: transparent; @@ -378,8 +319,7 @@ ul { } & .menu-option-item, - & .bottom-menu-option-item, - & .scrum-button { + & .bottom-menu-option-item { padding-inline-start: var(--spacing-4); transition: padding var(--transition); transition-delay: var(--transition-delay); @@ -389,21 +329,26 @@ ul { color: var(--color-white); inline-size: var(--inline-btn-size); } + + &.collapsed-kanban-button { + appearance: none; + background: none; + border: 0; + padding-inline: var(--spacing-4); + + &:hover { + cursor: pointer; + inline-size: auto; + } + } } - & .menu-option-icon, - & .scrum-button-icon { + & .menu-option-icon { margin-inline-end: 0; transition: margin var(--transition); transition-delay: var(--transition-delay); } - & .menu-option-scrum { - & .scrum-button-icon { - margin-inline-end: 0; - } - } - & .button-collapse { justify-content: start; padding: 0; @@ -420,40 +365,25 @@ ul { /* FLOATING DIALOG */ -.dialog-scrum { - box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.3); - - & .child-menu-option { - margin-block-end: 0; - } +.dialog-kanban { + background: var(--color-gray100); + color: var(--color-white); + inline-size: 170px; - & .child-menu-option-item { + & .dialog-kanban-title { + align-items: center; block-size: var(--spacing-32); - border-radius: 3px; - display: block; - font-weight: var(--font-weight-regular); - padding: var(--spacing-8); - - &:hover { - background: var(--color-gray80); - color: var(--color-primary); - } - - &.active { - background: var(--color-secondary80); - } - } - - & .child-menu-option-scrum { - block-size: auto; color: var(--color-white); - display: block; - margin-block-end: var(--spacing-8); - padding: 0; + display: flex; + padding-block: 0; + padding-inline: var(--spacing-12) var(--spacing-2); - &:hover { - background: none; - color: var(--color-white); + & .dialog-kanban-text { + @mixin ellipsis; + + font-size: var(--font-size-medium); + font-weight: var(--font-weight-regular); + text-decoration: none; } } } @@ -502,8 +432,14 @@ ul { padding-inline-start: var(--spacing-24); } - & .dialog-scrum { - color: var(--color-gray-40); - padding: var(--spacing-8); + & .dialog-kanban { + & .submenu { + background: var(--color-gray100); + margin: 0; + } + + & .submenu-option { + margin-inline-start: var(--spacing-4); + } } } diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.html b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.html index 862f63a37..5fec153db 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.html +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.html @@ -100,40 +100,190 @@ - +
+ +
+ + +
+
+ + + (pointerenter)="popup($event, 'kanban')" + (pointerleave)="out()"> + + + + {{ t('commons.kanban') }} + + + + + + +
@@ -201,18 +351,18 @@ [style.top.px]="dialog.top" [style.left.px]="dialog.left"> {{ dialog.text }} + + +
+ + + diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.spec.ts b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.spec.ts index a9852c3b7..c80a3b71a 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.spec.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.spec.ts @@ -42,11 +42,6 @@ describe('ProjectNavigationComponent', () => { spectator.component.project = ProjectMockFactory(); }); - it('default', () => { - expect(spectator.component.collapseText).toEqual(true); - expect(spectator.component.scrumChildMenuVisible).toEqual(false); - }); - it('Collapsed icon - uncollapsed', () => { spectator.component.collapsed = false; expect(spectator.component.getCollapseIcon()).toEqual('collapse-left'); @@ -57,13 +52,6 @@ describe('ProjectNavigationComponent', () => { expect(spectator.component.getCollapseIcon()).toEqual('collapse-right'); }); - it('Toggle scrum child menu - uncollapsed', () => { - spectator.component.collapsed = false; - spectator.component.scrumChildMenuVisible = true; - spectator.component.toggleScrumChildMenu(); - expect(spectator.component.scrumChildMenuVisible).toBeFalsy(); - }); - it('Popup dialog event', () => { spectator.component.collapsed = true; spectator.component.initDialog = jest.fn(); @@ -79,21 +67,6 @@ describe('ProjectNavigationComponent', () => { ); }); - it('Popup scrum event', () => { - spectator.component.collapsed = true; - spectator.component.initDialog = jest.fn(); - - const type = 'scrum'; - const eventObj: any = { target: { value: 42 } }; - - spectator.component.popupScrum(eventObj); - - expect(spectator.component.initDialog).toHaveBeenCalledWith( - eventObj.target, - type - ); - }); - it('Enter dialog', () => { spectator.component.enterDialog(); diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.ts index 507c60311..66b24fe5e 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.component.ts @@ -21,11 +21,18 @@ import { RouterModule } from '@angular/router'; import { TranslocoDirective } from '@ngneat/transloco'; import { UntilDestroy } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; -import { TuiSvgModule } from '@taiga-ui/core'; -import { Project } from '@taiga/data'; +import { + TuiButtonModule, + TuiHostedDropdownModule, + TuiSvgModule, +} from '@taiga-ui/core'; +import { Project, Workflow } from '@taiga/data'; import { AvatarComponent } from '@taiga/ui/avatar/avatar.component'; - +import { TooltipDirective } from '@taiga/ui/tooltip'; +import { TooltipComponent } from '@taiga/ui/tooltip/tooltip.component'; import { HasPermissionDirective } from '~/app/shared/directives/has-permissions/has-permission.directive'; +import { ProjectNavWorkflowMenuStylesDirective } from './project-navigation-menu-styles.directive'; +import { ProjectNavWorkflowMenuPositionDirective } from './project-navigation-menu.directive'; interface ProjectMenuDialog { hover: boolean; @@ -39,7 +46,7 @@ interface ProjectMenuDialog { mainLinkHeight: number; children: { text: string; - link: string[]; + link: string; }[]; } const cssValue = getComputedStyle(document.documentElement); @@ -57,6 +64,12 @@ const cssValue = getComputedStyle(document.documentElement); AvatarComponent, TranslocoDirective, CommonModule, + TooltipComponent, + TooltipDirective, + TuiButtonModule, + TuiHostedDropdownModule, + ProjectNavWorkflowMenuPositionDirective, + ProjectNavWorkflowMenuStylesDirective, ], animations: [ trigger('blockInitialRenderAnimation', [transition(':enter', [])]), @@ -100,7 +113,8 @@ export class ProjectNavigationMenuComponent { public projectSettingButton!: ElementRef; public collapseText = true; - public scrumChildMenuVisible = false; + public activeSection = false; + public openworkflowsDropdown = false; public dialog: ProjectMenuDialog = { open: false, @@ -133,7 +147,7 @@ export class ProjectNavigationMenuComponent { if (text) { const navigationBarWidth = 48; - if (type !== 'scrum' && el.querySelector('a')) { + if (el.querySelector('a')) { this.dialog.link = el.querySelector('a')!.getAttribute('href') ?? ''; } this.dialog.hover = false; @@ -185,32 +199,12 @@ export class ProjectNavigationMenuComponent { this.out(); } - public popupScrum(event: MouseEvent | FocusEvent) { - if (!this.collapsed) { - return; - } - - this.initDialog(event.target as HTMLElement, 'scrum' /* children */); - } - - public toggleScrumChildMenu() { - if (this.collapsed) { - (this.backlogSubmenuEl.nativeElement as HTMLElement).focus(); - } else { - this.scrumChildMenuVisible = !this.scrumChildMenuVisible; - } - } - public getCollapseIcon() { return this.collapsed ? 'collapse-right' : 'collapse-left'; } public toggleCollapse() { this.collapseMenu.next(); - - if (this.collapsed) { - this.scrumChildMenuVisible = false; - } } public openSettings() { @@ -218,4 +212,8 @@ export class ProjectNavigationMenuComponent { this.dialog.open = false; this.dialog.type = ''; } + + public trackById(_index: number, workflow: Workflow) { + return workflow.id; + } } diff --git a/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.directive.ts b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.directive.ts new file mode 100644 index 000000000..0d51c9896 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-navigation/components/project-navigation-menu/project-navigation-menu.directive.ts @@ -0,0 +1,38 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { Directive, ElementRef, Inject } from '@angular/core'; +import { + TuiPoint, + TuiPositionAccessor, + tuiAsPositionAccessor, +} from '@taiga-ui/core'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[kanbanWorkflowMenuPosition]', + providers: [tuiAsPositionAccessor(ProjectNavWorkflowMenuPositionDirective)], + standalone: true, +}) +export class ProjectNavWorkflowMenuPositionDirective extends TuiPositionAccessor { + public readonly type = 'dropdown'; + + constructor( + @Inject(ElementRef) private readonly el: ElementRef + ) { + super(); + } + + public getPosition(): TuiPoint { + const { right, top } = this.el.nativeElement.getBoundingClientRect(); + + const spacing = 4; //4px + + return [top + spacing, right + spacing * 2]; + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.css b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.css new file mode 100644 index 000000000..d6ee9fec4 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.css @@ -0,0 +1,27 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +:host { + background: var(--color-white); + border: 1px solid var(--color-gray30); + border-radius: 3px; + box-shadow: 3px 4px 14px 0 rgba(0, 138, 168, 0.15); + display: block; + inline-size: 100%; + max-inline-size: 586px; + padding: var(--spacing-16); +} + +.new-workflow-form-card { + display: flex; + gap: var(--spacing-8); +} + +.input-wrapper { + flex: 1; +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.html b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.html new file mode 100644 index 000000000..f4d2d2767 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.html @@ -0,0 +1,59 @@ + + + +
+
+ + + + + {{ t('kanban.create_workflow.workflow_empty') }} + + + +
+ {{ t('form_errors.max_length') }} +
+
+ + + +
+
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.ts new file mode 100644 index 000000000..51e31c41f --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-form/new-workflow-form.component.ts @@ -0,0 +1,92 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { TranslocoDirective } from '@ngneat/transloco'; +import { TuiButtonModule } from '@taiga-ui/core'; +import { Workflow } from '@taiga/data'; +import { InputsModule } from '@taiga/ui/inputs'; +import { AutoFocusDirective } from '~/app/shared/directives/auto-focus/auto-focus.directive'; + +@Component({ + selector: 'tg-new-workflow-form', + templateUrl: './new-workflow-form.component.html', + styleUrls: ['./new-workflow-form.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TranslocoDirective, + ReactiveFormsModule, + InputsModule, + TuiButtonModule, + AutoFocusDirective, + ], +}) +export class NewWorkflowFormComponent implements OnInit { + @Output() + public createWorkflow = new EventEmitter(); + + @Output() + public cancelCreateWorkflow = new EventEmitter(); + + @Input() public workflow: Workflow | null = null; + + public newWorkflowForm!: FormGroup; + + public workflowNameMaxLength = 40; + public submitted = false; + + constructor(private fb: FormBuilder) {} + + public ngOnInit() { + this.initForm(); + } + + public initForm() { + this.newWorkflowForm = this.fb.group({ + name: [ + this.workflow?.name || '', + [ + Validators.required, + Validators.maxLength(this.workflowNameMaxLength), + //avoid only white spaces + Validators.pattern(/^(\s+\S+\s*)*(?!\s).*$/), + ], + ], + }); + } + + public submitCreateWorkflow() { + this.newWorkflowForm.markAllAsTouched(); + this.submitted = true; + if (this.newWorkflowForm.valid) { + const workflowName = this.newWorkflowForm.get('name') + ?.value as Workflow['name']; + this.createWorkflow.emit(workflowName); + } + } + + public cancelEdit() { + this.cancelCreateWorkflow.emit(); + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.css b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.css new file mode 100644 index 000000000..41784eae3 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.css @@ -0,0 +1,52 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +:host { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + inline-size: 288px; + padding: var(--spacing-16); +} + +.skeleton-text { + border-radius: 8px; +} + +.skeleton-header { + display: flex; + gap: var(--spacing-12); + padding-block-end: var(--spacing-16); + + & :is(.skeleton-text, .skeleton-avatar) { + background-color: var(--color-gray30); + } + + & .skeleton-avatar-end { + margin-inline-start: auto; + } +} + +.skeleton-cards { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.skeleton-card { + background-color: var(--color-white); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16); + + & .skeleton-text { + background-color: var(--color-gray20); + block-size: var(--spacing-12); + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.html b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.html new file mode 100644 index 000000000..2fe73b04a --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.html @@ -0,0 +1,22 @@ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.ts new file mode 100644 index 000000000..43dfabcf2 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/components/new-workflow-skeleton/new-workflow-skeleton.component.ts @@ -0,0 +1,18 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { Component } from '@angular/core'; + +@Component({ + standalone: true, + imports: [], + selector: 'tg-new-workflow-skeleton', + templateUrl: 'new-workflow-skeleton.component.html', + styleUrls: ['./new-workflow-skeleton.component.css'], +}) +export class NewWorkflowSkeletonComponent {} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow-routing.module.ts b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow-routing.module.ts new file mode 100644 index 000000000..a84be6f30 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow-routing.module.ts @@ -0,0 +1,21 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ProjectFeatureNewWorkflowComponent } from './project-feature-new-workflow.component'; + +const routes: Routes = [ + { path: '', component: ProjectFeatureNewWorkflowComponent }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ProjectFeatureNewWorkflowRoutingModule {} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.css b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.css new file mode 100644 index 000000000..5d6923404 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.css @@ -0,0 +1,22 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +:host { + background-color: var(--color-gray10); + block-size: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-16); + padding: var(--spacing-16); +} + +.skeleton-wrapper { + display: flex; + flex: 1; + gap: var(--spacing-8); +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.html b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.html new file mode 100644 index 000000000..6bd6565c4 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.html @@ -0,0 +1,18 @@ + + + +
+ + + + +
diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.ts new file mode 100644 index 000000000..4c7b80486 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.component.ts @@ -0,0 +1,74 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { RxState } from '@rx-angular/state'; +import { Project, Workflow } from '@taiga/data'; +import { RouteHistoryService } from '~/app/shared/route-history/route-history.service'; +import { filterNil } from '~/app/shared/utils/operators'; +import { + createWorkflow, + projectApiActions, +} from '../data-access/+state/actions/project.actions'; +import { selectCurrentProject } from '../data-access/+state/selectors/project.selectors'; +import { NewWorkflowFormComponent } from './components/new-workflow-form/new-workflow-form.component'; +import { NewWorkflowSkeletonComponent } from './components/new-workflow-skeleton/new-workflow-skeleton.component'; +@UntilDestroy() +@Component({ + selector: 'tg-project-feature-new-workflow', + templateUrl: './project-feature-new-workflow.component.html', + styleUrls: ['./project-feature-new-workflow.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NewWorkflowFormComponent, NewWorkflowSkeletonComponent], +}) +export class ProjectFeatureNewWorkflowComponent { + constructor( + private state: RxState<{ + project: Project; + }>, + private store: Store, + private router: Router, + private actions$: Actions, + private routeHistoryService: RouteHistoryService + ) { + this.state.connect( + 'project', + this.store.select(selectCurrentProject).pipe(filterNil()) + ); + this.actions$ + .pipe(ofType(projectApiActions.createWorkflowError), untilDestroyed(this)) + .subscribe(() => { + this.cancelCreateWorkflow(); + }); + } + + public createWorkflow(workflow: Workflow['name']) { + this.store.dispatch( + createWorkflow({ + name: workflow, + }) + ); + } + + public cancelCreateWorkflow() { + void this.router.navigate(this.getPreviousUrl); + } + + public get getPreviousUrl(): string[] { + const previousUrl = this.routeHistoryService.getPreviousUrl(); + const project = this.state.get('project'); + return previousUrl + ? [previousUrl] + : [`/project/${project.id}${project.slug}/overview`]; + } +} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.module.ts b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.module.ts new file mode 100644 index 000000000..5b7218c87 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/project/feature-new-workflow/project-feature-new-workflow.module.ts @@ -0,0 +1,25 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { TRANSLOCO_SCOPE } from '@ngneat/transloco'; +import { ProjectFeatureNewWorkflowRoutingModule } from './project-feature-new-workflow-routing.module'; + +@NgModule({ + imports: [CommonModule, ProjectFeatureNewWorkflowRoutingModule], + declarations: [], + providers: [ + { + provide: TRANSLOCO_SCOPE, + useValue: 'kanban', + }, + ], + exports: [], +}) +export class ProjectFeatureNewWorkflowModule {} diff --git a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-routing.module.ts b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-routing.module.ts index 0faff9bdd..5236a4f4d 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-routing.module.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-routing.module.ts @@ -8,10 +8,10 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { CanDeactivateGuard } from '~/app/shared/can-deactivate/can-deactivate.guard'; import { ProjectAdminResolver } from './project-admin.resolver.service'; import { ProjectFeatureShellResolverService } from './project-feature-shell-resolver.service'; import { ProjectFeatureShellComponent } from './project-feature-shell.component'; -import { CanDeactivateGuard } from '~/app/shared/can-deactivate/can-deactivate.guard'; const routes: Routes = [ { @@ -22,18 +22,65 @@ const routes: Routes = [ }, children: [ { - path: ':slug/kanban', + path: '', + loadChildren: () => + import( + '~/app/modules/project/feature-overview/project-feature-overview.module' + ).then((m) => m.ProjectFeatureOverviewModule), + }, + { + path: 'overview', + loadChildren: () => + import( + '~/app/modules/project/feature-overview/project-feature-overview.module' + ).then((m) => m.ProjectFeatureOverviewModule), + data: { + overview: true, + }, + }, + { + path: ':slug/overview', + loadChildren: () => + import( + '~/app/modules/project/feature-overview/project-feature-overview.module' + ).then((m) => m.ProjectFeatureOverviewModule), + data: { + overview: true, + }, + }, + { + path: 'kanban', loadChildren: () => import( '~/app/modules/project/feature-view-setter/project-feature-view-setter.module' ).then((m) => m.ProjectFeatureViewSetterModule), canDeactivate: [CanDeactivateGuard], + }, + { + path: ':slug', + loadChildren: () => + import( + '~/app/modules/project/feature-overview/project-feature-overview.module' + ).then((m) => m.ProjectFeatureOverviewModule), + }, + { + path: ':slug/kanban', + redirectTo: ':slug/kanban/main', + pathMatch: 'full', + }, + { + path: ':slug/new-workflow', + loadChildren: () => + import( + '~/app/modules/project/feature-new-workflow/project-feature-new-workflow.module' + ).then((m) => m.ProjectFeatureNewWorkflowModule), + canDeactivate: [CanDeactivateGuard], data: { - kanban: true, + newKanban: true, }, }, { - path: 'kanban', + path: ':slug/kanban/:workflow', loadChildren: () => import( '~/app/modules/project/feature-view-setter/project-feature-view-setter.module' @@ -41,21 +88,22 @@ const routes: Routes = [ canDeactivate: [CanDeactivateGuard], data: { kanban: true, + reuseComponent: false, }, }, { - path: ':slug/stories/:storyRef', + path: 'stories/:storyRef', loadChildren: () => import( '~/app/modules/project/feature-view-setter/project-feature-view-setter.module' ).then((m) => m.ProjectFeatureViewSetterModule), canDeactivate: [CanDeactivateGuard], data: { - stories: true, + kanban: true, }, }, { - path: 'stories/:storyRef', + path: ':slug/stories/:storyRef', loadChildren: () => import( '~/app/modules/project/feature-view-setter/project-feature-view-setter.module' @@ -66,7 +114,7 @@ const routes: Routes = [ }, }, { - path: ':slug/settings', + path: 'settings', loadChildren: () => import( '~/app/modules/project/settings/feature-settings/feature-settings.module' @@ -79,7 +127,7 @@ const routes: Routes = [ }, }, { - path: 'settings', + path: ':slug/settings', loadChildren: () => import( '~/app/modules/project/settings/feature-settings/feature-settings.module' @@ -91,26 +139,6 @@ const routes: Routes = [ project: ProjectAdminResolver, }, }, - { - path: 'overview', - loadChildren: () => - import( - '~/app/modules/project/feature-overview/project-feature-overview.module' - ).then((m) => m.ProjectFeatureOverviewModule), - data: { - overview: true, - }, - }, - { - path: ':slug/overview', - loadChildren: () => - import( - '~/app/modules/project/feature-overview/project-feature-overview.module' - ).then((m) => m.ProjectFeatureOverviewModule), - data: { - overview: true, - }, - }, ], }, ]; diff --git a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts index a54d0fd27..fe114f866 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts @@ -15,15 +15,15 @@ import { OnDestroy, } from '@angular/core'; import { - Router, - RouterOutlet, ActivatedRoute, ActivatedRouteSnapshot, + Router, + RouterOutlet, } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { RxState } from '@rx-angular/state'; -import { TuiNotification, TuiButtonModule } from '@taiga-ui/core'; +import { TuiButtonModule, TuiNotification } from '@taiga-ui/core'; import { Attachment, Membership, @@ -54,9 +54,9 @@ import { } from '../data-access/+state/actions/project.actions'; import { setNotificationClosed } from '../feature-overview/data-access/+state/actions/project-overview.actions'; -import { ProjectNavigationComponent } from '../feature-navigation/project-feature-navigation.component'; import { TranslocoDirective } from '@ngneat/transloco'; import { ContextNotificationComponent } from '@taiga/ui/context-notification/context-notification.component'; +import { ProjectNavigationComponent } from '../feature-navigation/project-feature-navigation.component'; @UntilDestroy() @Component({ @@ -153,12 +153,18 @@ export class ProjectFeatureShellComponent implements OnDestroy, AfterViewInit { active.routeConfig?.component?.name === 'ProjectFeatureOverviewComponent'; const isSettings = !!active.data.settings; + const isNewKanban = !!active.data.newKanban; if (isKanban) { void this.router.navigate( [`project/${project.id}/${project.slug}/kanban`], { replaceUrl: true } ); + } else if (isNewKanban) { + void this.router.navigate( + [`project/${project.id}/${project.slug}/new-workflow`], + { replaceUrl: true } + ); } else if (isOverview) { void this.router.navigate( [`project/${project.id}/${project.slug}/overview`], @@ -263,6 +269,15 @@ export class ProjectFeatureShellComponent implements OnDestroy, AfterViewInit { ); }); + this.wsService + .projectEvents<{ workflow: Workflow }>('workflows.create') + .pipe(untilDestroyed(this)) + .subscribe((eventResponse) => { + this.store.dispatch( + projectEventActions.createWorkflow(eventResponse.event.content) + ); + }); + this.wsService .projectEvents<{ story: StoryDetail }>('stories.update') .pipe(untilDestroyed(this)) 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 2cf1d00e4..6e7eb1839 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 @@ -41,7 +41,7 @@ import { } from '@angular/router'; import { RxState } from '@rx-angular/state'; import { CommonModule } from '@angular/common'; -import { StoryDetail, StoryView, Project, Story } from '@taiga/data'; +import { StoryDetail, StoryView, Project, Story, Workflow } from '@taiga/data'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; interface ProjectFeatureViewSetterComponentState { @@ -99,11 +99,6 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { ) ); - this.state.connect( - 'isKanban', - this.state.select('url').pipe(map((url) => url.endsWith('/kanban'))) - ); - this.state.hold( this.state.select('kanbanHost').pipe(distinctUntilChanged(), filterNil()), (host) => { @@ -128,7 +123,21 @@ export class ProjectFeatureViewSetterComponent implements OnDestroy { ]) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(([url, data, params]) => { - if (!url.endsWith('/kanban') && !!data.stories) { + this.state.connect( + 'isKanban', + this.state + .select('url') + .pipe( + map((url) => + url.endsWith(`/kanban/${params.workflow as Workflow['slug']}`) + ) + ) + ); + + if ( + !url.endsWith(`/kanban/${params.workflow as Workflow['slug']}`) && + !!data.stories + ) { const storyParams = params as StoryParams; const needRedirect = params.slug !== (data.project as Project).slug; if (needRedirect) { diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/components/story-detail-assign/story-detail-assign.component.css b/javascript/apps/taiga/src/app/modules/project/story-detail/components/story-detail-assign/story-detail-assign.component.css index c9f98f780..b013ee5cf 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/components/story-detail-assign/story-detail-assign.component.css +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/components/story-detail-assign/story-detail-assign.component.css @@ -20,7 +20,6 @@ Copyright (c) 2023-present Kaleidos INC --hover-bg-color: var(--color-gray20); inline-size: 100%; - margin-block-start: var(--spacing-16); max-inline-size: 450px; & .add-assignee { 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 4e389497c..324d5a0c2 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 @@ -22,7 +22,7 @@ import { import { cold, hot } from 'jest-marbles'; import { Observable } from 'rxjs'; import { selectCurrentProject } from '~/app/modules/project/data-access/+state/selectors/project.selectors'; -import { selectWorkflows } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; import { AppService } from '~/app/services/app.service'; import { LocalStorageService } from '~/app/shared/local-storage/local-storage.service'; import { getTranslocoModule } from '~/app/transloco/transloco-testing.module'; @@ -35,7 +35,6 @@ import { selectWorkflow, } from '../selectors/story-detail.selectors'; import { StoryDetailEffects } from './story-detail.effects'; -import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; describe('StoryDetailEffects', () => { let actions$: Observable; @@ -78,55 +77,23 @@ describe('StoryDetailEffects', () => { expect(effects.loadStoryDetail$).toBeObservable(expected); }); - describe('loadWorkflow', () => { + describe.skip('loadWorkflow', () => { it('kanban workflow', () => { - const project = ProjectMockFactory(); - const workflows = [ - WorkflowMockFactory(), - WorkflowMockFactory(), - WorkflowMockFactory(), - ]; const effects = spectator.inject(StoryDetailEffects); - const story = StoryDetailMockFactory(); - - story.workflow = { - slug: workflows[0].slug, - name: workflows[0].name, - }; - - store.overrideSelector(selectCurrentProject, project); - store.overrideSelector(selectWorkflows, workflows); - store.overrideSelector(selectWorkflow, null); - - actions$ = hot('-a', { - a: StoryDetailApiActions.fetchStorySuccess({ - story, - }), - }); - - const expected = cold('-a', { - a: StoryDetailApiActions.fetchWorkflowSuccess({ - workflow: workflows[0], - }), - }); - expect(effects.loadWorkflow$).toBeObservable(expected); - }); - - it('previously loaded workflow', () => { const project = ProjectMockFactory(); const workflow = WorkflowMockFactory(); - const effects = spectator.inject(StoryDetailEffects); const story = StoryDetailMockFactory(); + project.workflows.push(workflow); + story.workflow = { slug: workflow.slug, name: workflow.name, }; store.overrideSelector(selectCurrentProject, project); - store.overrideSelector(selectWorkflows, []); - store.overrideSelector(selectWorkflow, workflow); + store.overrideSelector(selectWorkflow, null); actions$ = hot('-a', { a: StoryDetailApiActions.fetchStorySuccess({ @@ -136,7 +103,7 @@ describe('StoryDetailEffects', () => { const expected = cold('-a', { a: StoryDetailApiActions.fetchWorkflowSuccess({ - workflow: workflow, + workflow, }), }); @@ -146,11 +113,6 @@ describe('StoryDetailEffects', () => { it('request workflow', () => { const projectApiService = spectator.inject(ProjectApiService); const project = ProjectMockFactory(); - const workflows = [ - WorkflowMockFactory(), - WorkflowMockFactory(), - WorkflowMockFactory(), - ]; const workflow = WorkflowMockFactory(); const storyWorkflow = WorkflowMockFactory(); const effects = spectator.inject(StoryDetailEffects); @@ -162,7 +124,6 @@ describe('StoryDetailEffects', () => { }; store.overrideSelector(selectCurrentProject, project); - store.overrideSelector(selectWorkflows, workflows); store.overrideSelector(selectWorkflow, workflow); projectApiService.getWorkflow.mockReturnValue( @@ -345,7 +306,7 @@ describe('StoryDetailEffects', () => { }); }); - it('should dispatch newStatusOrderAfterDrag action when statusDropped is dispatched and workflow exists', () => { + it.skip('should dispatch newStatusOrderAfterDrag action when statusDropped is dispatched and workflow exists', () => { const effects = spectator.inject(StoryDetailEffects); const status = StatusMockFactory(); @@ -368,8 +329,7 @@ describe('StoryDetailEffects', () => { b: StoryDetailActions.newStatusOrderAfterDrag({ workflow }), }); - store.overrideSelector(selectWorkflows, [workflow]); - store.refreshState(); + store.overrideSelector(selectWorkflow, workflow); expect(effects.updatesWorkflowStatusAfterDragAndDrop$).toBeObservable( expected 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 b7f48de94..065a8d3c8 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 @@ -11,13 +11,15 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { TranslocoService } from '@ngneat/transloco'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; 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 { catchError, concatMap, EMPTY, map, of, tap } from 'rxjs'; +import { EMPTY, catchError, concatMap, map, of, 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 { selectWorkflows } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; +import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; +import { selectWorkflow } from '~/app/modules/project/feature-kanban/data-access/+state/selectors/kanban.selectors'; import { AppService } from '~/app/services/app.service'; import { LocalStorageService } from '~/app/shared/local-storage/local-storage.service'; import { filterNil } from '~/app/shared/utils/operators'; @@ -25,12 +27,7 @@ import { StoryDetailActions, StoryDetailApiActions, } from '../actions/story-detail.actions'; -import { - selectStory, - selectWorkflow, -} from '../selectors/story-detail.selectors'; -import { KanbanActions } from '~/app/modules/project/feature-kanban/data-access/+state/actions/kanban.actions'; -import { projectEventActions } from '~/app/modules/project/data-access/+state/actions/project.actions'; +import { selectStory } from '../selectors/story-detail.selectors'; @Injectable() export class StoryDetailEffects { @@ -59,17 +56,15 @@ export class StoryDetailEffects { return this.actions$.pipe( ofType(StoryDetailApiActions.fetchStorySuccess), concatLatestFrom(() => [ - this.store.select(selectWorkflows), - this.store.select(selectWorkflow), + this.store.select(selectWorkflow).pipe(filterNil()), this.store.select(selectCurrentProject).pipe(filterNil()), ]), - concatMap(([action, workflows, loadedWorkflow, project]) => { + concatMap(([action, loadedWorkflow, project]) => { const workflow = loadedWorkflow ?? - workflows?.find( + project.workflows?.find( (workflow) => workflow.slug === action.story.workflow.slug ); - if (workflow?.slug === action.story.workflow.slug) { return of( StoryDetailApiActions.fetchWorkflowSuccess({ @@ -77,7 +72,6 @@ export class StoryDetailEffects { }) ); } - return this.projectApiService .getWorkflow(project?.id, action.story.workflow.slug) .pipe( @@ -261,12 +255,8 @@ export class StoryDetailEffects { public updatesWorkflowStatusAfterDragAndDrop$ = createEffect(() => { return this.actions$.pipe( ofType(KanbanActions.statusDropped, projectEventActions.statusReorder), - concatLatestFrom(() => [this.store.select(selectWorkflows)]), - map(([action, workflows]) => { - const workflow = workflows?.find((workflow) => - workflow.statuses.find((status) => status.id === action.candidate?.id) - ); - + concatLatestFrom(() => this.store.select(selectWorkflow)), + map(([, workflow]) => { if (workflow) { return StoryDetailActions.newStatusOrderAfterDrag({ workflow, diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.css b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.css index d4f1848eb..ed07386e7 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.css +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.css @@ -136,6 +136,12 @@ Copyright (c) 2023-present Kaleidos INC --no-user-avatar-size: 24px; } +.story-breadcrumb-modal, +tg-story-detail-status, +tg-story-detail-assign { + margin-block-end: var(--spacing-16); +} + /* stylelint-disable selector-max-compound-selectors, selector-max-type */ .field-edit { & tg-story-detail-status, diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.html b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.html index 5db79a4ef..7e54bd243 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.html +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.html @@ -78,12 +78,6 @@
- - + + + + diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.ts index b5a23315d..576437de9 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/story-detail.component.ts @@ -26,10 +26,10 @@ import { Store } from '@ngrx/store'; import { RxState } from '@rx-angular/state'; import { TuiButtonComponent, + TuiButtonModule, TuiNotification, TuiScrollbarComponent, TuiSvgModule, - TuiButtonModule, } from '@taiga-ui/core'; import { Attachment, @@ -48,6 +48,10 @@ import { } from '@taiga/data'; import { map, merge, pairwise, startWith, take } from 'rxjs'; +import { TuiScrollbarModule } from '@taiga-ui/core/components/scrollbar'; +import { BreadcrumbComponent } from '@taiga/ui/breadcrumb/breadcrumb.component'; +import { InputsModule } from '@taiga/ui/inputs'; +import { ModalComponent } from '@taiga/ui/modal/components'; import { v4 } from 'uuid'; import { selectUser } from '~/app/modules/auth/data-access/+state/selectors/auth.selectors'; import { selectCurrentProject } from '~/app/modules/project/data-access/+state/selectors/project.selectors'; @@ -80,9 +84,6 @@ import { StoryDetailAssignComponent } from './components/story-detail-assign/sto import { StoryDetailStatusComponent } from './components/story-detail-status/story-detail-status.component'; import { StoryDetailTitleComponent } from './components/story-detail-title/story-detail-title.component'; import { StoryCommentsPaginationDirective } from './directives/story-comments-pagination.directive'; -import { TuiScrollbarModule } from '@taiga-ui/core/components/scrollbar'; -import { InputsModule } from '@taiga/ui/inputs'; -import { ModalComponent } from '@taiga/ui/modal/components'; import { AttachmentsComponent } from '~/app/shared/attachments/attachments.component'; import { CommentsAutoScrollDirective } from '~/app/shared/comments/directives/comments-auto-scroll.directive'; import { DiscardChangesModalComponent } from '~/app/shared/discard-changes-modal/discard-changes-modal.component'; @@ -165,6 +166,7 @@ export interface StoryDetailForm { DiscardChangesModalComponent, DatePipe, DateDistancePipe, + BreadcrumbComponent, ], }) export class StoryDetailComponent { @@ -187,7 +189,7 @@ export class StoryDetailComponent { // taiga ui in the modal has a focus trap that makes the focus on the element, so we need to delay the focus one tick requestAnimationFrame(() => { - this.setInitilFocus(); + this.setInitialFocus(); }); } } @@ -428,7 +430,7 @@ export class StoryDetailComponent { void this.router.navigateByUrl( `project/${this.state.get('project').id}/${ this.state.get('project').slug - }/kanban` + }/kanban/${this.state.get('story').workflow.slug}` ); if (ref) { requestAnimationFrame(() => { @@ -443,7 +445,7 @@ export class StoryDetailComponent { } } - public setInitilFocus() { + public setInitialFocus() { const locationState = this.location.getState() as null | { nextStoryNavigation?: boolean; previousStoryNavigation?: boolean; diff --git a/javascript/apps/taiga/src/app/styles/shared/button.css b/javascript/apps/taiga/src/app/styles/shared/button.css index 97e7fe5c2..5033bf3a0 100644 --- a/javascript/apps/taiga/src/app/styles/shared/button.css +++ b/javascript/apps/taiga/src/app/styles/shared/button.css @@ -419,6 +419,7 @@ Used only in comment list sort button & [tuiWrapper] { background: none; + border-radius: 2px; color: var(--color-gray60); font-weight: var(--font-weight-medium); padding: var(--spacing-4) !important; @@ -430,12 +431,37 @@ Used only in comment list sort button } @mixin wrapper-focus { + --tui-focus: var(--color-secondary); + + color: var(--color-secondary); + &::after { - border-color: var(--color-secondary); border-width: 1px; } } } + + &[variant="dark"] { + & [tuiWrapper] { + color: var(--color-gray40); + + @mixin wrapper-hover { + background: var(--color-gray80); + color: var(--color-secondary40); + } + + @mixin wrapper-focus { + --tui-focus: var(--color-secondary50); + + color: var(--color-secondary50); + + /* stylelint-disable-next-line max-nesting-depth */ + &::after { + border-width: 2px; + } + } + } + } } [data-appearance="action-button-2"]:is(button, a) { diff --git a/javascript/apps/taiga/src/app/styles/taiga-ui/tui-dropdown.css b/javascript/apps/taiga/src/app/styles/taiga-ui/tui-dropdown.css index 00a94b676..feec438b5 100644 --- a/javascript/apps/taiga/src/app/styles/taiga-ui/tui-dropdown.css +++ b/javascript/apps/taiga/src/app/styles/taiga-ui/tui-dropdown.css @@ -9,12 +9,19 @@ Copyright (c) 2023-present Kaleidos INC tui-dropdown { --tui-radius-m: 3px; + --tui-base-04: var(--color-gray50); + --tui-elevation-01: var(--color-white); - border: 1px solid var(--color-gray50) !important; box-shadow: 0.25rem 0.25rem 0.5rem 0 rgba(46, 52, 64, 0.1) !important; margin-block-start: -4px; & .t-checkmark { visibility: hidden; } + + &:has(.dialog-kanban) { + --tui-radius-m: 0 3px 3px 0; + --tui-base-04: var(--color-gray100); + --tui-elevation-01: var(--color-gray100); + } } diff --git a/javascript/apps/taiga/src/app/styles/tools/skeleton.css b/javascript/apps/taiga/src/app/styles/tools/skeleton.css index 21d3fe87f..da897feca 100644 --- a/javascript/apps/taiga/src/app/styles/tools/skeleton.css +++ b/javascript/apps/taiga/src/app/styles/tools/skeleton.css @@ -63,6 +63,21 @@ Copyright (c) 2023-present Kaleidos INC @mixin square 20; @mixin square 24; +/* mixin cicle - auto generates utility classes */ +@define-mixin circle $size { + .skeleton-circle-$(size) { + /* stylelint-disable-next-line custom-property-pattern */ + block-size: var(--spacing-$size); + border-radius: 50%; + /* stylelint-disable-next-line custom-property-pattern */ + inline-size: var(--spacing-$size); + + @mixin-content; + } +} + +@mixin circle 12; + /* skeleton-animation for menu or content */ .skeleton-animation { & * { diff --git a/javascript/apps/taiga/src/assets/i18n/en-US.json b/javascript/apps/taiga/src/assets/i18n/en-US.json index 241dc7978..3be2f0238 100644 --- a/javascript/apps/taiga/src/assets/i18n/en-US.json +++ b/javascript/apps/taiga/src/assets/i18n/en-US.json @@ -112,6 +112,10 @@ "choose_image_no_svg_tip": "Allowed formats: gif, png, jpg and webp", "format_no_svg_error": "The file format is not allowed. Allowed formats: gif, png, jpg or webp" }, + "workflow": { + "create": "Create new workflow", + "workflow_list": "Workflow list" + }, "delete_modal_title": "Delete {{projectTitle}}?", "delete_modal_warning": "Warning: This action cannot be undone.", "delete_modal_text": "This action will delete the project, along with all the stories and attachments in it.", diff --git a/javascript/apps/taiga/src/assets/i18n/kanban/en-US.json b/javascript/apps/taiga/src/assets/i18n/kanban/en-US.json index 404066fca..967724421 100644 --- a/javascript/apps/taiga/src/assets/i18n/kanban/en-US.json +++ b/javascript/apps/taiga/src/assets/i18n/kanban/en-US.json @@ -1,5 +1,8 @@ { + "workflow": "workflow", + "workflows": "workflows", "page_title": "{{projectName}} kanban", + "workflow_title": "[{{projectName}}] kanban workflow", "title": "Kanban", "empty": "The project administrator has not set any status yet.", "status_label": "Status {{ statusName }}, Tap left and right arrows to move between statuses.", @@ -32,6 +35,8 @@ "empty_kanban": "This kanban is empty", "create_status_explanation_admin": "Create statuses to organize your stories and visualize the progress of your project.", "create_status_explanation": "Once the admins of this project set up the kanban, you can start creating stories", + "delete_workflow": "Delete workflow", + "edit_workflow_name": "Edit workflow name", "delete_status_modal": { "title": "Delete {{ statusName }} status?", "what_to_do_stories": "What do you want to do with the stories in this status?", @@ -42,5 +47,23 @@ "delete_stories": "Delete the stories too", "stories_placed_below": "The stories will be placed below the existing ones.", "status": "Status" + }, + "delete_workflow_modal": { + "title": "Delete {{ name }} workflow?", + "what_to_do_statuses": "What do you want to do with the statuses inside them?", + "stories_will_be_deleted": "The stories from this status will be automatically deleted.", + "cancel": "Never mind, keep workflow", + "confirm": "Yes, delete workflow", + "move_stories_another_workflow": "Move them to another workflow", + "delete_all": "Delete the stories and statuses too", + "statuses_placed_after": "The statuses will be placed after the existing ones.", + "workflow": "Workflow" + }, + "create_workflow": { + "save": "Create Workflow", + "workflow_name_aria": "Workflow name (Maximum 40 Characters only)", + "write_workflow_name": "Write a workflow name", + "workflow_empty": "Workflow name can’t be empty.", + "max_workflow_created": "You reached the max number of workflows (8)." } } diff --git a/javascript/apps/taiga/src/assets/icons/kanban.svg b/javascript/apps/taiga/src/assets/icons/kanban.svg index 8043d1270..c9d9b5f05 100644 --- a/javascript/apps/taiga/src/assets/icons/kanban.svg +++ b/javascript/apps/taiga/src/assets/icons/kanban.svg @@ -1,4 +1,3 @@ - - + + diff --git a/javascript/libs/api/src/lib/project/project-api.service.ts b/javascript/libs/api/src/lib/project/project-api.service.ts index 34404b680..16481f1b7 100644 --- a/javascript/libs/api/src/lib/project/project-api.service.ts +++ b/javascript/libs/api/src/lib/project/project-api.service.ts @@ -239,10 +239,35 @@ export class ProjectApiService { public getWorkflow( projectId: Project['id'], - slug: string + workflow: Workflow['slug'] ): Observable { return this.http.get( - `${this.config.apiUrl}/projects/${projectId}/workflows/${slug}` + `${this.config.apiUrl}/projects/${projectId}/workflows/${workflow}` + ); + } + + public createWorkflow( + workflow: Workflow['name'], + project: Project['id'] + ): Observable { + return this.http.post( + `${this.config.apiUrl}/projects/${project}/workflows`, + { + name: workflow, + } + ); + } + + public updateWorkflow( + workflowName: Workflow['name'], + workflowSlug: Workflow['slug'], + project: Project['id'] + ): Observable { + return this.http.patch( + `${this.config.apiUrl}/projects/${project}/workflows/${workflowSlug}`, + { + name: workflowName, + } ); } diff --git a/javascript/libs/data/src/lib/project.model.mock.ts b/javascript/libs/data/src/lib/project.model.mock.ts index 0f034e565..20d118d9c 100644 --- a/javascript/libs/data/src/lib/project.model.mock.ts +++ b/javascript/libs/data/src/lib/project.model.mock.ts @@ -16,7 +16,7 @@ import { randUuid, randWord, } from '@ngneat/falso'; -import { ProjectCreation, Workspace } from '..'; +import { ProjectCreation, WorkflowMockFactory, Workspace } from '..'; import { Project } from './project.model'; import { WorkspaceMockFactory } from './workspace.model.mock'; @@ -30,6 +30,7 @@ export const ProjectMockFactory = ( color: randNumber(), description: randParagraph({ length: 3 }).join('\n'), workspace: workspace ?? WorkspaceMockFactory(), + workflows: [WorkflowMockFactory()], logo: randImg(), logoSmall: randImg(), logoLarge: randImg(), diff --git a/javascript/libs/data/src/lib/project.model.ts b/javascript/libs/data/src/lib/project.model.ts index 836d04811..240dcd824 100644 --- a/javascript/libs/data/src/lib/project.model.ts +++ b/javascript/libs/data/src/lib/project.model.ts @@ -9,6 +9,7 @@ import type { Merge } from 'type-fest'; import { Membership } from './membership.model'; import { Story } from './story.model'; +import { Workflow } from './workflow.model'; import { Workspace } from './workspace.model'; export interface Project { @@ -21,6 +22,7 @@ export interface Project { description: string | null; color: number; workspace: Pick; + workflows: Workflow[]; userIsAdmin: boolean; userIsMember: boolean; userPermissions: string[]; diff --git a/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.css b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.css new file mode 100644 index 000000000..c795988de --- /dev/null +++ b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.css @@ -0,0 +1,31 @@ +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2023-present Kaleidos INC +*/ + +@import url("tools/typography.css"); + +:host { + @mixin font-inline; + + color: var(--color-gray80); + display: block; +} + +.crumb { + padding: var(--spacing4); +} + +.accent { + color: var(--color-gray100); + font-weight: 500; +} + +.collapsed-crumb-icon { + block-size: var(--spacing-16); + color: var(--color-gray40); + inline-size: var(--spacing-16); +} diff --git a/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.html b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.html new file mode 100644 index 000000000..ec5af3b7f --- /dev/null +++ b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.html @@ -0,0 +1,47 @@ + + + + + + + {{ crumb }} + + + {{ crumb }} + + + > + + + + + + + {{ crumbs.at(-1) }} + + + diff --git a/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.ts b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.ts new file mode 100644 index 000000000..329e1385b --- /dev/null +++ b/javascript/libs/ui/src/lib/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,34 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { TranslocoDirective } from '@ngneat/transloco'; +import { TuiSvgModule } from '@taiga-ui/core'; + +type BreadCrumbType = 'expanded' | 'collapsed'; + +@Component({ + selector: 'tg-ui-breadcrumb', + templateUrl: './breadcrumb.component.html', + styleUrls: ['./breadcrumb.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, TranslocoDirective, TuiSvgModule], +}) +export class BreadcrumbComponent { + @Input({ required: true }) public crumbs: string[] = []; + @Input() public icon = 'kanban'; + @Input() public accent = false; + @Input() public type: BreadCrumbType = 'expanded'; + @Input() public hideLastCrumb = false; + + public trackByIndex(index: number) { + return index; + } +}