diff --git a/javascript/apps/taiga-e2e/src/e2e/project/story-detail/story-detail-attachments.cy.ts b/javascript/apps/taiga-e2e/src/e2e/project/story-detail/story-detail-attachments.cy.ts index 11c08a9aa..074c743cc 100644 --- a/javascript/apps/taiga-e2e/src/e2e/project/story-detail/story-detail-attachments.cy.ts +++ b/javascript/apps/taiga-e2e/src/e2e/project/story-detail/story-detail-attachments.cy.ts @@ -13,14 +13,17 @@ import { Story, WorkspaceMockFactory, } from '@taiga/data'; -import { uploadFiles } from '@test/support/helpers/attachments.helper'; -import * as commentsHelper from '@test/support/helpers/comments.helper'; +import { + uploadFiles, + initDelete, +} from '@test/support/helpers/attachments.helper'; import { createFullProjectInWSRequest, createStoryRequest, getProjectWorkflows, } from '@test/support/helpers/project.helpers'; import { createWorkspaceRequest } from '@test/support/helpers/workspace.helpers'; +import * as commentsHelper from '@test/support/helpers/comments.helper'; const workspace = WorkspaceMockFactory(); const projectMock = ProjectMockFactory(); @@ -28,6 +31,7 @@ const projectMock = ProjectMockFactory(); describe('StoryDetail attachments', () => { let story!: Story; let project!: Project; + const undoTime = 5000; before(() => { cy.login(); @@ -73,9 +77,28 @@ describe('StoryDetail attachments', () => { cy.getBySel('attachment').should('have.length', 2); }); + it('delete', () => { + cy.getBySel('attachment-row') + .its('length') + .then((attachments) => { + initDelete(0); + + cy.getBySel('undo-action').should('be.visible'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(undoTime); + + cy.getBySel('attachment-row').should('have.length', attachments - 1); + }); + }); + it('paginate', () => { uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); + uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); + uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); + uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); + uploadFiles(['cypress/fixtures/file1.txt', 'cypress/fixtures/file2.txt']); cy.getBySel('page-button').should('have.length', 2); }); diff --git a/javascript/apps/taiga-e2e/src/support/helpers/attachments.helper.ts b/javascript/apps/taiga-e2e/src/support/helpers/attachments.helper.ts index 0c96277ad..e4c0fc9e2 100644 --- a/javascript/apps/taiga-e2e/src/support/helpers/attachments.helper.ts +++ b/javascript/apps/taiga-e2e/src/support/helpers/attachments.helper.ts @@ -9,3 +9,7 @@ export const uploadFiles = (files: string[]) => { cy.getBySel('upload-attachment').selectFile(files, { force: true }); }; + +export const initDelete = (index: number) => { + cy.getBySel('delete-attachment').eq(index).click(); +}; diff --git a/javascript/apps/taiga/.eslintrc.json b/javascript/apps/taiga/.eslintrc.json index 1c39c4aba..1a2ff076c 100644 --- a/javascript/apps/taiga/.eslintrc.json +++ b/javascript/apps/taiga/.eslintrc.json @@ -39,28 +39,7 @@ } } ], - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/member-ordering": [ - "error", - { - "default": [ - "decorated-field", - "decorated-get", - "decorated-set", - "decorated-method", - "field", - "public-get", - "protected-get", - "private-get", - "public-set", - "protected-set", - "private-set", - "public-method", - "protected-method", - "private-method" - ] - } - ] + "@typescript-eslint/no-non-null-assertion": "off" } }, { 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 be6bd7666..3bc15d2ff 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 @@ -146,5 +146,9 @@ export const projectEventActions = createActionGroup({ storyRef: Story['ref']; attachment: Attachment; }>(), + 'Delete attachment': props<{ + storyRef: Story['ref']; + attachment: Attachment; + }>(), }, }); 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 c4c4df8a4..6112f0d4e 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 @@ -301,6 +301,20 @@ export class ProjectFeatureShellComponent implements OnDestroy, AfterViewInit { ); }); + this.wsService + .projectEvents<{ ref: Story['ref']; attachment: Attachment }>( + 'stories.attachments.delete' + ) + .pipe(untilDestroyed(this)) + .subscribe((eventResponse) => { + this.store.dispatch( + projectEventActions.deleteAttachment({ + storyRef: eventResponse.event.content.ref, + attachment: eventResponse.event.content.attachment, + }) + ); + }); + this.wsService .projectEvents<{ reorder: { diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts index 92cfb489c..10664e6c8 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/actions/story-detail.actions.ts @@ -90,6 +90,11 @@ export const StoryDetailActions = createActionGroup({ storyRef: Story['ref']; projectId: Project['id']; }>(), + 'Delete attachment': props<{ + id: Attachment['id']; + storyRef: Story['ref']; + projectId: Project['id']; + }>(), }, }); @@ -154,5 +159,8 @@ export const StoryDetailApiActions = createActionGroup({ 'Upload attachment success': props<{ attachment: Attachment; }>(), + 'Delete attachment success': props<{ + id: Attachment['id']; + }>(), }, }); diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.spec.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.spec.ts index 175496cbd..e9dd5dd7d 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.spec.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.spec.ts @@ -37,22 +37,39 @@ describe('StoryDetailAttachmentsEffects', () => { effects = spectator.service; }); - describe('initStory$', () => { - it('should fetch attachments', () => { - const action = StoryDetailActions.initStory({ - projectId: '1', - storyRef: 1, - }); - const responseAction = StoryDetailApiActions.fetchAttachments({ - projectId: '1', - storyRef: 1, - }); + it('should fetch attachments', () => { + const action = StoryDetailActions.initStory({ + projectId: '1', + storyRef: 1, + }); + const responseAction = StoryDetailApiActions.fetchAttachments({ + projectId: '1', + storyRef: 1, + }); - actions$ = hot('-a', { a: action }); - const expected = cold('-b', { b: responseAction }); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: responseAction }); + + expect(effects.initStory$).toBeObservable(expected); + }); - expect(effects.initStory$).toBeObservable(expected); + it('delete attachment', () => { + const action = StoryDetailActions.deleteAttachment({ + projectId: '1', + storyRef: 1, + id: '1', + }); + const responseAction = StoryDetailApiActions.deleteAttachmentSuccess({ + id: '1', }); + + const projectApiService = spectator.inject(ProjectApiService); + projectApiService.deleteAttachment.mockReturnValue(cold('-a', { a: null })); + + actions$ = hot('-a', { a: action }); + const expected = cold('--b', { b: responseAction }); + + expect(effects.deleteAttachment$).toBeObservable(expected); }); describe('fetchAttachments$', () => { diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.ts index f5a788738..31ef43733 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/effects/story-detail-attachments.effects.ts @@ -16,7 +16,7 @@ import { EMPTY, catchError, map, merge, mergeMap } from 'rxjs'; import { HttpErrorResponse, HttpEventType } from '@angular/common/http'; import { ProjectApiService } from '@taiga/api'; import { AppService } from '~/app/services/app.service'; -import { fetch } from '@ngrx/router-store/data-persistence'; +import { fetch, pessimisticUpdate } from '@ngrx/router-store/data-persistence'; import { filterNil } from '~/app/shared/utils/operators'; import { v4 } from 'uuid'; @Injectable() @@ -101,4 +101,26 @@ export class StoryDetailAttachmentsEffects { }) ); }); + + public deleteAttachment$ = createEffect(() => { + return this.actions$.pipe( + ofType(StoryDetailActions.deleteAttachment), + pessimisticUpdate({ + run: (action) => { + return this.projectApiService + .deleteAttachment(action.projectId, action.storyRef, action.id) + .pipe( + map(() => { + return StoryDetailApiActions.deleteAttachmentSuccess({ + id: action.id, + }); + }) + ); + }, + onError: (_, httpResponse: HttpErrorResponse) => { + this.appService.toastGenericError(httpResponse); + }, + }) + ); + }); } diff --git a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts index d14bd0138..e7a217c35 100644 --- a/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts +++ b/javascript/apps/taiga/src/app/modules/project/story-detail/data-access/+state/reducers/story-detail.reducer.ts @@ -459,6 +459,13 @@ export const reducer = createImmerReducer( return state; } ), + on(StoryDetailActions.deleteAttachment, (state, { id }): StoryDetailState => { + state.attachments = state.attachments.filter((it) => { + return it.id !== id; + }); + + return state; + }), on( projectEventActions.newAttachment, (state, { attachment, storyRef }): StoryDetailState => { @@ -468,6 +475,20 @@ export const reducer = createImmerReducer( state.attachments.unshift(attachment); + return state; + } + ), + on( + projectEventActions.deleteAttachment, + (state, { attachment, storyRef }): StoryDetailState => { + if (state.story?.ref !== storyRef) { + return state; + } + + state.attachments = state.attachments.filter((it) => { + return it.id !== attachment.id; + }); + return state; } ) 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 dc87f12ba..711403d27 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 @@ -272,3 +272,7 @@ tg-attachments, tg-story-detail-description { margin-block-end: var(--spacing-16); } + +.attachments-side-view { + --undo-inline-size: 100%; +} 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 70c027704..01a205090 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 @@ -279,13 +279,19 @@ "> + (uploadFiles)="onUploadFiles($event)" + (deleteAttachment)=" + onDeleteAttachment($event) + "> ; @Output() public uploadFiles = new EventEmitter(); + @Output() + public deleteAttachment = this.state.deleteAttachment$; + @Input() public set canEdit(canEdit: boolean) { this.state.set({ canEdit }); @@ -76,7 +81,6 @@ export class AttachmentsComponent { this.state.set({ loadingAttachments }); } - public state = inject(AttachmentsState); public attachmentsTotal = toSignal( this.state.select().pipe( map(({ attachments, loadingAttachments }) => { diff --git a/javascript/apps/taiga/src/app/shared/attachments/attachments.state.spec.ts b/javascript/apps/taiga/src/app/shared/attachments/attachments.state.spec.ts new file mode 100644 index 000000000..358cdb3a9 --- /dev/null +++ b/javascript/apps/taiga/src/app/shared/attachments/attachments.state.spec.ts @@ -0,0 +1,77 @@ +/** + * 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 { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { cold } from 'jest-marbles'; +import { AttachmentsState } from './attachments.state'; +import { LocalStorageService } from '../local-storage/local-storage.service'; +import { AttachmentMockFactory } from '@taiga/data'; + +describe('AttachmentsState', () => { + let spectator: SpectatorService; + let service: AttachmentsState; + + const createService = createServiceFactory({ + service: AttachmentsState, + mocks: [LocalStorageService], + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.inject(AttachmentsState); + }); + + it('fist page attachments', () => { + const attachments = Array.from({ length: 20 }, () => + AttachmentMockFactory() + ); + + service.set({ + attachments, + }); + + expect(service.paginatedList$).toBeObservable( + cold('a', { + a: { + page: 0, + paginationItems: 9, + total: 20, + folded: false, + canEdit: true, + pages: 2, + list: attachments.slice(0, 10), + }, + }) + ); + }); + + it('current page is exceeds total pages', () => { + const attachments = Array.from({ length: 20 }, () => + AttachmentMockFactory() + ); + + service.set({ + attachments, + page: 3, + }); + + expect(service.paginatedList$).toBeObservable( + cold('a', { + a: { + page: 1, + paginationItems: 9, + total: 20, + folded: false, + canEdit: true, + pages: 2, + list: attachments.slice(10, 20), + }, + }) + ); + }); +}); diff --git a/javascript/apps/taiga/src/app/shared/attachments/attachments.state.ts b/javascript/apps/taiga/src/app/shared/attachments/attachments.state.ts index 75d2db7e8..d7049c79c 100644 --- a/javascript/apps/taiga/src/app/shared/attachments/attachments.state.ts +++ b/javascript/apps/taiga/src/app/shared/attachments/attachments.state.ts @@ -9,7 +9,7 @@ import { Injectable, inject } from '@angular/core'; import { RxState } from '@rx-angular/state'; import { Attachment, LoadingAttachment } from '@taiga/data'; -import { map } from 'rxjs'; +import { Subject, map } from 'rxjs'; import { LocalStorageService } from '../local-storage/local-storage.service'; interface State { @@ -30,8 +30,20 @@ export class AttachmentsState extends RxState { public paginatedList$ = this.select().pipe( map((state) => { - const { attachments, loadingAttachments, page } = state; + const { attachments, loadingAttachments } = state; const list = [...loadingAttachments, ...attachments]; + let { page } = state; + + const totalPages = Math.ceil(list.length / ATTACHMENTS_PER_PAGE); + + if (page >= totalPages) { + page = totalPages - 1; + } + + const pageList = list.slice( + page * ATTACHMENTS_PER_PAGE, + (page + 1) * ATTACHMENTS_PER_PAGE + ); return { page, @@ -39,11 +51,8 @@ export class AttachmentsState extends RxState { total: list.length, folded: state.folded, canEdit: state.canEdit, - pages: Math.ceil(list.length / ATTACHMENTS_PER_PAGE), - list: list.slice( - page * ATTACHMENTS_PER_PAGE, - (page + 1) * ATTACHMENTS_PER_PAGE - ), + pages: totalPages, + list: pageList, }; }) ); @@ -64,4 +73,6 @@ export class AttachmentsState extends RxState { canEdit: true, }); } + + public deleteAttachment$ = new Subject(); } diff --git a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.css b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.css index 6281018ef..014121888 100644 --- a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.css +++ b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.css @@ -13,6 +13,7 @@ Copyright (c) 2023-present Kaleidos INC } .row { + block-size: var(--spacing-32); padding-block: var(--spacing-4); } diff --git a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.html b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.html index 9691a7c45..a0ef476ea 100644 --- a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.html +++ b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.html @@ -7,66 +7,79 @@ --> - - - - {{ attachment.name }} - - - - + + + + + {{ attachment.name }} - - - - - {{ attachment.size | transformSize }} - - - - - - - - + + + + + + + + + {{ attachment.size | transformSize }} + + + + + + + + - - - - + + + + + diff --git a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.ts b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.ts index ba0849b46..1d8526671 100644 --- a/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.ts +++ b/javascript/apps/taiga/src/app/shared/attachments/components/attachment/attachment.component.ts @@ -6,8 +6,8 @@ * Copyright (c) 2023-present Kaleidos INC */ +import { Component, Input, OnChanges, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Component, Input, OnChanges } from '@angular/core'; import { TranslocoModule } from '@ngneat/transloco'; import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'; import { Attachment, LoadingAttachment } from '@taiga/data'; @@ -17,6 +17,12 @@ import { ToolTipModule } from '@taiga/ui/tooltip'; import { DateDistancePipe } from '~/app/shared/pipes/date-distance/date-distance.pipe'; import { TransformSizePipe } from '~/app/shared/pipes/transform-size/transform-size.pipe'; import { RealTimeDateDistanceComponent } from '~/app/shared/real-time-date-distance/real-time-date-distance.component'; +import { AttachmentsState } from '~/app/shared/attachments/attachments.state'; +import { ContextNotificationModule } from '@taiga/ui/context-notification/context-notification.module'; +import { showUndo, undoDone } from '~/app/shared/utils/animations'; +import { UndoComponent } from '~/app/shared/undo/undo.component'; +import { Subject } from 'rxjs'; + @Component({ selector: 'tg-attachment', standalone: true, @@ -31,9 +37,12 @@ import { RealTimeDateDistanceComponent } from '~/app/shared/real-time-date-dista TuiSvgModule, ProgressBarComponent, RealTimeDateDistanceComponent, + ContextNotificationModule, + UndoComponent, ], templateUrl: './attachment.component.html', styleUrls: ['./attachment.component.css'], + animations: [showUndo, undoDone], }) export class AttachmentComponent implements OnChanges { @Input({ required: true }) @@ -43,6 +52,9 @@ export class AttachmentComponent implements OnChanges { public canEdit = true; public extension = 'paperclip'; + public state = inject(AttachmentsState); + #initUndo$ = new Subject(); + public initUndo$ = this.#initUndo$.asObservable(); public calculateExtension() { if (this.attachment.contentType.startsWith('image')) { @@ -91,6 +103,16 @@ export class AttachmentComponent implements OnChanges { return 'progress' in attachment; }; + public deleteAttachment() { + this.#initUndo$.next(); + } + + public onConfirmDeleteFile() { + if (!this.isLoadingAttachments(this.attachment)) { + this.state.deleteAttachment$.next(this.attachment.id); + } + } + public ngOnChanges() { this.calculateExtension(); } diff --git a/javascript/apps/taiga/src/app/shared/undo/undo.component.css b/javascript/apps/taiga/src/app/shared/undo/undo.component.css new file mode 100644 index 000000000..2ee0d38dd --- /dev/null +++ b/javascript/apps/taiga/src/app/shared/undo/undo.component.css @@ -0,0 +1,60 @@ +/* +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 { + --row-inline-size: var(--undo-inline-size, 455px); + + display: block; + overflow: hidden; + position: relative; +} + +.msg { + @mixin ellipsis; +} + +.countdown-wrapper { + align-items: center; + display: flex; + gap: var(--spacing-16); + inline-size: calc(100% - var(--spacing-24)); + + & button { + text-decoration: underline; + } +} + +.countdown { + background: var(--color-white); + display: flex; + inline-size: 100%; + inset-block-start: 100%; + inset-inline-end: 0; + justify-content: flex-end; + position: absolute; +} + +.countdown tg-ui-context-notification { + inline-size: var(--row-inline-size); +} + +.countdown, +.action-undo-done { + inset-block-start: 0; + inset-inline-end: 0; + position: absolute; +} + +.undo-action { + --color-secondary: var(--color-info90); + + color: var(--color-info90); + margin-inline-start: auto; +} diff --git a/javascript/apps/taiga/src/app/shared/undo/undo.component.html b/javascript/apps/taiga/src/app/shared/undo/undo.component.html new file mode 100644 index 000000000..755128d01 --- /dev/null +++ b/javascript/apps/taiga/src/app/shared/undo/undo.component.html @@ -0,0 +1,71 @@ + + +
+ +
+ +
+ +
+ {{ msg }} + + +
+
+
+ +
+ +
+ {{ msgActionUndon }} + +
+
+
diff --git a/javascript/apps/taiga/src/app/shared/undo/undo.component.spec.ts b/javascript/apps/taiga/src/app/shared/undo/undo.component.spec.ts new file mode 100644 index 000000000..6552dfcf8 --- /dev/null +++ b/javascript/apps/taiga/src/app/shared/undo/undo.component.spec.ts @@ -0,0 +1,107 @@ +/** + * 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 { Spectator, createHostFactory } from '@ngneat/spectator/jest'; +import { UndoComponent } from './undo.component'; +import { Subject } from 'rxjs'; +import { getTranslocoModule } from '~/app/transloco/transloco-testing.module'; +import { fakeAsync } from '@angular/core/testing'; + +describe('TooltipDirective', () => { + let spectator: Spectator; + const createHost = createHostFactory({ + component: UndoComponent, + imports: [getTranslocoModule()], + }); + const init$ = new Subject(); + + it('run undo & wait confirm', fakeAsync(() => { + const confirm = jest.fn(); + + spectator = createHost( + ` + +
Some content here
+
`, + { + hostProps: { + initUndo$: init$.asObservable(), + confirm, + }, + } + ); + + init$.next(); + + spectator.detectChanges(); + + expect(spectator.query('[data-test="undo-action"]')).toExist(); + + expect(confirm).not.toHaveBeenCalled(); + + spectator.tick(6000); + + expect(confirm).toHaveBeenCalled(); + })); + + it('run undo & close', fakeAsync(() => { + const confirm = jest.fn(); + + spectator = createHost( + ` + +
Some content here
+
`, + { + hostProps: { + initUndo$: init$.asObservable(), + confirm, + }, + } + ); + + init$.next(); + + spectator.detectChanges(); + + expect(spectator.query('[data-test="undo-action"]')).toExist(); + + spectator.click('[data-test="close-action"]'); + + expect(confirm).toHaveBeenCalled(); + })); + + it('run undo & cancel', fakeAsync(() => { + const confirm = jest.fn(); + + spectator = createHost( + ` + +
Some content here
+
`, + { + hostProps: { + initUndo$: init$.asObservable(), + confirm, + }, + } + ); + + init$.next(); + + spectator.detectChanges(); + + expect(spectator.query('[data-test="undo-action"]')).toExist(); + + spectator.click('[data-test="undo-action"]'); + + spectator.tick(6000); + + expect(confirm).not.toHaveBeenCalled(); + })); +}); diff --git a/javascript/apps/taiga/src/app/shared/undo/undo.component.ts b/javascript/apps/taiga/src/app/shared/undo/undo.component.ts new file mode 100644 index 000000000..02e20eeaf --- /dev/null +++ b/javascript/apps/taiga/src/app/shared/undo/undo.component.ts @@ -0,0 +1,208 @@ +/** + * 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, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + OnInit, + Output, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Observable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ContextNotificationModule } from '@taiga/ui/context-notification/context-notification.module'; +import { + animate, + state, + style, + transition, + trigger, +} from '@angular/animations'; +import { TuiButtonModule, TuiLinkModule } from '@taiga-ui/core'; +import { TranslocoService } from '@ngneat/transloco'; + +@Component({ + selector: 'tg-undo', + standalone: true, + imports: [ + CommonModule, + ContextNotificationModule, + TuiLinkModule, + TuiButtonModule, + ], + templateUrl: './undo.component.html', + styleUrls: ['./undo.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('undoDone', [ + transition(':enter', [ + style({ + opacity: 0, + transform: 'translateX(100%)', + }), + animate( + '400ms 1s ease-out', + style({ + opacity: 1, + transform: 'translateX(0%)', + }) + ), + ]), + transition(':leave', [ + animate( + '400ms ease-out', + style({ + opacity: 0, + transform: 'translateX(100%)', + }) + ), + ]), + ]), + trigger('showUndo', [ + transition(':enter', [ + style({ + opacity: 0, + transform: 'translateY(100%)', + }), + animate( + '400ms 0.5s ease-out', + style({ + opacity: 1, + transform: 'translateY(0%)', + }) + ), + ]), + transition(':leave', [ + animate( + '400ms ease-out', + style({ + opacity: 0, + transform: 'translateX(0%)', + }) + ), + ]), + ]), + trigger('undoSteps', [ + state( + 'none', + style({ + opacity: 1, + transform: 'translateY(0%)', + }) + ), + state( + 'undone', + style({ + opacity: 1, + transform: 'translateY(0%)', + }) + ), + state( + 'waitUndo', + style({ + opacity: 0, + transform: 'translateY(-100%)', + }) + ), + transition('none => waitUndo', [ + style({ opacity: 0.7 }), + animate('0.3s 0.5s'), + ]), + transition('waitUndo => undone', [ + style({ opacity: 1 }), + animate('0.3s'), + ]), + transition('waitUndo => none', [animate('0.3s')]), + ]), + ], +}) +export class UndoComponent implements OnInit, OnDestroy { + private t = inject(TranslocoService); + private takeUntilDestroyed = takeUntilDestroyed(); + + @Input({ required: true }) + public initUndo!: Observable; + + @Input({ required: true }) + public msg!: string; + + @Input() + public msgActionUndon = this.t.translate('ui_components.undo.action_undone'); + + @Input() + public msgActionUndo = this.t.translate('ui_components.undo.action_undo'); + + @Output() + public confirm = new EventEmitter(); + + @HostListener('window:beforeunload') + public beforeUnload() { + if (this.state() === 'waitUndo') { + this.confirm.next(); + } + } + + public state = signal('none'); + public el = inject>(ElementRef); + public confirmTimeout: ReturnType | null = null; + public undoneTimeout: ReturnType | null = null; + + public undo() { + if (this.confirmTimeout) { + clearTimeout(this.confirmTimeout); + } + + this.state.set('undone'); + + this.undoneTimeout = setTimeout(() => { + this.state.set('none'); + }, 4000); + } + + public ngOnInit() { + this.initUndo.pipe(this.takeUntilDestroyed).subscribe(() => { + this.el.nativeElement.style.setProperty( + '--row-height', + `${this.el.nativeElement.offsetHeight}px` + ); + + this.state.set('waitUndo'); + + this.confirmTimeout = setTimeout(() => { + this.state.set('none'); + this.confirm.next(); + }, 5000); + }); + } + + public closeConfirm() { + this.state.set('none'); + this.confirm.next(); + + if (this.confirmTimeout) { + clearTimeout(this.confirmTimeout); + } + } + + public close() { + this.state.set('none'); + } + + public ngOnDestroy() { + if (this.undoneTimeout) { + clearTimeout(this.undoneTimeout); + } + } +} diff --git a/javascript/apps/taiga/src/assets/i18n/attachments/en-US.json b/javascript/apps/taiga/src/assets/i18n/attachments/en-US.json index e910bbcd8..06148fe4f 100644 --- a/javascript/apps/taiga/src/assets/i18n/attachments/en-US.json +++ b/javascript/apps/taiga/src/assets/i18n/attachments/en-US.json @@ -11,5 +11,6 @@ "download": "Download", "delete": "Delete", "error_size": "The file {{filename}} has not been attached because it exceeds the maximum size of {{maxSize}}MB", - "uploading": "Uploading {{file}}" + "uploading": "Uploading {{file}}", + "deleted": "{{filename}} deleted" } diff --git a/javascript/apps/taiga/src/assets/i18n/en-US.json b/javascript/apps/taiga/src/assets/i18n/en-US.json index e0c649f17..e1d22a1be 100644 --- a/javascript/apps/taiga/src/assets/i18n/en-US.json +++ b/javascript/apps/taiga/src/assets/i18n/en-US.json @@ -225,5 +225,11 @@ "projects_dropdown": { "projects_number": "{projectsNum, plural, =0 {0 projects} one {1 project} other {# projects}}", "projects_list_title": "Projects in this workspace" + }, + "ui_components": { + "undo": { + "action_undone": "Action undone", + "action_undo": "Undo" + } } } 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 5d1375412..64e042d34 100644 --- a/javascript/libs/api/src/lib/project/project-api.service.ts +++ b/javascript/libs/api/src/lib/project/project-api.service.ts @@ -589,4 +589,14 @@ export class ProjectApiService { return this.http.request(req); } + + public deleteAttachment( + projectId: Project['id'], + storyRef: Story['ref'], + attachmentId: Attachment['id'] + ) { + return this.http.delete( + `${this.config.apiUrl}/projects/${projectId}/stories/${storyRef}/attachments/${attachmentId}` + ); + } } diff --git a/javascript/libs/data/src/lib/attachment.model.mock.ts b/javascript/libs/data/src/lib/attachment.model.mock.ts index cb6f20376..956f9e79e 100644 --- a/javascript/libs/data/src/lib/attachment.model.mock.ts +++ b/javascript/libs/data/src/lib/attachment.model.mock.ts @@ -12,6 +12,7 @@ import { randNumber, randPastDate, randUrl, + randUuid, } from '@ngneat/falso'; import { Attachment } from './attachment.model'; @@ -19,6 +20,7 @@ export const AttachmentMockFactory = ( params?: Partial ): Attachment => { return { + id: randUuid(), name: randFileName(), size: randNumber({ min: 1, max: 9000000 }), file: randUrl(), diff --git a/javascript/libs/data/src/lib/attachment.model.ts b/javascript/libs/data/src/lib/attachment.model.ts index 3576fb852..b3f26586d 100644 --- a/javascript/libs/data/src/lib/attachment.model.ts +++ b/javascript/libs/data/src/lib/attachment.model.ts @@ -12,6 +12,7 @@ export interface Attachment { createdAt: string; contentType: string; file: string; + id: string; } export interface LoadingAttachment { diff --git a/javascript/libs/ui/src/lib/context-notification/context-notification.component.css b/javascript/libs/ui/src/lib/context-notification/context-notification.component.css index aff1adade..e90900b6e 100644 --- a/javascript/libs/ui/src/lib/context-notification/context-notification.component.css +++ b/javascript/libs/ui/src/lib/context-notification/context-notification.component.css @@ -16,8 +16,8 @@ Copyright (c) 2023-present Kaleidos INC } .wrapper { - border: solid 1px var(--color-gray20); border-radius: 4px; + box-shadow: inset 0 0 0 1px var(--color-gray20); display: inline-block; inline-size: 100%; line-height: 1.5; @@ -48,7 +48,7 @@ Copyright (c) 2023-present Kaleidos INC & .close-button { align-self: start; block-size: var(--spacing-16); - color: var(--color-info80); + color: var(--color-info90); inline-size: var(--spacing-16); margin-block-start: 2px; } @@ -62,8 +62,8 @@ Copyright (c) 2023-present Kaleidos INC } :host([size="s"]) .wrapper { - padding-block: var(--spacing-8); - padding-inline: var(--spacing-8) var(--spacing-16); + padding-block: var(--spacing-4); + padding-inline: var(--spacing-8); } :host([size="l"]) .wrapper { @@ -73,7 +73,7 @@ Copyright (c) 2023-present Kaleidos INC :host([status="info"]) .wrapper { background: var(--color-info20); border-color: var(--color-info30); - color: var(--color-info80); + color: var(--color-info90); &::after { background: none; @@ -85,12 +85,12 @@ Copyright (c) 2023-present Kaleidos INC text-decoration: underline; @mixin wrapper-content { - color: var(--color-info80) !important; + color: var(--color-info90) !important; } } & .close-button [tuiWrapper] { - color: var(--color-info80) !important; + color: var(--color-info90) !important; @mixin wrapper-hover { background: var(--color-info30);