Formatted text
- Descriptions, comments, replies and release notes render Markdown,
sanitised with DOMPurify (tested against scripts, handlers and
javascript: links). A pasted image is uploaded and written as
; the web fetches it with the reader's session,
so it works on private projects. Every text box has a preview. The
attachment upload now answers its attachmentId.
List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
text, open/closed, type, priority and assignee; status, priority and
assignee change in place; bulk edit (the backlog's bar, now shared).
Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
when reading with one query per board or page; shown on cards, list
rows and the ticket.
Public proposals
- A public project can take bug reports and ideas from anyone
(acceptsProposals): POST /api/explore/projects/{project}/proposals and
the form at /p/:key/proponer. They become tickets labelled «propuesta»,
opened by TaskStream; a honeypot field and 5 per address every 15
minutes keep spam out. No e-mail is asked: it would be public.
Votes
- Anyone signed in votes on a public project's tickets, members on a
private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
voted on each ticket, buttons on the ticket and the roadmap, a column
in the list. The showcase carries votes and the open proposals.
MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
7.2 KiB
TypeScript
238 lines
7.2 KiB
TypeScript
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<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,
|
|
): 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<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 });
|
|
}
|
|
|
|
/** 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];
|
|
}
|