feat: follow an epic with everything under it
«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).
API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ export const issuePageEvents = eventGroup({
|
||||
}>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||
trashRequested: type<{
|
||||
issueId: string;
|
||||
trashed: boolean;
|
||||
|
||||
@@ -120,6 +120,13 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) =>
|
||||
|
||||
@@ -379,6 +379,20 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.followTreeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.followTree(payload.issueId, payload.on),
|
||||
payload.on
|
||||
? 'Ahora sigues este ticket con todo lo que cuelga de él'
|
||||
: 'Ya no sigues lo que cuelga de este ticket',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pinRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface Epic {
|
||||
progress: ChildProgress;
|
||||
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
||||
links: IssueLink[];
|
||||
/** Whether I follow it with everything under it. */
|
||||
followingTree: boolean;
|
||||
}
|
||||
|
||||
/** What a saved view narrows the board to; empty: everything. */
|
||||
@@ -192,6 +194,8 @@ export interface Issue extends IssueSummary {
|
||||
watchers: UserRef[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** Whether I follow it with everything under it (an epic). */
|
||||
followingTree: boolean;
|
||||
/** Its comments marked as decisions, oldest first. */
|
||||
decisions: Comment[];
|
||||
checklist: ChecklistItem[];
|
||||
|
||||
@@ -148,6 +148,8 @@ export abstract class IssuesGateway {
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Follows the ticket with everything under it (or stops). */
|
||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
UnfollowTreeDocument,
|
||||
SearchDecisionsDocument,
|
||||
PinIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
@@ -218,6 +220,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
),
|
||||
);
|
||||
}
|
||||
followTree(issueId: string, on: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = on
|
||||
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||
: this.gql.mutate(UnfollowTreeDocument, { issueId });
|
||||
return done$.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = pinned
|
||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||
@@ -259,6 +268,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
issue: toIssueSummary(epic.issue),
|
||||
progress: { ...epic.progress },
|
||||
links: epic.issue.links.map((l) => toIssueLink(l)),
|
||||
followingTree: epic.issue.followingTree,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
issue: toIssueSummary(e.issue),
|
||||
progress: { ...e.progress },
|
||||
links: [],
|
||||
// The portfolio does not offer following.
|
||||
followingTree: false,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue {
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
followingTree
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
@@ -688,6 +688,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
|
||||
export type IssueFieldsFragment = {
|
||||
pinned: boolean;
|
||||
followingTree: boolean;
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
createdAt: string;
|
||||
@@ -933,6 +934,7 @@ export type IssueQueryVariables = Exact<{
|
||||
export type IssueQuery = {
|
||||
issue: {
|
||||
pinned: boolean;
|
||||
followingTree: boolean;
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
createdAt: string;
|
||||
@@ -1151,6 +1153,18 @@ export type MyPinsQuery = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type FollowTreeMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type FollowTreeMutation = { followTree: boolean };
|
||||
|
||||
export type UnfollowTreeMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type UnfollowTreeMutation = { unfollowTree: boolean };
|
||||
|
||||
export type PinIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
@@ -1321,6 +1335,7 @@ export type EpicsQueryVariables = Exact<{
|
||||
export type EpicsQuery = {
|
||||
epics: Array<{
|
||||
issue: {
|
||||
followingTree: boolean;
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
@@ -3820,6 +3835,7 @@ export const IssueFieldsFragmentDoc = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
@@ -6420,6 +6436,7 @@ export const IssueDocument = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
@@ -7452,6 +7469,93 @@ export const MyPinsDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
||||
export const FollowTreeDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'FollowTree' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'followTree' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<FollowTreeMutation, FollowTreeMutationVariables>;
|
||||
export const UnfollowTreeDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UnfollowTree' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unfollowTree' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UnfollowTreeMutation,
|
||||
UnfollowTreeMutationVariables
|
||||
>;
|
||||
export const PinIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -8633,6 +8737,10 @@ export const EpicsDocument = {
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'followingTree' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'links' },
|
||||
|
||||
@@ -101,6 +101,14 @@ query MyPins {
|
||||
}
|
||||
}
|
||||
|
||||
mutation FollowTree($issueId: ID!) {
|
||||
followTree(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation UnfollowTree($issueId: ID!) {
|
||||
unfollowTree(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation PinIssue($issueId: ID!) {
|
||||
pinIssue(issueId: $issueId)
|
||||
}
|
||||
@@ -185,6 +193,7 @@ query Epics($projectId: ID!) {
|
||||
epics(projectId: $projectId) {
|
||||
issue {
|
||||
...IssueSummaryFields
|
||||
followingTree
|
||||
links {
|
||||
type
|
||||
direction
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiEmptyStateComponent,
|
||||
@@ -17,7 +20,8 @@ import {
|
||||
ProjectFacade,
|
||||
provideEpicsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { Epic } from '@task-stream/web/tracker/domain';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { Epic, IssuesGateway } from '@task-stream/web/tracker/domain';
|
||||
import { statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** /p/:key/epics — the big pieces of work and how far each has got. */
|
||||
@@ -69,6 +73,22 @@ import { statusLook, typeLook } from '../ui/copy';
|
||||
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||
statusLabel(epic)
|
||||
}}</span>
|
||||
@if (project.myRole()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-epic__follow"
|
||||
[class.is-on]="following(epic)"
|
||||
[attr.aria-pressed]="following(epic)"
|
||||
[attr.title]="
|
||||
following(epic)
|
||||
? 'Dejar de seguir esta épica y sus tickets'
|
||||
: 'Avisarme de esta épica y de todos sus tickets, también los que se añadan'
|
||||
"
|
||||
(click)="toggleFollow(epic)"
|
||||
>
|
||||
{{ following(epic) ? 'Siguiendo todo' : 'Seguir todo' }}
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
<pi-progress-bar
|
||||
[label]="summary(epic)"
|
||||
@@ -86,6 +106,31 @@ import { statusLook, typeLook } from '../ui/copy';
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
/* A small pill, not @pi/ui's sized button. */
|
||||
.ts-epic__follow {
|
||||
flex: none;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.1875rem 0.625rem !important;
|
||||
border: 1px solid var(--ts-line-strong);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ts-ink-soft);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--ts-brand);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
&.is-on {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-epics {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -120,7 +165,12 @@ import { statusLook, typeLook } from '../ui/copy';
|
||||
})
|
||||
export class EpicsPage {
|
||||
protected readonly facade = inject(EpicsFacade);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly issues = inject(IssuesGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
/** Changed here since they loaded: epic id → following. */
|
||||
private readonly changed = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -129,6 +179,32 @@ export class EpicsPage {
|
||||
});
|
||||
}
|
||||
|
||||
protected following(epic: Epic): boolean {
|
||||
return this.changed().get(epic.issue.id) ?? epic.followingTree;
|
||||
}
|
||||
|
||||
protected toggleFollow(epic: Epic): void {
|
||||
const on = !this.following(epic);
|
||||
const set = (value: boolean) =>
|
||||
this.changed.update((m) => new Map(m).set(epic.issue.id, value));
|
||||
set(on);
|
||||
this.issues
|
||||
.followTree(epic.issue.id, on)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () =>
|
||||
this.toast.success(
|
||||
on
|
||||
? `Sigues ${epic.issue.key} y todo lo que cuelga de ella`
|
||||
: `Ya no sigues ${epic.issue.key}`,
|
||||
),
|
||||
error: () => {
|
||||
set(!on);
|
||||
this.toast.error('No se pudo cambiar');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected summary(epic: Epic): string {
|
||||
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
||||
epic.progress;
|
||||
|
||||
@@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="facade.setWatching(!facade.watching())"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
project.myRole() &&
|
||||
(facade.children().length || issue.followingTree)
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="kanban"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="
|
||||
issue.followingTree
|
||||
? 'Siguiendo todo · ' + facade.children().length
|
||||
: 'Seguir todo'
|
||||
"
|
||||
[attr.aria-pressed]="issue.followingTree"
|
||||
[attr.title]="
|
||||
issue.followingTree
|
||||
? 'Dejar de seguir lo que cuelga de este ticket'
|
||||
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
|
||||
"
|
||||
(execute)="facade.setFollowingTree(!issue.followingTree)"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
icon="pin"
|
||||
|
||||
Reference in New Issue
Block a user