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:
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