@@ -83,6 +83,44 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Type dropdown (inline issue creation) */
|
||||
.type-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.type-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.type-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.3rem);
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Create issue split button dropdown */
|
||||
.create-issue-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.create-type-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.3rem);
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Dependency badges */
|
||||
.dep-badge {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -334,10 +334,31 @@
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<div class="card-footer bg-white">
|
||||
<div class="card-footer bg-white pt-3">
|
||||
@if (showCreateInEpic) {
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<div class="type-dropdown-wrapper">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1" (click)="toggleTypeDropdown()">
|
||||
<span class="type-icon" [style.background]="typeIcon(newIssueType).bg" [title]="newIssueType">{{ typeIcon(newIssueType).letter }}</span>
|
||||
{{ newIssueType }}
|
||||
<span class="dropdown-toggle ms-1"></span>
|
||||
</button>
|
||||
@if (showTypeDropdown) {
|
||||
<div class="type-backdrop" (click)="closeTypeDropdown()"></div>
|
||||
<ul class="type-dropdown dropdown-menu show">
|
||||
@for (type of typeOptions; track type) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2" [class.active]="newIssueType === type" (click)="selectNewIssueType(type)">
|
||||
<span class="type-icon" [style.background]="typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
|
||||
{{ type }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
#newIssueInput
|
||||
aria-label="Titre de la nouvelle issue"
|
||||
class="form-control form-control-sm dep-select"
|
||||
type="text"
|
||||
@@ -362,7 +383,25 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group create-issue-wrapper">
|
||||
<button type="button" class="btn btn-sm btn-primary" (click)="openCreateInEpic()">+ Créer une issue</button>
|
||||
<button type="button" class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split" (click)="toggleCreateDropdown()">
|
||||
<span class="visually-hidden">Choisir le type</span>
|
||||
</button>
|
||||
@if (showCreateDropdown) {
|
||||
<div class="create-backdrop" (click)="closeCreateDropdown()"></div>
|
||||
<ul class="create-type-dropdown dropdown-menu show">
|
||||
@for (type of typeOptions; track type) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2" (click)="openCreateInEpic(type)">
|
||||
<span class="type-icon" [style.background]="typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
|
||||
{{ type }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="openAddToEpic()">Ajouter une existante</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -737,6 +737,12 @@ describe('IssueDetail — existing issue', () => {
|
||||
expect((component as any).showAddToEpic).toBe(false);
|
||||
});
|
||||
|
||||
it('openCreateInEpic resets newIssueType to Story', () => {
|
||||
(component as any).newIssueType = 'Bug';
|
||||
(component as any).openCreateInEpic();
|
||||
expect((component as any).newIssueType).toBe('Story');
|
||||
});
|
||||
|
||||
it('cancelCreateInEpic hides the form and clears the name', () => {
|
||||
(component as any).showCreateInEpic = true;
|
||||
(component as any).newIssueName = 'Draft';
|
||||
@@ -745,7 +751,13 @@ describe('IssueDetail — existing issue', () => {
|
||||
expect((component as any).newIssueName).toBe('');
|
||||
});
|
||||
|
||||
it('confirmCreateInEpic creates a child issue linked to the epic', () => {
|
||||
it('cancelCreateInEpic resets newIssueType to Story', () => {
|
||||
(component as any).newIssueType = 'Task';
|
||||
(component as any).cancelCreateInEpic();
|
||||
expect((component as any).newIssueType).toBe('Story');
|
||||
});
|
||||
|
||||
it('confirmCreateInEpic creates a child issue with Story type by default', () => {
|
||||
(component as any).newIssueName = 'Child Issue';
|
||||
const before = store.issues().length;
|
||||
(component as any).confirmCreateInEpic();
|
||||
@@ -755,6 +767,14 @@ describe('IssueDetail — existing issue', () => {
|
||||
expect(created?.type).toBe('Story');
|
||||
});
|
||||
|
||||
it('confirmCreateInEpic creates a child issue with the selected type', async () => {
|
||||
(component as any).newIssueName = 'Child Bug';
|
||||
(component as any).newIssueType = 'Bug';
|
||||
await (component as any).confirmCreateInEpic();
|
||||
const created = store.issues().find((i) => i.name === 'Child Bug');
|
||||
expect(created?.type).toBe('Bug');
|
||||
});
|
||||
|
||||
it('confirmCreateInEpic resets the form', async () => {
|
||||
(component as any).newIssueName = 'Child Issue';
|
||||
await (component as any).confirmCreateInEpic();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { Component, effect, ElementRef, inject, ViewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
@@ -76,8 +76,13 @@ export class IssueDetail {
|
||||
private _descriptionBeforeEdit = '';
|
||||
protected showAddToEpic = false;
|
||||
protected selectedEpicCandidateId: number | null = null;
|
||||
@ViewChild('newIssueInput') private newIssueInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
protected showCreateInEpic = false;
|
||||
protected showCreateDropdown = false;
|
||||
protected newIssueName = '';
|
||||
protected newIssueType: IssueEntity['type'] = 'Story';
|
||||
protected showTypeDropdown = false;
|
||||
|
||||
protected readonly statusOptions = this.statusesStore.statuses;
|
||||
|
||||
@@ -221,15 +226,43 @@ export class IssueDetail {
|
||||
);
|
||||
}
|
||||
|
||||
protected openCreateInEpic(): void {
|
||||
protected openCreateInEpic(type: IssueEntity['type'] = 'Story'): void {
|
||||
this.newIssueName = '';
|
||||
this.newIssueType = type;
|
||||
this.showTypeDropdown = false;
|
||||
this.showCreateDropdown = false;
|
||||
this.showCreateInEpic = true;
|
||||
this.showAddToEpic = false;
|
||||
setTimeout(() => this.newIssueInput?.nativeElement.focus(), 0);
|
||||
}
|
||||
|
||||
protected cancelCreateInEpic(): void {
|
||||
this.showCreateInEpic = false;
|
||||
this.newIssueName = '';
|
||||
this.newIssueType = 'Story';
|
||||
this.showTypeDropdown = false;
|
||||
this.showCreateDropdown = false;
|
||||
}
|
||||
|
||||
protected toggleCreateDropdown(): void {
|
||||
this.showCreateDropdown = !this.showCreateDropdown;
|
||||
}
|
||||
|
||||
protected closeCreateDropdown(): void {
|
||||
this.showCreateDropdown = false;
|
||||
}
|
||||
|
||||
protected toggleTypeDropdown(): void {
|
||||
this.showTypeDropdown = !this.showTypeDropdown;
|
||||
}
|
||||
|
||||
protected closeTypeDropdown(): void {
|
||||
this.showTypeDropdown = false;
|
||||
}
|
||||
|
||||
protected selectNewIssueType(type: IssueEntity['type']): void {
|
||||
this.newIssueType = type;
|
||||
this.showTypeDropdown = false;
|
||||
}
|
||||
|
||||
protected async confirmCreateInEpic(): Promise<void> {
|
||||
@@ -237,7 +270,7 @@ export class IssueDetail {
|
||||
if (!name) return;
|
||||
const created = await this.issuesStore.upsert({
|
||||
id: 0,
|
||||
type: 'Story',
|
||||
type: this.newIssueType,
|
||||
assignee: '',
|
||||
epic: this.issue.name,
|
||||
name,
|
||||
|
||||
@@ -43,6 +43,42 @@
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.create-issue-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.create-type-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.3rem);
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.type-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.type-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.type-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.3rem);
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Issue name link in table */
|
||||
.issue-name-btn {
|
||||
border: none;
|
||||
|
||||
@@ -229,18 +229,38 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-body" [class.pt-0]="linkedIssues.length > 0">
|
||||
<div class="card-body" [class.pt-2]="linkedIssues.length > 0">
|
||||
@if (showCreateIssue) {
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="type-dropdown-wrapper">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1" (click)="toggleTypeDropdown()">
|
||||
<span class="type-icon" [style.background]="typeIcon(newIssueType).bg" [title]="newIssueType">{{ typeIcon(newIssueType).letter }}</span>
|
||||
{{ newIssueType }}
|
||||
<span class="dropdown-toggle ms-1"></span>
|
||||
</button>
|
||||
@if (showTypeDropdown) {
|
||||
<div class="type-backdrop" (click)="closeTypeDropdown()"></div>
|
||||
<ul class="type-dropdown dropdown-menu show">
|
||||
@for (type of typeOptions; track type) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2" [class.active]="newIssueType === type" (click)="selectNewIssueType(type)">
|
||||
<span class="type-icon" [style.background]="typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
|
||||
{{ type }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
#newIssueInput
|
||||
aria-label="Titre de la nouvelle issue"
|
||||
class="form-control form-control-sm"
|
||||
class="form-control form-control-sm dep-select"
|
||||
type="text"
|
||||
placeholder="Titre de l'issue..."
|
||||
[(ngModel)]="newIssueName"
|
||||
(keydown.enter)="confirmCreateIssue()"
|
||||
(keydown.escape)="cancelCreateIssue()"
|
||||
autofocus
|
||||
/>
|
||||
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmCreateIssue()" [disabled]="!newIssueName.trim()">Créer</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelCreateIssue()">Annuler</button>
|
||||
@@ -283,7 +303,25 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group create-issue-wrapper">
|
||||
<button type="button" class="btn btn-sm btn-primary" (click)="openCreateIssue()">+ Créer une issue</button>
|
||||
<button type="button" class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split" (click)="toggleCreateDropdown()">
|
||||
<span class="visually-hidden">Choisir le type</span>
|
||||
</button>
|
||||
@if (showCreateDropdown) {
|
||||
<div class="create-backdrop" (click)="closeCreateDropdown()"></div>
|
||||
<ul class="create-type-dropdown dropdown-menu show">
|
||||
@for (type of typeOptions; track type) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2" (click)="openCreateIssue(type)">
|
||||
<span class="type-icon" [style.background]="typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
|
||||
{{ type }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
|
||||
@@ -370,6 +370,12 @@ describe('MilestoneDetail', () => {
|
||||
expect((component as any).showAddIssue).toBe(false);
|
||||
});
|
||||
|
||||
it('openCreateIssue resets newIssueType to Story', () => {
|
||||
(component as any).newIssueType = 'Bug';
|
||||
(component as any).openCreateIssue();
|
||||
expect((component as any).newIssueType).toBe('Story');
|
||||
});
|
||||
|
||||
it('cancelCreateIssue hides the form and clears name', () => {
|
||||
(component as any).showCreateIssue = true;
|
||||
(component as any).newIssueName = 'Draft';
|
||||
@@ -377,6 +383,12 @@ describe('MilestoneDetail', () => {
|
||||
expect((component as any).showCreateIssue).toBe(false);
|
||||
expect((component as any).newIssueName).toBe('');
|
||||
});
|
||||
|
||||
it('cancelCreateIssue resets newIssueType to Story', () => {
|
||||
(component as any).newIssueType = 'Task';
|
||||
(component as any).cancelCreateIssue();
|
||||
expect((component as any).newIssueType).toBe('Story');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigateToIssue', () => {
|
||||
@@ -437,6 +449,21 @@ describe('MilestoneDetail', () => {
|
||||
expect((component as any).newIssueName).toBe('');
|
||||
});
|
||||
|
||||
it('creates an issue with the selected type', async () => {
|
||||
(component as any).newIssueName = 'New Bug';
|
||||
(component as any).newIssueType = 'Bug';
|
||||
await (component as any).confirmCreateIssue();
|
||||
const created = issuesStore.issues().find((i) => i.name === 'New Bug');
|
||||
expect(created?.type).toBe('Bug');
|
||||
});
|
||||
|
||||
it('creates a Story by default', async () => {
|
||||
(component as any).newIssueName = 'Default Story';
|
||||
await (component as any).confirmCreateIssue();
|
||||
const created = issuesStore.issues().find((i) => i.name === 'Default Story');
|
||||
expect(created?.type).toBe('Story');
|
||||
});
|
||||
|
||||
it('does nothing when name is blank', async () => {
|
||||
(component as any).newIssueName = ' ';
|
||||
await (component as any).confirmCreateIssue();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, ElementRef, inject, ViewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
@@ -28,10 +28,19 @@ export class MilestoneDetail {
|
||||
protected milestone: MilestoneEntity = this.buildMilestone();
|
||||
protected readonly issues = this.issuesStore.issues;
|
||||
|
||||
@ViewChild('newIssueInput') private newIssueInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
protected editingDescription = false;
|
||||
protected showAddIssue = false;
|
||||
protected showCreateIssue = false;
|
||||
protected showCreateDropdown = false;
|
||||
protected newIssueName = '';
|
||||
protected newIssueType: IssueEntity['type'] = 'Story';
|
||||
protected showTypeDropdown = false;
|
||||
|
||||
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||
];
|
||||
protected issueSearchQuery = '';
|
||||
protected showIssueSuggestions = false;
|
||||
protected moreMenuOpen = false;
|
||||
@@ -200,15 +209,43 @@ export class MilestoneDetail {
|
||||
).slice(0, 8);
|
||||
}
|
||||
|
||||
protected openCreateIssue(): void {
|
||||
protected openCreateIssue(type: IssueEntity['type'] = 'Story'): void {
|
||||
this.newIssueName = '';
|
||||
this.newIssueType = type;
|
||||
this.showTypeDropdown = false;
|
||||
this.showCreateDropdown = false;
|
||||
this.showCreateIssue = true;
|
||||
this.showAddIssue = false;
|
||||
setTimeout(() => this.newIssueInput?.nativeElement.focus(), 0);
|
||||
}
|
||||
|
||||
protected cancelCreateIssue(): void {
|
||||
this.showCreateIssue = false;
|
||||
this.newIssueName = '';
|
||||
this.newIssueType = 'Story';
|
||||
this.showTypeDropdown = false;
|
||||
this.showCreateDropdown = false;
|
||||
}
|
||||
|
||||
protected toggleCreateDropdown(): void {
|
||||
this.showCreateDropdown = !this.showCreateDropdown;
|
||||
}
|
||||
|
||||
protected closeCreateDropdown(): void {
|
||||
this.showCreateDropdown = false;
|
||||
}
|
||||
|
||||
protected toggleTypeDropdown(): void {
|
||||
this.showTypeDropdown = !this.showTypeDropdown;
|
||||
}
|
||||
|
||||
protected closeTypeDropdown(): void {
|
||||
this.showTypeDropdown = false;
|
||||
}
|
||||
|
||||
protected selectNewIssueType(type: IssueEntity['type']): void {
|
||||
this.newIssueType = type;
|
||||
this.showTypeDropdown = false;
|
||||
}
|
||||
|
||||
protected async confirmCreateIssue(): Promise<void> {
|
||||
@@ -216,7 +253,7 @@ export class MilestoneDetail {
|
||||
if (!name) return;
|
||||
const created = await this.issuesStore.upsert({
|
||||
id: 0,
|
||||
type: 'Story',
|
||||
type: this.newIssueType,
|
||||
assignee: '',
|
||||
epic: '',
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user