Gestion issue epic dans milestone
This commit is contained in:
@@ -120,21 +120,27 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Milestone</label>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select aria-label="Milestone" class="form-select form-select-sm" [ngModel]="currentMilestoneId" (ngModelChange)="onMilestoneChange($event)">
|
||||
<option [ngValue]="null">—</option>
|
||||
@for (m of milestones(); track m.id) {
|
||||
<option [ngValue]="m.id">{{ m.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (currentMilestoneId !== null) {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToMilestone()" title="Voir le Milestone">↗</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="field-label">Milestone</label>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select
|
||||
aria-label="Milestone"
|
||||
class="form-select form-select-sm"
|
||||
[ngModel]="currentMilestoneId"
|
||||
(ngModelChange)="onMilestoneChange($event)"
|
||||
[disabled]="isChildOfEpic"
|
||||
>
|
||||
<option [ngValue]="null">—</option>
|
||||
@for (m of milestones(); track m.id) {
|
||||
<option [ngValue]="m.id">{{ m.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (currentMilestoneId !== null) {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToMilestone()" title="Voir le Milestone">↗</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -621,6 +621,107 @@ describe('IssueDetail — existing issue', () => {
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChildOfEpic', () => {
|
||||
it('is false when issue has no epic', () => {
|
||||
(component as any).issue.epic = '';
|
||||
expect((component as any).isChildOfEpic).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when issue belongs to an epic', () => {
|
||||
(component as any).issue.epic = 'My Epic';
|
||||
expect((component as any).isChildOfEpic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMilestoneChange — epic propagation', () => {
|
||||
beforeEach(() => {
|
||||
(component as any).issue.type = 'Epic';
|
||||
(component as any).issue.name = 'Big Epic';
|
||||
store.upsert(makeIssue({ id: 2, name: 'Child 1', epic: 'Big Epic' }));
|
||||
store.upsert(makeIssue({ id: 3, name: 'Child 2', epic: 'Big Epic' }));
|
||||
});
|
||||
|
||||
it('adds epic and all children to the selected milestone', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]);
|
||||
await (component as any).onMilestoneChange(10);
|
||||
expect(milestonesStore.getById(10)?.issueIds).toContain(1);
|
||||
expect(milestonesStore.getById(10)?.issueIds).toContain(2);
|
||||
expect(milestonesStore.getById(10)?.issueIds).toContain(3);
|
||||
});
|
||||
|
||||
it('removes epic and all children from the previous milestone', async () => {
|
||||
milestonesStore.seed([
|
||||
makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1, 2, 3] }),
|
||||
makeMilestone({ id: 20, name: 'Sprint B', issueIds: [] }),
|
||||
]);
|
||||
await (component as any).onMilestoneChange(20);
|
||||
expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0);
|
||||
expect(milestonesStore.getById(20)?.issueIds).toContain(1);
|
||||
expect(milestonesStore.getById(20)?.issueIds).toContain(2);
|
||||
expect(milestonesStore.getById(20)?.issueIds).toContain(3);
|
||||
});
|
||||
|
||||
it('removes epic and all children from milestone when set to null', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1, 2, 3] })]);
|
||||
await (component as any).onMilestoneChange(null);
|
||||
expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmCreateInEpic — milestone propagation', () => {
|
||||
beforeEach(() => {
|
||||
(component as any).issue.type = 'Epic';
|
||||
(component as any).issue.name = 'My Epic';
|
||||
});
|
||||
|
||||
it('adds the created issue to the epic milestone', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]);
|
||||
(component as any).newIssueName = 'New Story';
|
||||
await (component as any).confirmCreateInEpic();
|
||||
const m = milestonesStore.getById(10);
|
||||
expect(m?.issueIds.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('does not touch milestones when epic has no milestone', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]);
|
||||
(component as any).newIssueName = 'New Story';
|
||||
await (component as any).confirmCreateInEpic();
|
||||
expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmAddToEpic — milestone propagation', () => {
|
||||
beforeEach(() => {
|
||||
(component as any).issue.type = 'Epic';
|
||||
(component as any).issue.name = 'My Epic';
|
||||
});
|
||||
|
||||
it('adds the issue to the epic milestone when epic has one', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]);
|
||||
(component as any).selectedEpicCandidateId = 2;
|
||||
await (component as any).confirmAddToEpic();
|
||||
expect(milestonesStore.getById(10)?.issueIds).toContain(2);
|
||||
});
|
||||
|
||||
it('moves the issue from its current milestone to the epic milestone', async () => {
|
||||
milestonesStore.seed([
|
||||
makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] }),
|
||||
makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] }),
|
||||
]);
|
||||
(component as any).selectedEpicCandidateId = 2;
|
||||
await (component as any).confirmAddToEpic();
|
||||
expect(milestonesStore.getById(10)?.issueIds).toContain(2);
|
||||
expect(milestonesStore.getById(20)?.issueIds).not.toContain(2);
|
||||
});
|
||||
|
||||
it('does not touch milestones when epic has no milestone', async () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] })]);
|
||||
(component as any).selectedEpicCandidateId = 2;
|
||||
await (component as any).confirmAddToEpic();
|
||||
expect(milestonesStore.getById(20)?.issueIds).toContain(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('IssueDetail — new issue route', () => {
|
||||
|
||||
@@ -166,7 +166,7 @@ export class IssueDetail {
|
||||
protected async confirmCreateInEpic(): Promise<void> {
|
||||
const name = this.newIssueName.trim();
|
||||
if (!name) return;
|
||||
await this.issuesStore.upsert({
|
||||
const created = await this.issuesStore.upsert({
|
||||
id: 0,
|
||||
type: 'Story',
|
||||
assignee: '',
|
||||
@@ -181,6 +181,13 @@ export class IssueDetail {
|
||||
status: 'draft',
|
||||
progress: 0,
|
||||
});
|
||||
const epicMilestone = this.currentMilestone;
|
||||
if (epicMilestone) {
|
||||
await this.milestonesStore.upsert({
|
||||
...epicMilestone,
|
||||
issueIds: [...epicMilestone.issueIds, created.id],
|
||||
});
|
||||
}
|
||||
this.showCreateInEpic = false;
|
||||
this.newIssueName = '';
|
||||
}
|
||||
@@ -200,6 +207,22 @@ export class IssueDetail {
|
||||
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
||||
if (target) {
|
||||
await this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
||||
const epicMilestone = this.currentMilestone;
|
||||
if (epicMilestone) {
|
||||
const prevMilestone = this.milestones().find((m) => m.issueIds.includes(target.id));
|
||||
if (prevMilestone && prevMilestone.id !== epicMilestone.id) {
|
||||
await this.milestonesStore.upsert({
|
||||
...prevMilestone,
|
||||
issueIds: prevMilestone.issueIds.filter((id) => id !== target.id),
|
||||
});
|
||||
}
|
||||
if (!epicMilestone.issueIds.includes(target.id)) {
|
||||
await this.milestonesStore.upsert({
|
||||
...epicMilestone,
|
||||
issueIds: [...epicMilestone.issueIds, target.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.showAddToEpic = false;
|
||||
@@ -210,6 +233,10 @@ export class IssueDetail {
|
||||
return this.issueTypeValue === 'Epic';
|
||||
}
|
||||
|
||||
protected get isChildOfEpic(): boolean {
|
||||
return !!this.issue.epic;
|
||||
}
|
||||
|
||||
protected onDescriptionPaste(event: ClipboardEvent): void {
|
||||
const ta = event.target as HTMLTextAreaElement;
|
||||
const start = ta.selectionStart;
|
||||
@@ -294,17 +321,23 @@ export class IssueDetail {
|
||||
|
||||
protected async onMilestoneChange(newMilestoneId: number | null): Promise<void> {
|
||||
if (this.isNewIssueRoute) return;
|
||||
const childIds = this.isEpicIssue
|
||||
? this.issues().filter((i) => i.epic === this.issue.name).map((i) => i.id)
|
||||
: [];
|
||||
const allIds = [this.issue.id, ...childIds];
|
||||
|
||||
const previous = this.currentMilestone;
|
||||
if (previous) {
|
||||
await this.milestonesStore.upsert({
|
||||
...previous,
|
||||
issueIds: previous.issueIds.filter((id) => id !== this.issue.id),
|
||||
issueIds: previous.issueIds.filter((id) => !allIds.includes(id)),
|
||||
});
|
||||
}
|
||||
if (newMilestoneId !== null) {
|
||||
const target = this.milestones().find((m) => m.id === newMilestoneId);
|
||||
if (target) {
|
||||
await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, this.issue.id] });
|
||||
const toAdd = allIds.filter((id) => !target.issueIds.includes(id));
|
||||
await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, ...toAdd] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user