Ajout diagram de gantt
This commit is contained in:
@@ -238,7 +238,7 @@ describe('Dashboard', () => {
|
||||
describe('activeMilestones', () => {
|
||||
it('exclut les milestones terminés à 100%', () => {
|
||||
issuesStore.seed([makeIssue({ id: 1, status: 'done' })]);
|
||||
milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', dueDate: '', issueIds: [1] }]);
|
||||
milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1] }]);
|
||||
expect((component as any).activeMilestones().length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -247,7 +247,7 @@ describe('Dashboard', () => {
|
||||
makeIssue({ id: 1, status: 'done' }),
|
||||
makeIssue({ id: 2, status: 'todo' }),
|
||||
]);
|
||||
milestonesStore.seed([{ id: 1, name: 'Active', description: '', dueDate: '', issueIds: [1, 2] }]);
|
||||
milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2] }]);
|
||||
expect((component as any).activeMilestones().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +341,16 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Gantt de l'Epic -->
|
||||
@if (isEpicIssue && !isNewIssueRoute) {
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header section-header">Diagramme Gantt</div>
|
||||
<div class="card-body">
|
||||
<app-gantt-diagram [tasks]="epicGanttTasks" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Commentaires -->
|
||||
@if (!isNewIssueRoute) {
|
||||
<app-issue-comments [issueId]="issue.id" />
|
||||
|
||||
@@ -88,6 +88,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
id: 1,
|
||||
name: 'Sprint 1',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
dueDate: '',
|
||||
issueIds: [],
|
||||
...overrides,
|
||||
|
||||
@@ -9,10 +9,11 @@ import { IssueComments } from '../issue-comments/issue-comments';
|
||||
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
|
||||
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issue-detail',
|
||||
imports: [FormsModule, IssueComments],
|
||||
imports: [FormsModule, IssueComments, GanttDiagram],
|
||||
templateUrl: './issue-detail.html',
|
||||
styleUrl: './issue-detail.css',
|
||||
})
|
||||
@@ -233,6 +234,39 @@ export class IssueDetail {
|
||||
return this.issueTypeValue === 'Epic';
|
||||
}
|
||||
|
||||
protected get epicGanttTasks(): GanttTask[] {
|
||||
const tasks: GanttTask[] = [];
|
||||
|
||||
if (this.issue.startDate && this.issue.endDate) {
|
||||
tasks.push({
|
||||
id: `issue-${this.issue.id}`,
|
||||
name: this.issue.name || 'Epic',
|
||||
start: this.issue.startDate,
|
||||
end: this.issue.endDate,
|
||||
progress: this.composedIssues.length === 0
|
||||
? this.issue.progress
|
||||
: Math.round(
|
||||
(this.composedIssues.filter((i) => i.status === 'done').length /
|
||||
this.composedIssues.length) * 100,
|
||||
),
|
||||
custom_class: 'bar-epic',
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of this.composedIssues) {
|
||||
if (!child.startDate || !child.endDate) continue;
|
||||
tasks.push({
|
||||
id: `issue-${child.id}`,
|
||||
name: `#${child.id} ${child.name}`,
|
||||
start: child.startDate,
|
||||
end: child.endDate,
|
||||
progress: child.progress,
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
protected get isChildOfEpic(): boolean {
|
||||
return !!this.issue.epic;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
id: 1,
|
||||
name: 'Sprint 1',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
dueDate: '',
|
||||
issueIds: [],
|
||||
...overrides,
|
||||
|
||||
@@ -47,6 +47,26 @@
|
||||
<div class="card-header section-header">Informations</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="field-label">Date de début</label>
|
||||
<input
|
||||
aria-label="Date de début"
|
||||
class="form-control form-control-sm"
|
||||
type="date"
|
||||
[(ngModel)]="milestone.startDate"
|
||||
(blur)="saveMilestone()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="field-label">Date de fin</label>
|
||||
<input
|
||||
aria-label="Date de fin"
|
||||
class="form-control form-control-sm"
|
||||
type="date"
|
||||
[(ngModel)]="milestone.endDate"
|
||||
(blur)="saveMilestone()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="field-label">Date d'échéance</label>
|
||||
<input
|
||||
@@ -237,6 +257,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagramme Gantt -->
|
||||
@if (!isNewRoute) {
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header section-header">Diagramme Gantt</div>
|
||||
<div class="card-body">
|
||||
<app-gantt-diagram [tasks]="milestoneGanttTasks" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Boutons de création -->
|
||||
@if (isNewRoute) {
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
@@ -31,6 +31,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
id: 1,
|
||||
name: 'Sprint 1',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
dueDate: '',
|
||||
issueIds: [],
|
||||
...overrides,
|
||||
@@ -275,6 +277,46 @@ describe('MilestoneDetail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('milestoneGanttTasks', () => {
|
||||
it('returns empty array when no linked issues', () => {
|
||||
issuesStore.seed([]);
|
||||
(component as any).milestone.issueIds = [];
|
||||
expect((component as any).milestoneGanttTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes issues missing startDate or endDate', () => {
|
||||
issuesStore.seed([
|
||||
makeIssue({ id: 1, startDate: '2025-01-01', endDate: '' }),
|
||||
makeIssue({ id: 2, startDate: '', endDate: '2025-01-31' }),
|
||||
]);
|
||||
(component as any).milestone.issueIds = [1, 2];
|
||||
expect((component as any).milestoneGanttTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns a task for each issue with both dates', () => {
|
||||
issuesStore.seed([
|
||||
makeIssue({ id: 1, name: 'Task A', startDate: '2025-01-01', endDate: '2025-01-15', progress: 50 }),
|
||||
makeIssue({ id: 2, name: 'Task B', startDate: '2025-01-10', endDate: '2025-01-31', progress: 0 }),
|
||||
]);
|
||||
(component as any).milestone.issueIds = [1, 2];
|
||||
const tasks = (component as any).milestoneGanttTasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0]).toMatchObject({ id: 'issue-1', name: '#1 Task A', start: '2025-01-01', end: '2025-01-15', progress: 50 });
|
||||
expect(tasks[1]).toMatchObject({ id: 'issue-2', name: '#2 Task B', start: '2025-01-10', end: '2025-01-31', progress: 0 });
|
||||
});
|
||||
|
||||
it('only includes issues linked to the milestone', () => {
|
||||
issuesStore.seed([
|
||||
makeIssue({ id: 1, startDate: '2025-01-01', endDate: '2025-01-31' }),
|
||||
makeIssue({ id: 2, startDate: '2025-02-01', endDate: '2025-02-28' }),
|
||||
]);
|
||||
(component as any).milestone.issueIds = [1];
|
||||
const tasks = (component as any).milestoneGanttTasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].id).toBe('issue-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueSuggestions', () => {
|
||||
beforeEach(() => {
|
||||
issuesStore.seed([
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IssueEntity, IssuesStore } from '../../issues/issues.store';
|
||||
import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
|
||||
|
||||
@Component({
|
||||
selector: 'app-milestone-detail',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, GanttDiagram],
|
||||
templateUrl: './milestone-detail.html',
|
||||
styleUrl: './milestone-detail.css',
|
||||
})
|
||||
@@ -94,6 +95,21 @@ export class MilestoneDetail {
|
||||
return this.sanitizer.bypassSecurityTrustHtml(html);
|
||||
}
|
||||
|
||||
protected get milestoneGanttTasks(): GanttTask[] {
|
||||
const tasks: GanttTask[] = [];
|
||||
for (const issue of this.linkedIssues) {
|
||||
if (!issue.startDate || !issue.endDate) continue;
|
||||
tasks.push({
|
||||
id: `issue-${issue.id}`,
|
||||
name: `#${issue.id} ${issue.name}`,
|
||||
start: issue.startDate,
|
||||
end: issue.endDate,
|
||||
progress: issue.progress,
|
||||
});
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
protected get progress(): number {
|
||||
if (this.linkedIssues.length === 0) return 0;
|
||||
return Math.round(
|
||||
@@ -245,9 +261,9 @@ export class MilestoneDetail {
|
||||
|
||||
private buildMilestone(): MilestoneEntity {
|
||||
if (this.route.snapshot.routeConfig?.path === 'milestones/new') {
|
||||
return { id: 0, name: '', description: '', dueDate: '', issueIds: [] };
|
||||
return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] };
|
||||
}
|
||||
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
|
||||
return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] };
|
||||
return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
id: 1,
|
||||
name: 'Sprint 1',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
dueDate: '',
|
||||
issueIds: [],
|
||||
...overrides,
|
||||
@@ -43,7 +45,7 @@ describe('MilestonesApiService', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('sends POST /api/milestones with the body and returns the created milestone', () => {
|
||||
const body = { name: 'Sprint 2', description: '', dueDate: '', issueIds: [] };
|
||||
const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] };
|
||||
const response = makeMilestone({ id: 2, name: 'Sprint 2' });
|
||||
let result: MilestoneEntity | undefined;
|
||||
service.create(body).subscribe((data) => (result = data));
|
||||
|
||||
@@ -4,9 +4,16 @@
|
||||
<h1 class="h2 mb-2">Milestones</h1>
|
||||
<p class="text-secondary mb-0">Objectifs et jalons du projet.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" (click)="createMilestone()">Créer</button>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm" [class.btn-outline-secondary]="viewMode !== 'list'" [class.btn-secondary]="viewMode === 'list'" (click)="viewMode = 'list'" title="Vue liste">☰</button>
|
||||
<button type="button" class="btn btn-sm" [class.btn-outline-secondary]="viewMode !== 'gantt'" [class.btn-secondary]="viewMode === 'gantt'" (click)="viewMode = 'gantt'" title="Vue Gantt">▦</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" (click)="createMilestone()">Créer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (viewMode === 'list') {
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="search"
|
||||
@@ -16,7 +23,18 @@
|
||||
style="max-width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (viewMode === 'gantt') {
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header section-header">Diagramme Gantt</div>
|
||||
<div class="card-body">
|
||||
<app-gantt-diagram [tasks]="ganttTasks" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (viewMode === 'list') {
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
@@ -25,6 +43,8 @@
|
||||
<th>#</th>
|
||||
<th>Nom</th>
|
||||
<th>Description</th>
|
||||
<th>Début</th>
|
||||
<th>Fin</th>
|
||||
<th>Échéance</th>
|
||||
<th>Issues</th>
|
||||
<th>Progression</th>
|
||||
@@ -41,6 +61,8 @@
|
||||
<td class="text-secondary small">#{{ milestone.id }}</td>
|
||||
<td class="fw-semibold">{{ milestone.name }}</td>
|
||||
<td class="text-secondary small description-cell">{{ milestone.description }}</td>
|
||||
<td class="text-nowrap small">{{ formatDate(milestone.startDate) }}</td>
|
||||
<td class="text-nowrap small">{{ formatDate(milestone.endDate) }}</td>
|
||||
<td class="text-nowrap small">{{ formatDate(milestone.dueDate) }}</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span>
|
||||
@@ -64,10 +86,11 @@
|
||||
}
|
||||
@if (filteredMilestones.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">Aucun milestone trouvé.</td>
|
||||
<td colspan="8" class="text-center text-secondary py-4">Aucun milestone trouvé.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
id: 1,
|
||||
name: 'Sprint 1',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
dueDate: '',
|
||||
issueIds: [],
|
||||
...overrides,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type MilestoneEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
dueDate: string;
|
||||
issueIds: number[];
|
||||
};
|
||||
@@ -67,6 +69,8 @@ export class MilestonesStore {
|
||||
id: milestone.id ?? 0,
|
||||
name: milestone.name ?? '',
|
||||
description: milestone.description ?? '',
|
||||
startDate: milestone.startDate ?? '',
|
||||
endDate: milestone.endDate ?? '',
|
||||
dueDate: milestone.dueDate ?? '',
|
||||
issueIds: Array.isArray(milestone.issueIds)
|
||||
? milestone.issueIds.filter((v): v is number => typeof v === 'number')
|
||||
|
||||
@@ -3,10 +3,11 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from './milestones.store';
|
||||
import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram';
|
||||
|
||||
@Component({
|
||||
selector: 'app-milestones',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, GanttDiagram],
|
||||
templateUrl: './milestones.html',
|
||||
styleUrl: './milestones.css',
|
||||
})
|
||||
@@ -22,6 +23,39 @@ export class Milestones {
|
||||
|
||||
protected readonly milestones = this.milestonesStore.milestones;
|
||||
protected searchQuery = '';
|
||||
protected viewMode: 'list' | 'gantt' = 'list';
|
||||
|
||||
protected get ganttTasks(): GanttTask[] {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tasks: GanttTask[] = [];
|
||||
|
||||
for (const milestone of this.milestones()) {
|
||||
const end = milestone.endDate || milestone.dueDate;
|
||||
if (!end) continue;
|
||||
|
||||
let start = milestone.startDate;
|
||||
if (!start) {
|
||||
const linkedIssues = this.issuesStore.issues().filter((i) =>
|
||||
milestone.issueIds.includes(i.id),
|
||||
);
|
||||
const starts = linkedIssues.map((i) => i.startDate).filter(Boolean);
|
||||
start = starts.length > 0
|
||||
? starts.reduce((min, d) => (d < min ? d : min))
|
||||
: today;
|
||||
}
|
||||
const clampedEnd = end < start ? start : end;
|
||||
|
||||
tasks.push({
|
||||
id: `milestone-${milestone.id}`,
|
||||
name: milestone.name,
|
||||
start,
|
||||
end: clampedEnd,
|
||||
progress: this.getProgress(milestone),
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
protected get filteredMilestones(): MilestoneEntity[] {
|
||||
const q = this.searchQuery.trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
:host {
|
||||
display: block;
|
||||
--g-bar-color: #f0fdf4;
|
||||
--g-bar-border: #9ae6b4;
|
||||
--g-progress-color: #38a169;
|
||||
--g-expected-progress: #2f855a;
|
||||
--g-handle-color: #744210;
|
||||
--g-today-highlight: #276749;
|
||||
}
|
||||
|
||||
.gantt-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gantt-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gantt-container {
|
||||
min-width: 600px;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@if (hasTasks) {
|
||||
<div class="gantt-toolbar">
|
||||
@for (mode of viewModes; track mode) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
[class.btn-primary]="activeViewMode === mode"
|
||||
[class.btn-outline-secondary]="activeViewMode !== mode"
|
||||
(click)="setViewMode(mode)"
|
||||
>{{ viewModeLabels[mode] }}</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
(click)="scrollToToday()"
|
||||
>Aujourd'hui</button>
|
||||
</div>
|
||||
<div class="gantt-wrapper">
|
||||
<div #ganttContainer class="gantt-container"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="text-secondary small mb-0">
|
||||
Aucune tâche avec des dates de début et de fin définies.
|
||||
</p>
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { GanttDiagram, GanttTask } from './gantt-diagram';
|
||||
import { vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('frappe-gantt', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: vi.fn().mockImplementation(class {} as any),
|
||||
}));
|
||||
|
||||
const makeTask = (overrides: Partial<GanttTask> = {}): GanttTask => ({
|
||||
id: 'task-1',
|
||||
name: 'Tâche test',
|
||||
start: '2026-06-01',
|
||||
end: '2026-06-15',
|
||||
progress: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeFakeGantt = () => ({
|
||||
refresh: vi.fn(),
|
||||
change_view_mode: vi.fn(),
|
||||
});
|
||||
|
||||
const makeFakeGanttWithScroll = (today = new Date()) => {
|
||||
const ganttStart = new Date(today.getFullYear(), today.getMonth() - 2, 1);
|
||||
const ganttEnd = new Date(today.getFullYear(), today.getMonth() + 4, 1);
|
||||
return {
|
||||
refresh: vi.fn(),
|
||||
change_view_mode: vi.fn(),
|
||||
scroll_current: vi.fn(),
|
||||
gantt_start: ganttStart,
|
||||
gantt_end: ganttEnd,
|
||||
config: { column_width: 120 },
|
||||
$container: { scrollTo: vi.fn() },
|
||||
};
|
||||
};
|
||||
|
||||
describe('GanttDiagram', () => {
|
||||
let fixture: ComponentFixture<GanttDiagram>;
|
||||
let component: GanttDiagram;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GanttDiagram],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GanttDiagram);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('état initial', () => {
|
||||
it('affiche le message vide quand aucune tâche', () => {
|
||||
component.tasks = [];
|
||||
fixture.detectChanges();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
expect(el.textContent).toContain('Aucune tâche avec des dates');
|
||||
});
|
||||
|
||||
it('ne rend pas le conteneur gantt quand aucune tâche', () => {
|
||||
component.tasks = [];
|
||||
fixture.detectChanges();
|
||||
const container = fixture.nativeElement.querySelector('.gantt-container');
|
||||
expect(container).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('avec des tâches', () => {
|
||||
it('affiche le conteneur gantt quand des tâches sont fournies', () => {
|
||||
component.tasks = [makeTask()];
|
||||
fixture.detectChanges();
|
||||
const container = fixture.nativeElement.querySelector('.gantt-container');
|
||||
expect(container).not.toBeNull();
|
||||
});
|
||||
|
||||
it('affiche les boutons de mode de vue et le bouton Aujourd\'hui', () => {
|
||||
component.tasks = [makeTask()];
|
||||
fixture.detectChanges();
|
||||
const buttons: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('.gantt-toolbar button');
|
||||
expect(buttons.length).toBe(4);
|
||||
const labels = Array.from(buttons).map((b) => b.textContent?.trim());
|
||||
expect(labels).toContain('Jour');
|
||||
expect(labels).toContain('Semaine');
|
||||
expect(labels).toContain('Mois');
|
||||
expect(labels).toContain("Aujourd'hui");
|
||||
});
|
||||
|
||||
it('active le mode Semaine par défaut', () => {
|
||||
component.tasks = [makeTask()];
|
||||
fixture.detectChanges();
|
||||
const buttons: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('.gantt-toolbar button');
|
||||
const active = Array.from(buttons).find((b) =>
|
||||
b.classList.contains('btn-primary'),
|
||||
);
|
||||
expect(active?.textContent?.trim()).toBe('Semaine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changement de mode de vue', () => {
|
||||
it('met à jour activeViewMode au clic sur Mois', () => {
|
||||
component.tasks = [makeTask()];
|
||||
fixture.detectChanges();
|
||||
const buttons: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('.gantt-toolbar button');
|
||||
const monthBtn = Array.from(buttons).find(
|
||||
(b) => b.textContent?.trim() === 'Mois',
|
||||
);
|
||||
monthBtn?.click();
|
||||
fixture.detectChanges();
|
||||
expect(component['activeViewMode']).toBe('Month');
|
||||
});
|
||||
|
||||
it('le bouton actif change après le clic sur Jour', () => {
|
||||
component.tasks = [makeTask()];
|
||||
fixture.detectChanges();
|
||||
const buttons: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('.gantt-toolbar button');
|
||||
const dayBtn = Array.from(buttons).find(
|
||||
(b) => b.textContent?.trim() === 'Jour',
|
||||
);
|
||||
dayBtn?.click();
|
||||
fixture.detectChanges();
|
||||
expect(dayBtn?.classList.contains('btn-primary')).toBe(true);
|
||||
});
|
||||
|
||||
it("appelle change_view_mode sur le gantt si initialisé", () => {
|
||||
const fakeGantt = makeFakeGantt();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
component['setViewMode']('Month');
|
||||
expect(fakeGantt.change_view_mode).toHaveBeenCalledWith('Month');
|
||||
});
|
||||
|
||||
it("ne plante pas si le gantt n'est pas encore initialisé", () => {
|
||||
expect(() => component['setViewMode']('Month')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ngOnChanges', () => {
|
||||
it('rafraîchit le diagramme quand les tâches changent et le gantt est initialisé', () => {
|
||||
const fakeGantt = makeFakeGantt();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
(component as any)['initialized'] = true;
|
||||
const newTasks = [makeTask({ id: 'task-2', name: 'Nouvelle tâche' })];
|
||||
component.tasks = newTasks;
|
||||
component.ngOnChanges({
|
||||
tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false },
|
||||
});
|
||||
expect(fakeGantt.refresh).toHaveBeenCalledWith(newTasks);
|
||||
});
|
||||
|
||||
it("ne lève pas d'erreur si appelé avant AfterViewInit", () => {
|
||||
expect(() => {
|
||||
component.ngOnChanges({
|
||||
tasks: { currentValue: [makeTask()], previousValue: [], firstChange: false, isFirstChange: () => false },
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('appelle renderGantt si le gantt est null au changement de tâches', () => {
|
||||
fixture.detectChanges(); // init sans tâches : AfterViewInit ne rend rien
|
||||
const renderSpy = vi.spyOn(component as any, 'renderGantt');
|
||||
(component as any)['gantt'] = null;
|
||||
const newTasks = [makeTask()];
|
||||
component.tasks = newTasks;
|
||||
component.ngOnChanges({
|
||||
tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false },
|
||||
});
|
||||
expect(renderSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTasks', () => {
|
||||
it('retourne false quand tasks est vide', () => {
|
||||
component.tasks = [];
|
||||
expect(component.hasTasks).toBe(false);
|
||||
});
|
||||
|
||||
it('retourne true quand des tâches existent', () => {
|
||||
component.tasks = [makeTask()];
|
||||
expect(component.hasTasks).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollToToday', () => {
|
||||
it('ne fait rien si le gantt n\'est pas initialisé', () => {
|
||||
expect(() => component['scrollToToday']()).not.toThrow();
|
||||
});
|
||||
|
||||
it('ne fait rien si aujourd\'hui est hors de la plage du gantt', () => {
|
||||
const fakeGantt = makeFakeGanttWithScroll();
|
||||
fakeGantt.gantt_start = new Date(2030, 0, 1);
|
||||
fakeGantt.gantt_end = new Date(2030, 11, 31);
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
component['scrollToToday']();
|
||||
expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled();
|
||||
expect(fakeGantt.scroll_current).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('appelle scrollTo avec la bonne position en vue Mois', () => {
|
||||
const today = new Date();
|
||||
const fakeGantt = makeFakeGanttWithScroll(today);
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
(component as any)['activeViewMode'] = 'Month';
|
||||
component['scrollToToday']();
|
||||
const monthDiff =
|
||||
(today.getFullYear() - fakeGantt.gantt_start.getFullYear()) * 12 +
|
||||
today.getMonth() - fakeGantt.gantt_start.getMonth();
|
||||
const expectedLeft = Math.max(0, monthDiff * 120 - 120 / 6);
|
||||
expect(fakeGantt.$container.scrollTo).toHaveBeenCalledWith({
|
||||
left: expectedLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
it('appelle scroll_current en vue Semaine', () => {
|
||||
const fakeGantt = makeFakeGanttWithScroll();
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
(component as any)['activeViewMode'] = 'Week';
|
||||
component['scrollToToday']();
|
||||
expect(fakeGantt.scroll_current).toHaveBeenCalled();
|
||||
expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('appelle scroll_current en vue Jour', () => {
|
||||
const fakeGantt = makeFakeGanttWithScroll();
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
(component as any)['activeViewMode'] = 'Day';
|
||||
component['scrollToToday']();
|
||||
expect(fakeGantt.scroll_current).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ngOnDestroy', () => {
|
||||
it('met gantt à null à la destruction', () => {
|
||||
const fakeGantt = makeFakeGantt();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(component as any)['gantt'] = fakeGantt;
|
||||
component.ngOnDestroy();
|
||||
expect(component['gantt']).toBeNull();
|
||||
});
|
||||
|
||||
it('marque le composant comme détruit', () => {
|
||||
component.ngOnDestroy();
|
||||
expect(component['destroyed']).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
NgZone,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import type Gantt from 'frappe-gantt';
|
||||
import type { GanttTask } from 'frappe-gantt';
|
||||
|
||||
export type { GanttTask };
|
||||
|
||||
type ViewMode = 'Day' | 'Week' | 'Month';
|
||||
|
||||
@Component({
|
||||
selector: 'app-gantt-diagram',
|
||||
templateUrl: './gantt-diagram.html',
|
||||
styleUrl: './gantt-diagram.css',
|
||||
})
|
||||
export class GanttDiagram implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@ViewChild('ganttContainer') ganttContainer!: ElementRef<HTMLElement>;
|
||||
@Input() tasks: GanttTask[] = [];
|
||||
|
||||
protected readonly viewModes: ViewMode[] = ['Day', 'Week', 'Month'];
|
||||
protected readonly viewModeLabels: Record<ViewMode, string> = {
|
||||
Day: 'Jour',
|
||||
Week: 'Semaine',
|
||||
Month: 'Mois',
|
||||
};
|
||||
protected activeViewMode: ViewMode = 'Week';
|
||||
|
||||
private gantt: Gantt | null = null;
|
||||
private initialized = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly zone: NgZone) {}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initialized = true;
|
||||
this.renderGantt();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (!this.initialized) return;
|
||||
if (changes['tasks']) {
|
||||
if (this.gantt) {
|
||||
this.zone.runOutsideAngular(() => this.gantt!.refresh(this.tasks));
|
||||
} else {
|
||||
this.renderGantt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
this.gantt = null;
|
||||
}
|
||||
|
||||
protected setViewMode(mode: ViewMode): void {
|
||||
this.activeViewMode = mode;
|
||||
if (this.gantt) {
|
||||
this.zone.runOutsideAngular(() => this.gantt!.change_view_mode(mode));
|
||||
}
|
||||
}
|
||||
|
||||
protected scrollToToday(): void {
|
||||
if (!this.gantt) return;
|
||||
this.zone.runOutsideAngular(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const g = this.gantt as any;
|
||||
const today = new Date();
|
||||
if (today < g.gantt_start || today > g.gantt_end) return;
|
||||
|
||||
if (this.activeViewMode === 'Month') {
|
||||
// Bug frappe-gantt : get_closest_date() construit new Date("YYYY-MM ")
|
||||
// qui est un Invalid Date → position NaN → aucun scroll.
|
||||
// Correctif : calcul entier du décalage en mois.
|
||||
const monthDiff =
|
||||
(today.getFullYear() - g.gantt_start.getFullYear()) * 12 +
|
||||
today.getMonth() - g.gantt_start.getMonth();
|
||||
const left = Math.max(
|
||||
0,
|
||||
monthDiff * g.config.column_width - g.config.column_width / 6,
|
||||
);
|
||||
g.$container.scrollTo({ left, behavior: 'smooth' });
|
||||
} else {
|
||||
g.scroll_current();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get hasTasks(): boolean {
|
||||
return this.tasks.length > 0;
|
||||
}
|
||||
|
||||
private renderGantt(): void {
|
||||
if (!this.tasks.length || !this.ganttContainer) return;
|
||||
|
||||
import('frappe-gantt').then(({ default: GanttClass }) => {
|
||||
if (this.destroyed || !this.ganttContainer) return;
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.ganttContainer.nativeElement.innerHTML = '';
|
||||
this.gantt = new GanttClass(
|
||||
this.ganttContainer.nativeElement,
|
||||
this.tasks,
|
||||
{
|
||||
view_mode: this.activeViewMode,
|
||||
language: 'fr',
|
||||
popup: false,
|
||||
today_button: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
declare module 'frappe-gantt' {
|
||||
export interface GanttTask {
|
||||
id: string;
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
progress: number;
|
||||
dependencies?: string;
|
||||
custom_class?: string;
|
||||
}
|
||||
|
||||
export interface GanttOptions {
|
||||
view_mode?: string;
|
||||
language?: string;
|
||||
popup?: boolean | ((task: GanttTask, x: number, y: number) => string);
|
||||
today_button?: boolean;
|
||||
on_click?: (task: GanttTask) => void;
|
||||
on_date_change?: (task: GanttTask, start: Date, end: Date) => void;
|
||||
on_progress_change?: (task: GanttTask, progress: number) => void;
|
||||
on_view_change?: (mode: string) => void;
|
||||
}
|
||||
|
||||
export default class Gantt {
|
||||
constructor(element: string | HTMLElement, tasks: GanttTask[], options?: GanttOptions);
|
||||
refresh(tasks: GanttTask[]): void;
|
||||
change_view_mode(mode?: string): void;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user