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:
@@ -1,43 +1,12 @@
|
||||
/** Internal type. DO NOT USE DIRECTLY. */
|
||||
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||
/** Internal type. DO NOT USE DIRECTLY. */
|
||||
export type Incremental<T> =
|
||||
| T
|
||||
| {
|
||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
||||
};
|
||||
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
export type MeQueryVariables = Exact<{ [key: string]: never }>;
|
||||
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type MeQuery = {
|
||||
me: { id: string; name: string; email: string; appRoles: Array<string> };
|
||||
};
|
||||
|
||||
export const MeDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'Me' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'me' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
||||
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
|
||||
|
||||
|
||||
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
||||
@@ -6,3 +6,4 @@ export * from './lib/facades/issue.facade';
|
||||
export * from './lib/facades/home.facade';
|
||||
export * from './lib/facades/explore.facade';
|
||||
export * from './lib/facades/inbox.facade';
|
||||
export * from './lib/facades/epics.facade';
|
||||
|
||||
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(
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
export * from './lib/models/mentions';
|
||||
export * from './lib/models/hierarchy';
|
||||
|
||||
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StatusCategory } from './enums';
|
||||
import { checklistProgress, childProgress } from './hierarchy';
|
||||
import { IssueSummary } from './models';
|
||||
|
||||
const child = (statusCategory: StatusCategory) =>
|
||||
({ statusCategory }) as IssueSummary;
|
||||
|
||||
describe('hierarchy', () => {
|
||||
it('counts done and cancelled subtasks as finished', () => {
|
||||
expect(
|
||||
childProgress([
|
||||
child(StatusCategory.DONE),
|
||||
child(StatusCategory.CANCELLED),
|
||||
child(StatusCategory.IN_PROGRESS),
|
||||
]),
|
||||
).toMatchObject({ total: 3, finished: 2, percent: 67 });
|
||||
expect(childProgress([]).percent).toBe(0);
|
||||
});
|
||||
|
||||
it('counts ticked checklist steps', () => {
|
||||
expect(
|
||||
checklistProgress([
|
||||
{ id: 'a', text: 'a', done: true },
|
||||
{ id: 'b', text: 'b', done: false },
|
||||
]),
|
||||
).toEqual({ done: 1, total: 2 });
|
||||
});
|
||||
});
|
||||
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChecklistItem, ChildProgress, IssueSummary } from './models';
|
||||
import { StatusCategory } from './enums';
|
||||
|
||||
const FINISHED: StatusCategory[] = [
|
||||
StatusCategory.DONE,
|
||||
StatusCategory.CANCELLED,
|
||||
];
|
||||
|
||||
/** Progress of the tickets hanging from one (story points are not loaded here). */
|
||||
export function childProgress(children: IssueSummary[]): ChildProgress {
|
||||
const finished = children.filter((c) =>
|
||||
FINISHED.includes(c.statusCategory),
|
||||
).length;
|
||||
return {
|
||||
total: children.length,
|
||||
finished,
|
||||
percent:
|
||||
children.length === 0
|
||||
? 0
|
||||
: Math.round((finished / children.length) * 100),
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** "3/5": ticked steps of a checklist. */
|
||||
export function checklistProgress(items: ChecklistItem[]): {
|
||||
done: number;
|
||||
total: number;
|
||||
} {
|
||||
return { done: items.filter((i) => i.done).length, total: items.length };
|
||||
}
|
||||
@@ -30,10 +30,12 @@ export interface CreateIssueInput {
|
||||
figmaUrl?: string | null;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateIssueInput = Partial<
|
||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
|
||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId' | 'parentId'>
|
||||
> & {
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
@@ -40,6 +40,36 @@ export interface IssueLink {
|
||||
issueKey: string;
|
||||
}
|
||||
|
||||
/** The ticket another one hangs from. */
|
||||
export interface IssueParent {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** A step inside a ticket that does not deserve a ticket of its own. */
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** How far the tickets hanging from one have got. */
|
||||
export interface ChildProgress {
|
||||
total: number;
|
||||
/** Done or cancelled. */
|
||||
finished: number;
|
||||
percent: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
}
|
||||
|
||||
/** A top-level ticket that groups others. */
|
||||
export interface Epic {
|
||||
issue: IssueSummary;
|
||||
progress: ChildProgress;
|
||||
}
|
||||
|
||||
/** What boards and lists show. */
|
||||
export interface IssueSummary {
|
||||
id: string;
|
||||
@@ -55,6 +85,8 @@ export interface IssueSummary {
|
||||
assignee: UserRef | null;
|
||||
release: { id: string; name: string } | null;
|
||||
labels: string[];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
parent: IssueParent | null;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
@@ -88,6 +120,7 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
checklist: ChecklistItem[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Attachment,
|
||||
BoardColumn,
|
||||
Comment,
|
||||
Epic,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssuePage,
|
||||
@@ -89,6 +90,30 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
/** The signed-in person follows (or stops following) the ticket. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** Tickets hanging from `issueId`. */
|
||||
abstract children(
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): Observable<IssueSummary[]>;
|
||||
/** Places the ticket under `parentId` (null: back to the top). */
|
||||
abstract setParent(
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract epics(projectId: string): Observable<Epic[]>;
|
||||
abstract addChecklistItem(
|
||||
issueId: string,
|
||||
text: string,
|
||||
): Observable<CommandResult>;
|
||||
abstract updateChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Observable<CommandResult>;
|
||||
abstract removeChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChangeStatusInput,
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
Epic,
|
||||
Issue,
|
||||
IssuePage,
|
||||
IssuesGateway,
|
||||
@@ -15,18 +16,24 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
AddChecklistItemDocument,
|
||||
AssignIssueDocument,
|
||||
BoardDocument,
|
||||
ChangeIssueStatusDocument,
|
||||
CreateIssueDocument,
|
||||
EpicsDocument,
|
||||
IssueChildrenDocument,
|
||||
IssueDocument,
|
||||
LinkIssuesDocument,
|
||||
MyIssuesDocument,
|
||||
RemoveChecklistItemDocument,
|
||||
SearchIssuesDocument,
|
||||
SetIssueParentDocument,
|
||||
SetIssueReleaseDocument,
|
||||
UnlinkIssuesDocument,
|
||||
UnwatchIssueDocument,
|
||||
WatchIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
UpdateIssueDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
@@ -131,6 +138,61 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
|
||||
}
|
||||
|
||||
children(projectId: string, issueId: string): Observable<IssueSummary[]> {
|
||||
return this.gql
|
||||
.query(IssueChildrenDocument, {
|
||||
filter: { projectId, parentId: issueId },
|
||||
})
|
||||
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
setParent(
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(SetIssueParentDocument, { issueId, parentId })
|
||||
.pipe(map((d) => toCommandResult(d.setIssueParent)));
|
||||
}
|
||||
|
||||
epics(projectId: string): Observable<Epic[]> {
|
||||
return this.gql.query(EpicsDocument, { projectId }).pipe(
|
||||
map((d) =>
|
||||
d.epics.map((epic) => ({
|
||||
issue: toIssueSummary(epic.issue),
|
||||
progress: { ...epic.progress },
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
addChecklistItem(issueId: string, text: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddChecklistItemDocument, { input: { issueId, text } })
|
||||
.pipe(map((d) => toCommandResult(d.addChecklistItem)));
|
||||
}
|
||||
|
||||
updateChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(UpdateChecklistItemDocument, {
|
||||
input: { issueId, itemId, ...changes },
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.updateChecklistItem)));
|
||||
}
|
||||
|
||||
removeChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(RemoveChecklistItemDocument, { issueId, itemId })
|
||||
.pipe(map((d) => toCommandResult(d.removeChecklistItem)));
|
||||
}
|
||||
|
||||
setRelease(
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
|
||||
@@ -69,6 +69,11 @@ fragment IssueSummaryFields on Issue {
|
||||
id
|
||||
name
|
||||
}
|
||||
parent {
|
||||
id
|
||||
key
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
fragment IssueFields on Issue {
|
||||
@@ -91,6 +96,11 @@ fragment IssueFields on Issue {
|
||||
watchers {
|
||||
...UserRefFields
|
||||
}
|
||||
checklist {
|
||||
id
|
||||
text
|
||||
done
|
||||
}
|
||||
attachments {
|
||||
id
|
||||
fileName
|
||||
|
||||
@@ -7,6 +7,11 @@ export type Incremental<T> =
|
||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
||||
};
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
export type AddChecklistItemInput = {
|
||||
issueId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AreaOptionInput = {
|
||||
archived?: boolean;
|
||||
id: string;
|
||||
@@ -30,6 +35,8 @@ export type CreateIssueInput = {
|
||||
/** Figma frame for UI changes */
|
||||
figmaUrl?: string | null | undefined;
|
||||
labels?: Array<string> | null | undefined;
|
||||
/** Epic or ticket of the same project the new one breaks down */
|
||||
parentId?: string | null | undefined;
|
||||
priority?: IssuePriority | null | undefined;
|
||||
projectId: string;
|
||||
releaseId?: string | null | undefined;
|
||||
@@ -61,6 +68,8 @@ export type IssueFilterInput = {
|
||||
assigneeId?: string | null | undefined;
|
||||
categories?: Array<StatusCategory> | null | undefined;
|
||||
labels?: Array<string> | null | undefined;
|
||||
/** Only the tickets hanging from this one */
|
||||
parentId?: string | null | undefined;
|
||||
priorities?: Array<IssuePriority> | null | undefined;
|
||||
projectId: string;
|
||||
releaseId?: string | null | undefined;
|
||||
@@ -139,6 +148,13 @@ export type StatusTransitionsInput = {
|
||||
to: Array<string>;
|
||||
};
|
||||
|
||||
export type UpdateChecklistItemInput = {
|
||||
done?: boolean | null | undefined;
|
||||
issueId: string;
|
||||
itemId: string;
|
||||
text?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type UpdateIssueInput = {
|
||||
/** An area id */
|
||||
area?: string | null | undefined;
|
||||
@@ -219,6 +235,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
};
|
||||
|
||||
export type IssueFieldsFragment = {
|
||||
@@ -249,6 +266,7 @@ export type IssueFieldsFragment = {
|
||||
issueKey: string;
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -260,6 +278,7 @@ export type IssueFieldsFragment = {
|
||||
}>;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
};
|
||||
|
||||
export type CommentFieldsFragment = {
|
||||
@@ -326,6 +345,7 @@ export type BoardQuery = {
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
@@ -364,6 +384,7 @@ export type IssueQuery = {
|
||||
issueKey: string;
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -375,6 +396,7 @@ export type IssueQuery = {
|
||||
}>;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -400,6 +422,7 @@ export type SearchIssuesQuery = {
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -549,10 +572,106 @@ export type MyIssuesQuery = {
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type IssueChildrenQueryVariables = Exact<{
|
||||
filter: IssueFilterInput;
|
||||
}>;
|
||||
|
||||
export type IssueChildrenQuery = {
|
||||
issues: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type EpicsQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type EpicsQuery = {
|
||||
epics: Array<{
|
||||
issue: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
};
|
||||
progress: {
|
||||
total: number;
|
||||
finished: number;
|
||||
percent: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SetIssueParentMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
parentId?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type SetIssueParentMutation = {
|
||||
setIssueParent: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type AddChecklistItemMutationVariables = Exact<{
|
||||
input: AddChecklistItemInput;
|
||||
}>;
|
||||
|
||||
export type AddChecklistItemMutation = {
|
||||
addChecklistItem: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type UpdateChecklistItemMutationVariables = Exact<{
|
||||
input: UpdateChecklistItemInput;
|
||||
}>;
|
||||
|
||||
export type UpdateChecklistItemMutation = {
|
||||
updateChecklistItem: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type RemoveChecklistItemMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
itemId: string;
|
||||
}>;
|
||||
|
||||
export type RemoveChecklistItemMutation = {
|
||||
removeChecklistItem: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type NotificationFieldsFragment = {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
@@ -987,6 +1106,7 @@ export type IssueChangedSubscription = {
|
||||
version: number;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1346,6 +1466,18 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1431,6 +1563,18 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklist' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
@@ -1524,6 +1668,18 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1926,6 +2082,18 @@ export const BoardDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2045,6 +2213,18 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2110,6 +2290,18 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklist' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
@@ -2271,6 +2463,18 @@ export const SearchIssuesDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -3474,11 +3678,652 @@ export const MyIssuesDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
||||
export const IssueChildrenDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'IssueChildren' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'IssueFilterInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issues' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: { kind: 'IntValue', value: '200' },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'items' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<IssueChildrenQuery, IssueChildrenQueryVariables>;
|
||||
export const EpicsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'Epics' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'epics' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issue' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'progress' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finished' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'percent' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'storyPoints' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finishedStoryPoints' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<EpicsQuery, EpicsQueryVariables>;
|
||||
export const SetIssueParentDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'SetIssueParent' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'parentId' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'setIssueParent' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'parentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'parentId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
SetIssueParentMutation,
|
||||
SetIssueParentMutationVariables
|
||||
>;
|
||||
export const AddChecklistItemDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'AddChecklistItem' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AddChecklistItemInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'addChecklistItem' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
AddChecklistItemMutation,
|
||||
AddChecklistItemMutationVariables
|
||||
>;
|
||||
export const UpdateChecklistItemDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UpdateChecklistItem' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UpdateChecklistItemInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'updateChecklistItem' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UpdateChecklistItemMutation,
|
||||
UpdateChecklistItemMutationVariables
|
||||
>;
|
||||
export const RemoveChecklistItemDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RemoveChecklistItem' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'itemId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'removeChecklistItem' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'itemId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'itemId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
RemoveChecklistItemMutation,
|
||||
RemoveChecklistItemMutationVariables
|
||||
>;
|
||||
export const MyNotificationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -6163,6 +7008,18 @@ export const IssueChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -129,3 +129,50 @@ query MyIssues(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query IssueChildren($filter: IssueFilterInput!) {
|
||||
issues(filter: $filter, limit: 200) {
|
||||
items {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query Epics($projectId: ID!) {
|
||||
epics(projectId: $projectId) {
|
||||
issue {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
progress {
|
||||
total
|
||||
finished
|
||||
percent
|
||||
storyPoints
|
||||
finishedStoryPoints
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SetIssueParent($issueId: ID!, $parentId: ID) {
|
||||
setIssueParent(issueId: $issueId, parentId: $parentId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddChecklistItem($input: AddChecklistItemInput!) {
|
||||
addChecklistItem(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdateChecklistItem($input: UpdateChecklistItemInput!) {
|
||||
updateChecklistItem(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation RemoveChecklistItem($issueId: ID!, $itemId: ID!) {
|
||||
removeChecklistItem(issueId: $issueId, itemId: $itemId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSkeletonComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
EpicsFacade,
|
||||
ProjectFacade,
|
||||
provideEpicsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { Epic } 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. */
|
||||
@Component({
|
||||
selector: 'ts-epics-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSkeletonComponent,
|
||||
],
|
||||
providers: [provideEpicsFacade()],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Una épica es un ticket que agrupa a otros. Cuelga tickets de ella desde
|
||||
su ficha («Subtareas») y aquí verás cuánto falta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (facade.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudieron cargar las épicas"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else if (!facade.loaded()) {
|
||||
<pi-skeleton shape="text" [lines]="6" />
|
||||
} @else if (facade.epics().length) {
|
||||
<ul class="ts-epics">
|
||||
@for (epic of facade.epics(); track epic.issue.id) {
|
||||
<li class="ts-epic ts-panel" [style.--tone]="typeTone(epic)">
|
||||
<header class="ts-epic__head">
|
||||
<a
|
||||
class="ts-key"
|
||||
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||
>{{ epic.issue.key }}</a
|
||||
>
|
||||
<a
|
||||
class="ts-epic__title"
|
||||
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||
>{{ epic.issue.title }}</a
|
||||
>
|
||||
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||
statusLabel(epic)
|
||||
}}</span>
|
||||
</header>
|
||||
<pi-progress-bar
|
||||
[label]="summary(epic)"
|
||||
[value]="epic.progress.percent"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-empty-state
|
||||
icon="flag"
|
||||
title="Aún no hay épicas"
|
||||
description="Abre un ticket grande y crea sus subtareas desde «Subtareas»: aparecerá aquí con su progreso."
|
||||
/>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-epics {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-epic {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border-left: 3px solid var(--tone, var(--ts-brand));
|
||||
}
|
||||
.ts-epic__head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-epic__title {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class EpicsPage {
|
||||
protected readonly facade = inject(EpicsFacade);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (projectId) untracked(() => this.facade.open(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected summary(epic: Epic): string {
|
||||
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
||||
epic.progress;
|
||||
const points = storyPoints
|
||||
? ` · ${finishedStoryPoints}/${storyPoints} puntos`
|
||||
: '';
|
||||
return `${percent}% · ${finished} de ${total} tickets terminados${points}`;
|
||||
}
|
||||
|
||||
protected typeTone = (epic: Epic) =>
|
||||
typeLook(this.project.workflow(), epic.issue.type).tone;
|
||||
protected statusTone = (epic: Epic) =>
|
||||
statusLook(this.project.workflow(), epic.issue.status).tone;
|
||||
protected statusLabel = (epic: Epic) =>
|
||||
statusLook(this.project.workflow(), epic.issue.status).label;
|
||||
}
|
||||
@@ -192,6 +192,92 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
.ts-inline-form--two {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
// Breakdown: the ticket it hangs from, its checklist and its subtasks.
|
||||
.ts-parent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-parent__title {
|
||||
overflow: hidden;
|
||||
max-width: 18rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-checklist,
|
||||
.ts-children {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ts-checklist__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.is-done {
|
||||
color: var(--ts-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-children {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ts-child {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
|
||||
.ts-comment__avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-child__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-parent-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.ts-inline-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .ts-muted {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Comments: a thread with initials on a rail.
|
||||
.ts-comment {
|
||||
display: grid;
|
||||
|
||||
@@ -14,11 +14,13 @@ import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
@@ -68,11 +70,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
@@ -96,6 +100,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||
status().label
|
||||
}}</span>
|
||||
@if (issue.parent; as parent) {
|
||||
<a
|
||||
class="ts-parent"
|
||||
[routerLink]="['..', parent.key]"
|
||||
[title]="'Cuelga de ' + parent.key"
|
||||
>
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span class="ts-key">{{ parent.key }}</span>
|
||||
<span class="ts-parent__title">{{ parent.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
@@ -161,6 +176,168 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Checklist
|
||||
@if (issue.checklist.length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.checklistProgress().done }}/{{
|
||||
facade.checklistProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (issue.checklist.length) {
|
||||
<ul class="ts-checklist">
|
||||
@for (item of issue.checklist; track item.id) {
|
||||
<li class="ts-checklist__item" [class.is-done]="item.done">
|
||||
<pi-checkbox
|
||||
[label]="item.text"
|
||||
[checked]="item.done"
|
||||
[disabled]="!project.canContribute()"
|
||||
(checkedChange)="
|
||||
facade.updateChecklistItem(item.id, { done: $event })
|
||||
"
|
||||
/>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="close"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="'Quitar «' + item.text + '»'"
|
||||
(execute)="facade.removeChecklistItem(item.id)"
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin pasos.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); addChecklistItem()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Nuevo paso, p. ej. Actualizar el changelog"
|
||||
[(value)]="checklistText"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir"
|
||||
[disabled]="!checklistText().trim()"
|
||||
(execute)="addChecklistItem()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Subtareas
|
||||
@if (facade.children().length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.childProgress().finished }}/{{
|
||||
facade.childProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (facade.children().length) {
|
||||
<pi-progress-bar
|
||||
[label]="facade.childProgress().percent + '% terminado'"
|
||||
[value]="facade.childProgress().percent"
|
||||
/>
|
||||
<ul class="ts-children">
|
||||
@for (child of facade.children(); track child.id) {
|
||||
<li class="ts-child">
|
||||
<a class="ts-key" [routerLink]="['..', child.key]">{{
|
||||
child.key
|
||||
}}</a>
|
||||
<span class="ts-child__title">{{ child.title }}</span>
|
||||
<span
|
||||
class="ts-pill"
|
||||
[style.--tone]="statusTone(child.status)"
|
||||
>{{ statusLabel(child.status) }}</span
|
||||
>
|
||||
@if (child.assignee) {
|
||||
<span
|
||||
class="ts-comment__avatar"
|
||||
[title]="child.assignee.name"
|
||||
>{{ initialsOf(child.assignee.name) }}</span
|
||||
>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin subtareas.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form"
|
||||
(submit)="$event.preventDefault(); addSubtask()"
|
||||
novalidate
|
||||
>
|
||||
<pi-select-field
|
||||
[options]="subtaskTypeOptions()"
|
||||
[value]="subtaskType() ?? defaultSubtaskType()"
|
||||
(valueChange)="subtaskType.set($event)"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Título de la subtarea"
|
||||
[(value)]="subtaskTitle"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Crear"
|
||||
[disabled]="!subtaskTitle().trim()"
|
||||
(execute)="addSubtask()"
|
||||
/>
|
||||
</form>
|
||||
<div class="ts-parent-form">
|
||||
@if (issue.parent; as parent) {
|
||||
<span class="ts-muted"
|
||||
>Cuelga de
|
||||
<a class="ts-key" [routerLink]="['..', parent.key]">{{
|
||||
parent.key
|
||||
}}</a></span
|
||||
>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Sacar"
|
||||
(execute)="facade.setParent(null)"
|
||||
/>
|
||||
} @else {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); setParent()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||
[(value)]="parentKey"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Colgar"
|
||||
[disabled]="!parentKey().trim()"
|
||||
(execute)="setParent()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Adjuntos
|
||||
@@ -639,6 +816,23 @@ export class IssuePage {
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
protected readonly parentKey = signal('');
|
||||
/** Active types; subtasks start as tasks when the project has them. */
|
||||
protected readonly subtaskTypeOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.issueTypes.filter((t) => !t.archived)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
);
|
||||
protected readonly defaultSubtaskType = computed(() => {
|
||||
const ids = this.subtaskTypeOptions().map((o) => o.value);
|
||||
return ids.includes('TASK') ? 'TASK' : (ids[0] ?? '');
|
||||
});
|
||||
protected readonly pendingFiles = signal<File[]>([]);
|
||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||
|
||||
@@ -687,6 +881,28 @@ export class IssuePage {
|
||||
? LINK[link.type].outward
|
||||
: LINK[link.type].inward;
|
||||
|
||||
protected addChecklistItem(): void {
|
||||
const text = this.checklistText().trim();
|
||||
if (!text) return;
|
||||
this.facade.addChecklistItem(text);
|
||||
this.checklistText.set('');
|
||||
}
|
||||
|
||||
protected addSubtask(): void {
|
||||
const title = this.subtaskTitle().trim();
|
||||
const type = this.subtaskType() ?? this.defaultSubtaskType();
|
||||
if (!title || !type) return;
|
||||
this.facade.addSubtask(title, type);
|
||||
this.subtaskTitle.set('');
|
||||
}
|
||||
|
||||
protected setParent(): void {
|
||||
const key = this.parentKey().trim();
|
||||
if (!key) return;
|
||||
this.facade.setParent(key);
|
||||
this.parentKey.set('');
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingStatus = to;
|
||||
|
||||
@@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
}
|
||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
||||
|
||||
@@ -38,6 +38,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/issue.page').then((m) => m.IssuePage),
|
||||
},
|
||||
{
|
||||
path: 'epics',
|
||||
title: 'Épicas · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/epics.page').then((m) => m.EpicsPage),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
title: 'Releases · TaskStream',
|
||||
|
||||
@@ -40,6 +40,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
>
|
||||
<i></i><i></i><i></i><i></i>
|
||||
</span>
|
||||
@if (issue().parent; as parent) {
|
||||
<span class="ts-card__meta" [attr.title]="'Cuelga de ' + parent.title"
|
||||
>↑ {{ parent.key }}</span
|
||||
>
|
||||
}
|
||||
@if (issue().release; as release) {
|
||||
<span class="ts-card__meta" title="Release"
|
||||
>◆ {{ release.name }}</span
|
||||
|
||||
Reference in New Issue
Block a user