Merge pull request 'Choix type issue' (#34) from feat/34-choix-type-issue into develop

Reviewed-on: Bonsai/Bonsai-webapp#34
This commit is contained in:
2026-05-30 13:46:46 +02:00
8 changed files with 283 additions and 15 deletions
@@ -83,6 +83,44 @@
z-index: 10; 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 */ /* Dependency badges */
.dep-badge { .dep-badge {
display: inline-flex; display: inline-flex;
+41 -2
View File
@@ -334,10 +334,31 @@
} }
</ul> </ul>
} }
<div class="card-footer bg-white"> <div class="card-footer bg-white pt-3">
@if (showCreateInEpic) { @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 <input
#newIssueInput
aria-label="Titre de la nouvelle issue" aria-label="Titre de la nouvelle issue"
class="form-control form-control-sm dep-select" class="form-control form-control-sm dep-select"
type="text" type="text"
@@ -362,7 +383,25 @@
</div> </div>
} @else { } @else {
<div class="d-flex gap-2"> <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" (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> <button type="button" class="btn btn-sm btn-outline-secondary" (click)="openAddToEpic()">Ajouter une existante</button>
</div> </div>
} }
@@ -737,6 +737,12 @@ describe('IssueDetail — existing issue', () => {
expect((component as any).showAddToEpic).toBe(false); 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', () => { it('cancelCreateInEpic hides the form and clears the name', () => {
(component as any).showCreateInEpic = true; (component as any).showCreateInEpic = true;
(component as any).newIssueName = 'Draft'; (component as any).newIssueName = 'Draft';
@@ -745,7 +751,13 @@ describe('IssueDetail — existing issue', () => {
expect((component as any).newIssueName).toBe(''); 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'; (component as any).newIssueName = 'Child Issue';
const before = store.issues().length; const before = store.issues().length;
(component as any).confirmCreateInEpic(); (component as any).confirmCreateInEpic();
@@ -755,6 +767,14 @@ describe('IssueDetail — existing issue', () => {
expect(created?.type).toBe('Story'); 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 () => { it('confirmCreateInEpic resets the form', async () => {
(component as any).newIssueName = 'Child Issue'; (component as any).newIssueName = 'Child Issue';
await (component as any).confirmCreateInEpic(); await (component as any).confirmCreateInEpic();
+36 -3
View File
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@@ -76,8 +76,13 @@ export class IssueDetail {
private _descriptionBeforeEdit = ''; private _descriptionBeforeEdit = '';
protected showAddToEpic = false; protected showAddToEpic = false;
protected selectedEpicCandidateId: number | null = null; protected selectedEpicCandidateId: number | null = null;
@ViewChild('newIssueInput') private newIssueInput?: ElementRef<HTMLInputElement>;
protected showCreateInEpic = false; protected showCreateInEpic = false;
protected showCreateDropdown = false;
protected newIssueName = ''; protected newIssueName = '';
protected newIssueType: IssueEntity['type'] = 'Story';
protected showTypeDropdown = false;
protected readonly statusOptions = this.statusesStore.statuses; 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.newIssueName = '';
this.newIssueType = type;
this.showTypeDropdown = false;
this.showCreateDropdown = false;
this.showCreateInEpic = true; this.showCreateInEpic = true;
this.showAddToEpic = false; this.showAddToEpic = false;
setTimeout(() => this.newIssueInput?.nativeElement.focus(), 0);
} }
protected cancelCreateInEpic(): void { protected cancelCreateInEpic(): void {
this.showCreateInEpic = false; this.showCreateInEpic = false;
this.newIssueName = ''; 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> { protected async confirmCreateInEpic(): Promise<void> {
@@ -237,7 +270,7 @@ export class IssueDetail {
if (!name) return; if (!name) return;
const created = await this.issuesStore.upsert({ const created = await this.issuesStore.upsert({
id: 0, id: 0,
type: 'Story', type: this.newIssueType,
assignee: '', assignee: '',
epic: this.issue.name, epic: this.issue.name,
name, name,
@@ -43,6 +43,42 @@
z-index: 9; 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 link in table */
.issue-name-btn { .issue-name-btn {
border: none; border: none;
@@ -229,18 +229,38 @@
</div> </div>
} }
<div class="card-body" [class.pt-0]="linkedIssues.length > 0"> <div class="card-body" [class.pt-2]="linkedIssues.length > 0">
@if (showCreateIssue) { @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 <input
#newIssueInput
aria-label="Titre de la nouvelle issue" aria-label="Titre de la nouvelle issue"
class="form-control form-control-sm" class="form-control form-control-sm dep-select"
type="text" type="text"
placeholder="Titre de l'issue..." placeholder="Titre de l'issue..."
[(ngModel)]="newIssueName" [(ngModel)]="newIssueName"
(keydown.enter)="confirmCreateIssue()" (keydown.enter)="confirmCreateIssue()"
(keydown.escape)="cancelCreateIssue()" (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-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> <button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelCreateIssue()">Annuler</button>
@@ -283,7 +303,25 @@
</div> </div>
} @else { } @else {
<div class="d-flex gap-2"> <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" (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 <button
type="button" type="button"
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
@@ -370,6 +370,12 @@ describe('MilestoneDetail', () => {
expect((component as any).showAddIssue).toBe(false); 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', () => { it('cancelCreateIssue hides the form and clears name', () => {
(component as any).showCreateIssue = true; (component as any).showCreateIssue = true;
(component as any).newIssueName = 'Draft'; (component as any).newIssueName = 'Draft';
@@ -377,6 +383,12 @@ describe('MilestoneDetail', () => {
expect((component as any).showCreateIssue).toBe(false); expect((component as any).showCreateIssue).toBe(false);
expect((component as any).newIssueName).toBe(''); 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', () => { describe('navigateToIssue', () => {
@@ -437,6 +449,21 @@ describe('MilestoneDetail', () => {
expect((component as any).newIssueName).toBe(''); 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 () => { it('does nothing when name is blank', async () => {
(component as any).newIssueName = ' '; (component as any).newIssueName = ' ';
await (component as any).confirmCreateIssue(); 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@@ -28,10 +28,19 @@ export class MilestoneDetail {
protected milestone: MilestoneEntity = this.buildMilestone(); protected milestone: MilestoneEntity = this.buildMilestone();
protected readonly issues = this.issuesStore.issues; protected readonly issues = this.issuesStore.issues;
@ViewChild('newIssueInput') private newIssueInput?: ElementRef<HTMLInputElement>;
protected editingDescription = false; protected editingDescription = false;
protected showAddIssue = false; protected showAddIssue = false;
protected showCreateIssue = false; protected showCreateIssue = false;
protected showCreateDropdown = false;
protected newIssueName = ''; 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 issueSearchQuery = '';
protected showIssueSuggestions = false; protected showIssueSuggestions = false;
protected moreMenuOpen = false; protected moreMenuOpen = false;
@@ -200,15 +209,43 @@ export class MilestoneDetail {
).slice(0, 8); ).slice(0, 8);
} }
protected openCreateIssue(): void { protected openCreateIssue(type: IssueEntity['type'] = 'Story'): void {
this.newIssueName = ''; this.newIssueName = '';
this.newIssueType = type;
this.showTypeDropdown = false;
this.showCreateDropdown = false;
this.showCreateIssue = true; this.showCreateIssue = true;
this.showAddIssue = false; this.showAddIssue = false;
setTimeout(() => this.newIssueInput?.nativeElement.focus(), 0);
} }
protected cancelCreateIssue(): void { protected cancelCreateIssue(): void {
this.showCreateIssue = false; this.showCreateIssue = false;
this.newIssueName = ''; 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> { protected async confirmCreateIssue(): Promise<void> {
@@ -216,7 +253,7 @@ export class MilestoneDetail {
if (!name) return; if (!name) return;
const created = await this.issuesStore.upsert({ const created = await this.issuesStore.upsert({
id: 0, id: 0,
type: 'Story', type: this.newIssueType,
assignee: '', assignee: '',
epic: '', epic: '',
name, name,