Opening a ticket notes the visit and answers the one before (issue_visits), so the page marks others' comments and changes since. A comment can become a ticket of its own, as a subtask or linked; it remembers where it went. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
10 KiB
TypeScript
365 lines
10 KiB
TypeScript
import { inject, Injectable, Provider } from '@angular/core';
|
|
import { injectDispatch } from '@ngrx/signals/events';
|
|
import {
|
|
Attachment,
|
|
AttachmentsGateway,
|
|
IssueLink,
|
|
IssuesGateway,
|
|
IssueLinkType,
|
|
IssueSummary,
|
|
UpdateIssueInput,
|
|
ReviewVerdict,
|
|
} 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<UpdateIssueInput, 'issueId'>): 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,
|
|
poll: string[] | null = null,
|
|
): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.commentAddRequested({
|
|
issueId,
|
|
body,
|
|
files,
|
|
replyTo,
|
|
poll,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Votes in a comment's poll (null takes the vote back). */
|
|
votePoll(commentId: string, option: number | null): void {
|
|
this.dispatch.pollVoteRequested({ commentId, option });
|
|
}
|
|
|
|
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
|
closePoll(commentId: string, decide = true): void {
|
|
this.dispatch.pollCloseRequested({ commentId, decide });
|
|
}
|
|
|
|
/** 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 }),
|
|
);
|
|
}
|
|
|
|
/** Asks a member to review the open ticket. */
|
|
requestReview(reviewerId: string, note: string | null = null): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.reviewRequested({
|
|
issueId,
|
|
reviewerId,
|
|
note: note?.trim() || null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Answers a review I was asked for. */
|
|
answerReview(
|
|
reviewId: string,
|
|
verdict: ReviewVerdict,
|
|
answer: string | null = null,
|
|
): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.reviewAnswerRequested({
|
|
issueId,
|
|
reviewId,
|
|
verdict,
|
|
answer: answer?.trim() || null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
cancelReview(reviewId: string): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.reviewCancelRequested({ issueId, reviewId }),
|
|
);
|
|
}
|
|
|
|
/** Remind me of the ticket then (null: drop it); one per ticket. */
|
|
setReminder(at: string | null, note: string | null = null): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.reminderRequested({
|
|
issueId,
|
|
at,
|
|
note: note?.trim() || null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Follow the ticket with everything under it (an epic), or stop. */
|
|
setFollowingTree(on: boolean): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.followTreeRequested({ issueId, on }),
|
|
);
|
|
}
|
|
|
|
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
|
|
setPinned(pinned: boolean): void {
|
|
this.withIssue((issueId) =>
|
|
this.dispatch.pinRequested({ issueId, pinned }),
|
|
);
|
|
}
|
|
|
|
/** Places the open ticket under another one by its key; null: back to the top. */
|
|
/**
|
|
* Tickets to link to or hang the open one from: the most recently updated
|
|
* of all my projects (links may cross projects), the open one aside.
|
|
*/
|
|
candidates(): Observable<IssueSummary[]> {
|
|
return this.issues
|
|
.mine({ assignedToMe: false, limit: 200 })
|
|
.pipe(
|
|
map((page) =>
|
|
page.items.filter((item) => item.id !== this.store.issue()?.id),
|
|
),
|
|
);
|
|
}
|
|
|
|
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<Blob> {
|
|
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<Blob> {
|
|
return this.fileOf({ id: attachmentId } as Attachment);
|
|
}
|
|
|
|
/**
|
|
* Uploads an image pasted into a text and answers its id, to write
|
|
* ``. It also shows among the ticket's files.
|
|
*/
|
|
uploadImage(file: File): Observable<string> {
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* Settles a conversation (or reopens it); a conclusion is added as a
|
|
* reply marked as a decision.
|
|
*/
|
|
resolveThread(
|
|
commentId: string,
|
|
resolved: boolean,
|
|
conclusion: string | null = null,
|
|
): void {
|
|
this.dispatch.threadResolveRequested({
|
|
commentId,
|
|
resolved,
|
|
conclusion: conclusion?.trim() || null,
|
|
});
|
|
}
|
|
|
|
/** Makes a ticket of a comment: a subtask of this one, or linked to it. */
|
|
turnCommentIntoIssue(
|
|
commentId: string,
|
|
title: string,
|
|
type: string,
|
|
asSubtask: boolean,
|
|
): void {
|
|
this.dispatch.commentToIssueRequested({
|
|
commentId,
|
|
title: title.trim(),
|
|
type,
|
|
asSubtask,
|
|
});
|
|
}
|
|
|
|
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
|
markDecision(commentId: string, on: boolean): void {
|
|
this.dispatch.decisionRequested({ commentId, on });
|
|
}
|
|
|
|
/** A copy of what the open ticket says; answers its key. */
|
|
duplicate(): Observable<string | null> {
|
|
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];
|
|
}
|