feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
18
libs/web/tracker/application/src/lib/events/epics.events.ts
Normal file
18
libs/web/tracker/application/src/lib/events/epics.events.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import { Epic } from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const epicsPageEvents = eventGroup({
|
||||
source: 'Epics Page',
|
||||
events: {
|
||||
opened: type<{ projectId: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const epicsApiEvents = eventGroup({
|
||||
source: 'Epics API',
|
||||
events: {
|
||||
loaded: type<Epic[]>(),
|
||||
loadFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Comment,
|
||||
CommentChanged,
|
||||
Issue,
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -36,6 +37,21 @@ export const issuePageEvents = eventGroup({
|
||||
files?: File[];
|
||||
}>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
|
||||
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
||||
subtaskCreateRequested: type<{
|
||||
projectId: string;
|
||||
parentId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
}>(),
|
||||
checklistAddRequested: type<{ issueId: string; text: string }>(),
|
||||
checklistUpdateRequested: type<{
|
||||
issueId: string;
|
||||
itemId: string;
|
||||
changes: { text?: string; done?: boolean };
|
||||
}>(),
|
||||
checklistRemoveRequested: type<{ issueId: string; itemId: string }>(),
|
||||
attachRequested: type<{
|
||||
issueId: string;
|
||||
files: File[];
|
||||
@@ -57,7 +73,10 @@ export const issueApiEvents = eventGroup({
|
||||
loadFailed: type<string>(),
|
||||
commentsLoaded: type<Comment[]>(),
|
||||
historyLoaded: type<Activity[]>(),
|
||||
childrenLoaded: type<IssueSummary[]>(),
|
||||
commandSucceeded: type<string>(),
|
||||
/** A checklist step was saved (no toast for something so small). */
|
||||
checklistSaved: type<void>(),
|
||||
commandFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -67,6 +86,8 @@ export const issueRealtimeEvents = eventGroup({
|
||||
events: {
|
||||
/** Something about this ticket changed: refetch it. */
|
||||
issueTouched: type<{ issueId: string; version: number }>(),
|
||||
/** A ticket under this one changed, arrived or left: reload them. */
|
||||
childrenTouched: type<{ issueId: string }>(),
|
||||
commentChanged: type<CommentChanged>(),
|
||||
},
|
||||
});
|
||||
|
||||
27
libs/web/tracker/application/src/lib/facades/epics.facade.ts
Normal file
27
libs/web/tracker/application/src/lib/facades/epics.facade.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { epicsPageEvents } from '../events/epics.events';
|
||||
import { EpicsStore } from '../stores/epics.store';
|
||||
|
||||
/** "Épicas": top-level tickets that group others, with their progress. */
|
||||
@Injectable()
|
||||
export class EpicsFacade {
|
||||
private readonly store = inject(EpicsStore);
|
||||
private readonly dispatch = injectDispatch(epicsPageEvents);
|
||||
|
||||
/** By key. */
|
||||
readonly epics = this.store.epics;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
readonly loaded = this.store.loaded;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads the project's epics and keeps them up to date. */
|
||||
open(projectId: string): void {
|
||||
this.dispatch.opened({ projectId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of the epics view, for its route component. */
|
||||
export function provideEpicsFacade(): Provider[] {
|
||||
return [EpicsStore, EpicsFacade];
|
||||
}
|
||||
@@ -28,7 +28,13 @@ import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||
|
||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
||||
const issue = {
|
||||
id: 'i1',
|
||||
key: 'WEB-1',
|
||||
projectId: 'p1',
|
||||
links: [],
|
||||
checklist: [],
|
||||
};
|
||||
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
||||
|
||||
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
||||
@@ -48,6 +54,11 @@ function gateways() {
|
||||
issue: vi.fn(() => of(issue)),
|
||||
changeStatus: vi.fn(() => of(ok)),
|
||||
watch: vi.fn(() => of(ok)),
|
||||
children: vi.fn(() => of([])),
|
||||
create: vi.fn(() => of(ok)),
|
||||
setParent: vi.fn(() => of(ok)),
|
||||
addChecklistItem: vi.fn(() => of(ok)),
|
||||
updateChecklistItem: vi.fn(() => of(ok)),
|
||||
},
|
||||
list: {
|
||||
list: vi.fn(() => of([])),
|
||||
@@ -238,6 +249,42 @@ describe('IssueFacade', () => {
|
||||
expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true);
|
||||
});
|
||||
|
||||
it('breaks the ticket down: subtasks, parent and checklist', () => {
|
||||
const { issue, fakes } = setup();
|
||||
// The parent is looked up by its key first.
|
||||
fakes.issues.issue.mockImplementation(((key: string) =>
|
||||
of({
|
||||
id: key === 'WEB-1' ? 'i1' : 'e1',
|
||||
key,
|
||||
projectId: 'p1',
|
||||
links: [],
|
||||
checklist: [],
|
||||
})) as never);
|
||||
|
||||
issue.open('WEB-1');
|
||||
expect(fakes.issues.children).toHaveBeenCalledWith('p1', 'i1');
|
||||
issue.addSubtask(' Formulario ', 'TASK');
|
||||
issue.setParent('web-9');
|
||||
issue.addChecklistItem('Changelog');
|
||||
issue.updateChecklistItem('c1', { done: true });
|
||||
|
||||
expect(fakes.issues.create).toHaveBeenCalledWith({
|
||||
projectId: 'p1',
|
||||
parentId: 'i1',
|
||||
type: 'TASK',
|
||||
title: 'Formulario',
|
||||
});
|
||||
expect(fakes.issues.issue).toHaveBeenCalledWith('WEB-9');
|
||||
expect(fakes.issues.setParent).toHaveBeenCalledWith('i1', 'e1');
|
||||
expect(fakes.issues.addChecklistItem).toHaveBeenCalledWith(
|
||||
'i1',
|
||||
'Changelog',
|
||||
);
|
||||
expect(fakes.issues.updateChecklistItem).toHaveBeenCalledWith('i1', 'c1', {
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads files one by one, and under a new comment when sent with it', () => {
|
||||
const { issue, fakes } = setup();
|
||||
const a = new File(['a'], 'a.png', { type: 'image/png' });
|
||||
|
||||
@@ -26,6 +26,11 @@ export class IssueFacade {
|
||||
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). */
|
||||
@@ -94,6 +99,47 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
66
libs/web/tracker/application/src/lib/stores/epics.store.ts
Normal file
66
libs/web/tracker/application/src/lib/stores/epics.store.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
Epic,
|
||||
IssuesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { debounceTime, startWith, switchMap } from 'rxjs';
|
||||
import { epicsApiEvents, epicsPageEvents } from '../events/epics.events';
|
||||
import { describeError } from '../notifications';
|
||||
|
||||
interface EpicsState {
|
||||
epics: Epic[];
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A project's epics and their progress, live. Scoped to the epics view. */
|
||||
export const EpicsStore = signalStore(
|
||||
withState<EpicsState>({ epics: [], loaded: false, error: null }),
|
||||
withReducer(
|
||||
on(epicsPageEvents.opened, () => ({ loaded: false, error: null })),
|
||||
on(epicsApiEvents.loaded, ({ payload }) => ({
|
||||
epics: payload,
|
||||
loaded: true,
|
||||
})),
|
||||
on(epicsApiEvents.loadFailed, ({ payload }) => ({
|
||||
loaded: true,
|
||||
error: payload,
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
issues = inject(IssuesGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
) => ({
|
||||
// Any ticket of the project may move an epic's progress: reload, calmly.
|
||||
load$: events.on(epicsPageEvents.opened).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
realtime.issueChanges(payload.projectId).pipe(
|
||||
debounceTime(500),
|
||||
startWith(null),
|
||||
switchMap(() =>
|
||||
issues.epics(payload.projectId).pipe(
|
||||
mapResponse({
|
||||
next: (list) => epicsApiEvents.loaded(list),
|
||||
error: (error) =>
|
||||
epicsApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
AttachmentsGateway,
|
||||
Comment,
|
||||
CommentsGateway,
|
||||
checklistProgress,
|
||||
childProgress,
|
||||
Issue,
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
@@ -45,6 +48,8 @@ interface IssueState {
|
||||
issue: Issue | null;
|
||||
comments: Comment[];
|
||||
history: Activity[];
|
||||
/** Tickets hanging from this one. */
|
||||
children: IssueSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -55,10 +60,15 @@ export const IssueStore = signalStore(
|
||||
issue: null,
|
||||
comments: [],
|
||||
history: [],
|
||||
children: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
|
||||
withComputed(({ issue, children }, auth = inject(AuthFacade)) => ({
|
||||
childProgress: computed(() => childProgress(children())),
|
||||
checklistProgress: computed(() =>
|
||||
checklistProgress(issue()?.checklist ?? []),
|
||||
),
|
||||
me: computed(() => auth.user()?.id ?? null),
|
||||
watching: computed(
|
||||
() =>
|
||||
@@ -79,6 +89,7 @@ export const IssueStore = signalStore(
|
||||
issue: null,
|
||||
comments: [],
|
||||
history: [],
|
||||
children: [],
|
||||
})),
|
||||
on(issueApiEvents.loaded, ({ payload }) => ({
|
||||
issue: payload,
|
||||
@@ -90,6 +101,7 @@ export const IssueStore = signalStore(
|
||||
})),
|
||||
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
|
||||
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
|
||||
on(issueApiEvents.childrenLoaded, ({ payload }) => ({ children: payload })),
|
||||
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
|
||||
const others = state.comments.filter((c) => c.id !== payload.commentId);
|
||||
if (!payload.comment) return { comments: others };
|
||||
@@ -181,6 +193,44 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
children$: merge(
|
||||
loaded$.pipe(
|
||||
map(({ payload }) => payload),
|
||||
filter(
|
||||
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
|
||||
),
|
||||
),
|
||||
events.on(issueRealtimeEvents.childrenTouched).pipe(
|
||||
debounceTime(200),
|
||||
map(() => store.issue()),
|
||||
filter((issue): issue is Issue => !!issue),
|
||||
),
|
||||
).pipe(
|
||||
switchMap((issue) =>
|
||||
issues
|
||||
.children(issue.projectId, issue.id)
|
||||
.pipe(map((list) => issueApiEvents.childrenLoaded(list))),
|
||||
),
|
||||
),
|
||||
/** A ticket under this one changed, or one moved in or out. */
|
||||
liveChildren$: events.on(issuePageEvents.opened).pipe(
|
||||
switchMap(() => loaded$.pipe(map(({ payload }) => payload))),
|
||||
filter(
|
||||
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
|
||||
),
|
||||
switchMap((issue) =>
|
||||
realtime.issueChanges(issue.projectId).pipe(
|
||||
filter(
|
||||
(change) =>
|
||||
change.issue.parent?.id === issue.id ||
|
||||
store.children().some((c) => c.id === change.issue.id),
|
||||
),
|
||||
map(() =>
|
||||
issueRealtimeEvents.childrenTouched({ issueId: issue.id }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
|
||||
debounceTime(150),
|
||||
switchMap(({ payload }) => fetchIssue(payload.issueId)),
|
||||
@@ -242,6 +292,82 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.parentRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
payload.parentKey
|
||||
? issues
|
||||
.issue(payload.parentKey.trim().toUpperCase())
|
||||
.pipe(
|
||||
switchMap((parent) =>
|
||||
issues.setParent(payload.issueId, parent.id),
|
||||
),
|
||||
)
|
||||
: issues.setParent(payload.issueId, null),
|
||||
payload.parentKey
|
||||
? `Ahora cuelga de ${payload.parentKey.trim().toUpperCase()}`
|
||||
: 'Ya no cuelga de ningún ticket',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.subtaskCreateRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.create(payload),
|
||||
'Subtarea creada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.checklistAddRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.addChecklistItem(payload.issueId, payload.text),
|
||||
'Paso añadido',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.checklistUpdateRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
issues
|
||||
.updateChecklistItem(
|
||||
payload.issueId,
|
||||
payload.itemId,
|
||||
payload.changes,
|
||||
)
|
||||
.pipe(
|
||||
// Ticking a step is not worth a toast; only failures are.
|
||||
mapResponse({
|
||||
next: () => issueApiEvents.checklistSaved(),
|
||||
error: (error) =>
|
||||
issueApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.checklistRemoveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.removeChecklistItem(payload.issueId, payload.itemId),
|
||||
'Paso eliminado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.linkRequested)
|
||||
.pipe(
|
||||
|
||||
Reference in New Issue
Block a user