import { inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; import { Attachment, AttachmentsGateway, IssueLink, IssuesGateway, IssueLinkType, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { issuePageEvents } from '../events/issue.events'; import { IssueStore } from '../stores/issue.store'; /** * One ticket for presenters: details, comments and history, live. Commands act * on the open ticket, so pages don't pass its id. */ @Injectable() export class IssueFacade { private readonly store = inject(IssueStore); private readonly dispatch = injectDispatch(issuePageEvents); private readonly files = inject(AttachmentsGateway); private readonly issues = inject(IssuesGateway); readonly issue = this.store.issue; readonly comments = this.store.comments; readonly history = this.store.history; readonly outwardLinks = this.store.outwardLinks; readonly inwardLinks = this.store.inwardLinks; /** Tickets hanging from the open one, and how far they have got. */ readonly children = this.store.children; readonly childProgress = this.store.childProgress; /** Ticked steps of the open ticket's checklist. */ readonly checklistProgress = this.store.checklistProgress; readonly loading = this.store.loading; readonly error = this.store.error; /** Signed-in user's id (authors can edit their comments). */ readonly me = this.store.me; /** Whether the signed-in person follows the open ticket. */ readonly watching = this.store.watching; /** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */ open(idOrKey: string): void { this.dispatch.opened({ idOrKey }); } /** QA_FAILED and CANCELLED need a reason. */ changeStatus(status: string, reason?: string | null): void { this.withIssue((issueId) => this.dispatch.statusChangeRequested({ issueId, status, reason }), ); } assign(assigneeId: string | null): void { this.withIssue((issueId) => this.dispatch.assignRequested({ issueId, assigneeId }), ); } update(changes: Omit): void { this.withIssue((issueId) => this.dispatch.updateRequested({ issueId, ...changes }), ); } /** Relates this ticket with another one by its key (e.g. BE-3). */ link(type: IssueLinkType, targetKey: string): void { this.withIssue((issueId) => this.dispatch.linkByKeyRequested({ issueId, type, targetKey }), ); } unlink(link: IssueLink): void { this.withIssue((issueId) => this.dispatch.unlinkRequested({ issueId, type: link.type, targetIssueId: link.issueId, }), ); } /** null: back to the backlog. */ setSprint(sprintId: string | null): void { this.withIssue((issueId) => this.dispatch.sprintChangeRequested({ issueId, sprintId }), ); } setRelease(releaseId: string | null): void { this.withIssue((issueId) => this.dispatch.releaseChangeRequested({ issueId, releaseId }), ); } /** `files` are attached under the new comment; `replyTo` answers one. */ addComment( body: string, files: File[] = [], replyTo: string | null = null, ): void { this.withIssue((issueId) => this.dispatch.commentAddRequested({ issueId, body, files, replyTo }), ); } /** To the trash (true) or back from it (false). */ setTrashed(trashed: boolean): void { this.withIssue((issueId) => this.dispatch.trashRequested({ issueId, trashed }), ); } /** Follow the ticket (or stop): be told when it moves or gets a comment. */ setWatching(watching: boolean): void { this.withIssue((issueId) => this.dispatch.watchRequested({ issueId, watching }), ); } /** Places the open ticket under another one by its key; null: back to the top. */ setParent(parentKey: string | null): void { this.withIssue((issueId) => this.dispatch.parentRequested({ issueId, parentKey }), ); } /** Creates a ticket under the open one. */ addSubtask(title: string, type: string): void { const issue = this.store.issue(); if (!issue || !title.trim()) return; this.dispatch.subtaskCreateRequested({ projectId: issue.projectId, parentId: issue.id, type, title: title.trim(), }); } addChecklistItem(text: string): void { if (!text.trim()) return; this.withIssue((issueId) => this.dispatch.checklistAddRequested({ issueId, text: text.trim() }), ); } updateChecklistItem( itemId: string, changes: { text?: string; done?: boolean }, ): void { this.withIssue((issueId) => this.dispatch.checklistUpdateRequested({ issueId, itemId, changes }), ); } removeChecklistItem(itemId: string): void { this.withIssue((issueId) => this.dispatch.checklistRemoveRequested({ issueId, itemId }), ); } /** Attaches files to the ticket (or under one of its comments). */ attach(files: File[], commentId: string | null = null): void { if (!files.length) return; this.withIssue((issueId) => this.dispatch.attachRequested({ issueId, files, commentId }), ); } removeAttachment(attachmentId: string): void { this.withIssue((issueId) => this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }), ); } /** The file's bytes, to show an image or save the file. */ fileOf(attachment: Attachment): Observable { const issueId = this.store.issue()?.id ?? ''; return this.files.download(issueId, attachment); } /** A file's bytes by id alone (an image inside a description or comment). */ fileById(attachmentId: string): Observable { return this.fileOf({ id: attachmentId } as Attachment); } /** * Uploads an image pasted into a text and answers its id, to write * `![name](attachment:)`. It also shows among the ticket's files. */ uploadImage(file: File): Observable { const issueId = this.store.issue()?.id ?? ''; return this.files.upload(issueId, file).pipe( map((uploaded) => { this.dispatch.imagePasted({ issueId }); return uploaded.attachmentId; }), ); } /** Votes for the open ticket (`on`) or takes the vote back. */ vote(on: boolean): void { this.withIssue((issueId) => this.dispatch.voteRequested({ issueId, on })); } /** Adds (`on`) or takes back the signed-in person's reaction. */ react(commentId: string, emoji: string, on: boolean): void { this.dispatch.reactRequested({ commentId, emoji, on }); } /** A copy of what the open ticket says; answers its key. */ duplicate(): Observable { const issueId = this.store.issue()?.id ?? ''; return this.issues .duplicate(issueId) .pipe(map((result) => result.key ?? null)); } editComment(commentId: string, body: string): void { this.dispatch.commentEditRequested({ commentId, body }); } deleteComment(commentId: string): void { this.dispatch.commentDeleteRequested({ commentId }); } private withIssue(command: (issueId: string) => void): void { const issueId = this.store.issue()?.id; if (issueId) command(issueId); } } /** Store + facade of a ticket, for the issue route's component. */ export function provideIssueFacade(): Provider[] { return [IssueStore, IssueFacade]; }