Merge pull request 'Feat/30 diagram gantt' (#33) from feat/30-diagram-gantt into develop

Reviewed-on: Bonsai/Bonsai-webapp#33
This commit is contained in:
2026-05-30 08:58:30 +02:00
29 changed files with 1686 additions and 22 deletions
+8 -3
View File
@@ -4,13 +4,18 @@
Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend. Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend.
## Action requise ## Action requise
Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé d'après le **numéro de ticket** extrait du nom de la branche courante, suivi du slug de la branche : Créer un **nouveau fichier** dans le dossier `api-issues/` à la racine du projet pour **chaque besoin d'évolution API distinct**. Ne jamais modifier un fichier existant : toujours créer un fichier supplémentaire.
Le nom du fichier est composé du **numéro de ticket** (extrait de la branche courante) suivi d'un **slug explicite décrivant le changement demandé** :
``` ```
api-issues/<numéro>-<slug>.md api-issues/<numéro>-<description-du-changement>.md
``` ```
> **Exemple** : branche `feat/30-ordre-statut` → fichier `api-issues/30-ordre-statut.md` > **Exemple** : ticket 30, besoin d'ajouter les dates de milestone → fichier `api-issues/30-milestone-dates.md`
> **Exemple** : ticket 30, besoin d'un endpoint de statistiques → fichier `api-issues/30-statistiques-epic.md`
Si plusieurs besoins distincts émergent pour le même ticket, créer autant de fichiers séparés.
## Contenu du fichier ## Contenu du fichier
Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire : Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire :
+11 -2
View File
@@ -4,11 +4,20 @@
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)", "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)",
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)", "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('projects',{}\\).get\\('Bonsai-webapp',{}\\).get\\('architect',{}\\).get\\('test',{}\\), indent=2\\)\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('projects',{}\\).get\\('Bonsai-webapp',{}\\).get\\('architect',{}\\).get\\('test',{}\\), indent=2\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")" "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d['projects']['bonsai-webapp']['architect']['build']['options'], indent=2\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); keys=list\\(d['projects'].keys\\(\\)\\); print\\(keys\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); opts=d['projects']['Bonsai-webapp']['architect']['build']['options']; print\\(json.dumps\\({k:opts[k] for k in ['styles','scripts','assets'] if k in opts}, indent=2\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('main',''\\), d.get\\('module',''\\), d.get\\('types',''\\), d.get\\('exports',''\\)\\)\")",
"Bash(grep -n \"^\\\\s*function \\\\$\\\\|const \\\\$ =\\\\|\\\\$ = \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)",
"Bash(grep -n \"function \\\\$\\\\b\\\\|const \\\\$ \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
"/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki" "/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki",
"/home/Gato/IdeaProjects/Bonsai-webapp/api-issues",
"/var/home/Gato/IdeaProjects/Bonsai-webapp/api-issues"
] ]
} }
} }
+2 -1
View File
@@ -31,7 +31,8 @@
} }
], ],
"styles": [ "styles": [
"src/styles.css" "src/styles.css",
"node_modules/frappe-gantt/dist/frappe-gantt.css"
] ]
}, },
"configurations": { "configurations": {
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bonsai-webapp", "name": "bonsai-webapp",
"version": "0.1.0", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bonsai-webapp", "name": "bonsai-webapp",
"version": "0.1.0", "version": "0.1.11",
"dependencies": { "dependencies": {
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"frappe-gantt": "^1.2.2",
"keycloak-js": "^26.2.4", "keycloak-js": "^26.2.4",
"marked": "^18.0.4", "marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -5387,6 +5388,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frappe-gantt": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.2.2.tgz",
"integrity": "sha512-1+uPNRa92LBIeKiZCVhJMiGIifJk5ONaoerXI8eBREXWRZGGoTJR5ATpMpsnAQcyBA6Gnq5wIht4eL+e4eHMBA==",
"license": "MIT"
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"frappe-gantt": "^1.2.2",
"keycloak-js": "^26.2.4", "keycloak-js": "^26.2.4",
"marked": "^18.0.4", "marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
+5 -2
View File
@@ -12,6 +12,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -236,7 +239,7 @@ describe('Dashboard', () => {
describe('activeMilestones', () => { describe('activeMilestones', () => {
it('exclut les milestones terminés à 100%', () => { it('exclut les milestones terminés à 100%', () => {
issuesStore.seed([makeIssue({ id: 1, status: 'done' })]); 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], dependsOnIds: [] }]);
expect((component as any).activeMilestones().length).toBe(0); expect((component as any).activeMilestones().length).toBe(0);
}); });
@@ -245,7 +248,7 @@ describe('Dashboard', () => {
makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 1, status: 'done' }),
makeIssue({ id: 2, status: 'todo' }), 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], dependsOnIds: [] }]);
expect((component as any).activeMilestones().length).toBe(1); expect((component as any).activeMilestones().length).toBe(1);
}); });
}); });
@@ -11,6 +11,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -170,6 +170,9 @@ export class IssueComments {
name, name,
assignee: '', assignee: '',
epic: '', epic: '',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -240,3 +240,22 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Date mode label row */
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3rem;
}
.date-mode-select {
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
appearance: auto;
}
+67 -1
View File
@@ -153,6 +153,58 @@
<label class="field-label">Assignee</label> <label class="field-label">Assignee</label>
<input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" /> <input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" />
</div> </div>
<div class="row g-2">
<div class="col-6">
<div class="field-label-row">
<span class="field-label mb-0">Date de début</span>
@if (hasDependencies) {
<select
class="date-mode-select"
aria-label="Mode date de début"
[ngModel]="issue.startDateMode"
(ngModelChange)="startDateModeValue = $event"
>
<option value="calculated">Calculée</option>
<option value="forced">Forcée</option>
</select>
}
</div>
@if (issue.startDateMode === 'calculated') {
<input
aria-label="Date de début calculée"
class="form-control form-control-sm bg-body-secondary"
type="date"
[ngModel]="calculatedStartDate"
readonly
/>
@if (startDateModeWarning) {
<div class="text-warning small mt-1">{{ startDateModeWarning }}</div>
}
} @else {
<input
aria-label="Date de début"
class="form-control form-control-sm"
[class.is-invalid]="!!dateValidationError"
type="date"
[(ngModel)]="issue.startDate"
(blur)="onStartDateBlur()"
/>
}
</div>
<div class="col-6">
<label class="field-label">Date de fin</label>
<input
aria-label="Date de fin"
class="form-control form-control-sm"
type="date"
[ngModel]="issue.endDate"
readonly
/>
</div>
@if (dateValidationError) {
<div class="col-12 text-danger small">{{ dateValidationError }}</div>
}
</div>
<div> <div>
<label class="field-label">Date d'échéance</label> <label class="field-label">Date d'échéance</label>
<input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" /> <input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" />
@@ -160,7 +212,11 @@
<div class="row g-2"> <div class="row g-2">
<div [class]="isEpicIssue ? 'col-12' : 'col-6'"> <div [class]="isEpicIssue ? 'col-12' : 'col-6'">
<label class="field-label">Temps estimé (h)</label> <label class="field-label">Temps estimé (h)</label>
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" /> @if (isEpicIssue) {
<div class="form-control form-control-sm bg-body-secondary text-secondary">{{ epicEstimatedTime !== null ? epicEstimatedTime : '—' }}</div>
} @else {
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" />
}
</div> </div>
@if (!isEpicIssue) { @if (!isEpicIssue) {
<div class="col-6"> <div class="col-6">
@@ -314,6 +370,16 @@
</div> </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 --> <!-- Commentaires -->
@if (!isNewIssueRoute) { @if (!isNewIssueRoute) {
<app-issue-comments [issueId]="issue.id" /> <app-issue-comments [issueId]="issue.id" />
@@ -14,6 +14,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -86,8 +89,11 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1, id: 1,
name: 'Sprint 1', name: 'Sprint 1',
description: '', description: '',
startDate: '',
endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -213,6 +219,253 @@ describe('IssueDetail — existing issue', () => {
(component as any).saveIssue(); (component as any).saveIssue();
expect(store.issues().length).toBe(countBefore); expect(store.issues().length).toBe(countBefore);
}); });
it('does not persist when dateValidationError is set', async () => {
(component as any).issue.name = 'Has Dates';
(component as any).issue.startDate = '2026-02-01';
(component as any).issue.endDate = '2026-01-01';
await (component as any).saveIssue();
expect(store.getById(1)?.name).not.toBe('Has Dates');
});
});
describe('dateValidationError', () => {
it('returns null when both dates are empty', () => {
(component as any).issue.startDate = '';
(component as any).issue.endDate = '';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when only startDate is set', () => {
(component as any).issue.startDate = '2026-01-01';
(component as any).issue.endDate = '';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when only endDate is set', () => {
(component as any).issue.startDate = '';
(component as any).issue.endDate = '2026-01-31';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when startDate equals endDate', () => {
(component as any).issue.startDate = '2026-01-15';
(component as any).issue.endDate = '2026-01-15';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when startDate is before endDate', () => {
(component as any).issue.startDate = '2026-01-01';
(component as any).issue.endDate = '2026-01-31';
expect((component as any).dateValidationError).toBeNull();
});
it('returns an error when startDate is after endDate', () => {
(component as any).issue.startDate = '2026-02-01';
(component as any).issue.endDate = '2026-01-01';
expect((component as any).dateValidationError).toContain('supérieure à la date de fin');
});
it('returns null when dependency has no endDate and startDate is set', async () => {
store.upsert(makeIssue({ id: 10, startDate: '2026-01-01', endDate: '' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2025-12-01';
expect((component as any).dateValidationError).toBeNull();
});
it('returns an error when startDate is before dependency endDate (Finish-to-Start)', async () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2026-01-15';
expect((component as any).dateValidationError).toContain('#10');
});
it('returns null when startDate equals dependency endDate', async () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2026-02-01';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when startDate is after dependency endDate', async () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2026-02-15';
expect((component as any).dateValidationError).toBeNull();
});
it('returns null when there are no dependencies', () => {
(component as any).issue.dependsOnIds = [];
(component as any).issue.startDate = '2026-01-01';
expect((component as any).dateValidationError).toBeNull();
});
});
describe('hasDependencies', () => {
it('returns false when dependsOnIds is empty', () => {
(component as any).issue.dependsOnIds = [];
expect((component as any).hasDependencies).toBe(false);
});
it('returns true when dependsOnIds has entries', () => {
(component as any).issue.dependsOnIds = [2];
expect((component as any).hasDependencies).toBe(true);
});
});
describe('calculatedStartDate', () => {
it('returns empty string when there are no dependencies', () => {
(component as any).issue.dependsOnIds = [];
expect((component as any).calculatedStartDate).toBe('');
});
it('returns empty string when dependencies have no endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '' }));
(component as any).issue.dependsOnIds = [10];
expect((component as any).calculatedStartDate).toBe('');
});
it('returns the endDate of the single dependency', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
(component as any).issue.dependsOnIds = [10];
expect((component as any).calculatedStartDate).toBe('2026-03-01');
});
it('returns the latest endDate when multiple dependencies exist', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
store.upsert(makeIssue({ id: 11, endDate: '2026-04-15' }));
store.upsert(makeIssue({ id: 12, endDate: '2026-02-20' }));
(component as any).issue.dependsOnIds = [10, 11, 12];
expect((component as any).calculatedStartDate).toBe('2026-04-15');
});
it('ignores dependencies without endDate in the max calculation', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
store.upsert(makeIssue({ id: 11, endDate: '' }));
(component as any).issue.dependsOnIds = [10, 11];
expect((component as any).calculatedStartDate).toBe('2026-03-01');
});
});
describe('startDateModeWarning', () => {
it('returns null in forced mode', () => {
(component as any).issue.startDateMode = 'forced';
expect((component as any).startDateModeWarning).toBeNull();
});
it('returns null in calculated mode when a dependency has an endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'calculated';
expect((component as any).startDateModeWarning).toBeNull();
});
it('returns a warning message in calculated mode when no dependency has an endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'calculated';
expect((component as any).startDateModeWarning).toContain('date de fin');
});
});
describe('startDateModeValue setter', () => {
it('switching to calculated sets startDate from dependency endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'forced';
(component as any).startDateModeValue = 'calculated';
expect((component as any).issue.startDate).toBe('2026-05-01');
});
it('switching to calculated recalculates endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.estimatedTime = 8;
(component as any).startDateModeValue = 'calculated';
expect((component as any).issue.endDate).toBe('2026-05-01');
});
it('switching to forced preserves the current startDate', () => {
(component as any).issue.startDate = '2026-04-10';
(component as any).issue.startDateMode = 'calculated';
(component as any).startDateModeValue = 'forced';
expect((component as any).issue.startDate).toBe('2026-04-10');
});
});
describe('dateValidationError — calculated mode', () => {
it('does not flag dependency constraint in calculated mode', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2026-01-01';
(component as any).issue.startDateMode = 'calculated';
expect((component as any).dateValidationError).toBeNull();
});
it('still flags startDate > endDate in calculated mode', () => {
(component as any).issue.startDate = '2026-02-01';
(component as any).issue.endDate = '2026-01-01';
(component as any).issue.startDateMode = 'calculated';
expect((component as any).dateValidationError).toContain('supérieure à la date de fin');
});
});
describe('recalculateEndDate (via estimatedTimeValue setter)', () => {
it('sets endDate to startDate when estimatedTime is 8h or less', () => {
(component as any).issue.startDate = '2026-06-01';
(component as any).estimatedTimeValue = 8;
expect((component as any).issue.endDate).toBe('2026-06-01');
});
it('adds one day for estimatedTime between 9h and 16h', () => {
(component as any).issue.startDate = '2026-06-01';
(component as any).estimatedTimeValue = 16;
expect((component as any).issue.endDate).toBe('2026-06-02');
});
it('adds two days for estimatedTime between 17h and 24h', () => {
(component as any).issue.startDate = '2026-06-01';
(component as any).estimatedTimeValue = 24;
expect((component as any).issue.endDate).toBe('2026-06-03');
});
it('clears endDate when startDate is empty', () => {
(component as any).issue.startDate = '';
(component as any).issue.endDate = '2026-06-05';
(component as any).estimatedTimeValue = 8;
expect((component as any).issue.endDate).toBe('');
});
it('clears endDate when estimatedTime is null', () => {
(component as any).issue.startDate = '2026-06-01';
(component as any).issue.endDate = '2026-06-05';
(component as any).estimatedTimeValue = null;
expect((component as any).issue.endDate).toBe('');
});
it('clears endDate when estimatedTime is 0', () => {
(component as any).issue.startDate = '2026-06-01';
(component as any).issue.endDate = '2026-06-05';
(component as any).estimatedTimeValue = 0;
expect((component as any).issue.endDate).toBe('');
});
});
describe('onStartDateBlur', () => {
it('recalculates endDate when startDate changes', () => {
(component as any).issue.estimatedTime = 16;
(component as any).issue.startDate = '2026-06-10';
(component as any).onStartDateBlur();
expect((component as any).issue.endDate).toBe('2026-06-11');
});
it('clears endDate when startDate is cleared', () => {
(component as any).issue.estimatedTime = 16;
(component as any).issue.endDate = '2026-06-11';
(component as any).issue.startDate = '';
(component as any).onStartDateBlur();
expect((component as any).issue.endDate).toBe('');
});
}); });
describe('deleteIssue', () => { describe('deleteIssue', () => {
@@ -438,6 +691,39 @@ describe('IssueDetail — existing issue', () => {
}); });
}); });
describe('epicEstimatedTime', () => {
beforeEach(() => {
(component as any).issue.type = 'Epic';
(component as any).issue.name = 'Test Epic';
});
it('returns null when there are no child issues', () => {
expect((component as any).epicEstimatedTime).toBeNull();
});
it('returns null when all children have null estimatedTime', () => {
store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: null }));
expect((component as any).epicEstimatedTime).toBeNull();
});
it('returns the sum of children estimatedTime', () => {
store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 }));
store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: 4 }));
expect((component as any).epicEstimatedTime).toBe(12);
});
it('ignores children with null estimatedTime in the sum', () => {
store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 }));
store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: null }));
expect((component as any).epicEstimatedTime).toBe(8);
});
it('includes children linked via dependsOnIds', () => {
store.upsert(makeIssue({ id: 200, dependsOnIds: [1], estimatedTime: 6 }));
expect((component as any).epicEstimatedTime).toBe(6);
});
});
describe('create-in-epic flow', () => { describe('create-in-epic flow', () => {
beforeEach(() => { beforeEach(() => {
(component as any).issue.type = 'Epic'; (component as any).issue.type = 'Epic';
+145 -2
View File
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, effect, inject } 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';
@@ -9,10 +9,11 @@ import { IssueComments } from '../issue-comments/issue-comments';
import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
@Component({ @Component({
selector: 'app-issue-detail', selector: 'app-issue-detail',
imports: [FormsModule, IssueComments], imports: [FormsModule, IssueComments, GanttDiagram],
templateUrl: './issue-detail.html', templateUrl: './issue-detail.html',
styleUrl: './issue-detail.css', styleUrl: './issue-detail.css',
}) })
@@ -55,6 +56,18 @@ export class IssueDetail {
this.showCreateInEpic = false; this.showCreateInEpic = false;
} }
}); });
effect(() => {
void this.issues();
if (this.issue.startDateMode === 'calculated') {
const newStart = this.calculatedStartDate;
if (this.issue.startDate !== newStart) {
this.issue.startDate = newStart;
this.recalculateEndDate();
this.saveIssue();
}
}
});
} }
protected showAddDependency = false; protected showAddDependency = false;
@@ -77,6 +90,10 @@ export class IssueDetail {
'Technical Story', 'Technical Story',
]; ];
protected get hasDependencies(): boolean {
return this.issue.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] { protected get dependencyIds(): number[] {
return this.issue.dependsOnIds; return this.issue.dependsOnIds;
} }
@@ -115,12 +132,59 @@ export class IssueDetail {
await this.saveIssue(); await this.saveIssue();
} }
protected get calculatedStartDate(): string {
const dates = this.issue.dependsOnIds
.map((id) => this.issuesStore.getById(id)?.endDate)
.filter((d): d is string => !!d);
if (dates.length === 0) return '';
return dates.reduce((max, d) => (d > max ? d : max));
}
protected get startDateModeWarning(): string | null {
if (this.issue.startDateMode !== 'calculated') return null;
if (!this.calculatedStartDate) return "Aucune dépendance n'a de date de fin définie.";
return null;
}
protected get startDateModeValue(): 'forced' | 'calculated' {
return this.issue.startDateMode;
}
protected set startDateModeValue(mode: 'forced' | 'calculated') {
this.issue.startDateMode = mode;
if (mode === 'calculated') {
this.issue.startDate = this.calculatedStartDate;
}
this.recalculateEndDate();
this.saveIssue();
}
protected get estimatedTimeValue(): number | null { protected get estimatedTimeValue(): number | null {
return this.issue.estimatedTime; return this.issue.estimatedTime;
} }
protected set estimatedTimeValue(value: number | null) { protected set estimatedTimeValue(value: number | null) {
this.issue.estimatedTime = value === null || value === undefined ? null : Number(value); this.issue.estimatedTime = value === null || value === undefined ? null : Number(value);
this.recalculateEndDate();
}
private recalculateEndDate(): void {
const effectiveStart =
this.issue.startDateMode === 'calculated' ? this.calculatedStartDate : this.issue.startDate;
const { estimatedTime } = this.issue;
if (!effectiveStart || estimatedTime === null || estimatedTime <= 0) {
this.issue.endDate = '';
return;
}
const start = new Date(effectiveStart);
const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1);
start.setDate(start.getDate() + extraDays);
this.issue.endDate = start.toISOString().split('T')[0];
}
protected onStartDateBlur(): void {
this.recalculateEndDate();
this.saveIssue();
} }
protected get issueTypeValue(): IssueEntity['type'] { protected get issueTypeValue(): IssueEntity['type'] {
@@ -131,6 +195,13 @@ export class IssueDetail {
this.issue.type = value; this.issue.type = value;
} }
protected get epicEstimatedTime(): number | null {
const times = this.composedIssues
.filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null)
.map((i) => i.estimatedTime);
return times.length === 0 ? null : times.reduce((a, b) => a + b, 0);
}
protected get epicIssues(): IssueEntity[] { protected get epicIssues(): IssueEntity[] {
return this.issues().filter((issue) => issue.type === 'Epic'); return this.issues().filter((issue) => issue.type === 'Epic');
} }
@@ -170,6 +241,9 @@ export class IssueDetail {
assignee: '', assignee: '',
epic: this.issue.name, epic: this.issue.name,
name, name,
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -231,10 +305,72 @@ export class IssueDetail {
return this.issueTypeValue === 'Epic'; 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',
});
}
const taskIds = new Set(tasks.map((t) => t.id));
const sortedChildren = [...this.composedIssues].sort((a, b) => {
if (!a.startDate && !b.startDate) return 0;
if (!a.startDate) return 1;
if (!b.startDate) return -1;
return a.startDate.localeCompare(b.startDate);
});
for (const child of sortedChildren) {
if (!child.startDate || !child.endDate) continue;
const deps = child.dependsOnIds
.map((id) => `issue-${id}`)
.filter((id) => taskIds.has(id))
.join(',');
tasks.push({
id: `issue-${child.id}`,
name: `#${child.id} ${child.name}`,
start: child.startDate,
end: child.endDate,
progress: child.progress,
...(deps ? { dependencies: deps } : {}),
});
taskIds.add(`issue-${child.id}`);
}
return tasks;
}
protected get isChildOfEpic(): boolean { protected get isChildOfEpic(): boolean {
return !!this.issue.epic; return !!this.issue.epic;
} }
protected get dateValidationError(): string | null {
const { startDate, endDate } = this.issue;
if (startDate && endDate && startDate > endDate) {
return 'La date de début ne peut pas être supérieure à la date de fin.';
}
if (this.issue.startDateMode !== 'calculated' && startDate && this.issue.dependsOnIds.length > 0) {
for (const depId of this.issue.dependsOnIds) {
const dep = this.issuesStore.getById(depId);
if (dep?.endDate && startDate < dep.endDate) {
return `La date de début ne peut pas être antérieure à la date de fin de la dépendance #${depId}.`;
}
}
}
return null;
}
protected startEditDescription(): void { protected startEditDescription(): void {
this._descriptionBeforeEdit = this.issue.description; this._descriptionBeforeEdit = this.issue.description;
this.editingDescription = true; this.editingDescription = true;
@@ -358,6 +494,7 @@ export class IssueDetail {
protected async saveIssue(explicit = false): Promise<void> { protected async saveIssue(explicit = false): Promise<void> {
if (this.isNewIssueRoute && !explicit) return; if (this.isNewIssueRoute && !explicit) return;
if (!this.issue.name.trim()) return; if (!this.issue.name.trim()) return;
if (this.dateValidationError) return;
const saved = await this.issuesStore.upsert(this.issue); const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved }; this.issue = { ...saved };
if (this.isNewIssueRoute) { if (this.isNewIssueRoute) {
@@ -413,6 +550,9 @@ export class IssueDetail {
assignee: '', assignee: '',
epic: '', epic: '',
name: '', name: '',
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -434,6 +574,9 @@ export class IssueDetail {
assignee: '', assignee: '',
epic: '', epic: '',
name: '', name: '',
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
+6
View File
@@ -13,6 +13,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -93,8 +96,11 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1, id: 1,
name: 'Sprint 1', name: 'Sprint 1',
description: '', description: '',
startDate: '',
endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
+145
View File
@@ -10,6 +10,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -156,6 +159,148 @@ describe('IssuesStore', () => {
await p; await p;
expect(store.getById(994)?.estimatedTime).toBeNull(); expect(store.getById(994)?.estimatedTime).toBeNull();
}); });
it('defaults startDate to empty string when missing in API response', async () => {
const issue = { ...makeIssue({ id: 0 }), startDate: undefined } as any;
const p = store.upsert(issue);
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined });
await p;
expect(store.getById(993)?.startDate).toBe('');
});
it('defaults endDate to empty string when missing in API response', async () => {
const issue = { ...makeIssue({ id: 0 }), endDate: undefined } as any;
const p = store.upsert(issue);
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined });
await p;
expect(store.getById(992)?.endDate).toBe('');
});
it('preserves startDate and endDate when provided', async () => {
const issue = makeIssue({ id: 0, startDate: '2026-01-01', endDate: '2026-01-31' });
const p = store.upsert(issue);
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' }));
await p;
expect(store.getById(991)?.startDate).toBe('2026-01-01');
expect(store.getById(991)?.endDate).toBe('2026-01-31');
});
it('restores startDateMode when the API response omits it', async () => {
const issue = makeIssue({ id: 1, startDateMode: 'calculated' });
const p = store.upsert(issue);
const apiResponse = { ...makeIssue({ id: 1 }), startDateMode: undefined };
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.startDateMode).toBe('calculated');
});
it('restores linkedIssueIds in comments when API response omits them', async () => {
const issueWithComment = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [2, 3] }] });
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: undefined as any }] });
const p = store.upsert(issueWithComment);
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]);
});
it('keeps existing linkedIssueIds in comments when already present in API response', async () => {
const issue = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
const p = store.upsert(issue);
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]);
});
});
describe('cascade recalculation of calculated-mode issues', () => {
it('updates startDate of a calculated-mode dependent when its dependency endDate changes', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', endDate: '' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
});
it('recalculates endDate of a calculated-mode dependent based on estimatedTime', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
expect(store.getById(2)?.endDate).toBe('2026-06-11');
});
it('clears endDate when dependency loses its endDate', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-10' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' }));
await p;
expect(store.getById(2)?.startDate).toBe('');
expect(store.getById(2)?.endDate).toBe('');
});
it('cascades through a chain A → B → C', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 8, endDate: '2026-06-01' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [2], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
expect(store.getById(2)?.endDate).toBe('2026-06-10');
expect(store.getById(3)?.startDate).toBe('2026-06-10');
expect(store.getById(3)?.endDate).toBe('2026-06-11');
});
it('does not affect forced-mode issues', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'forced', dependsOnIds: [1], startDate: '2026-05-01', endDate: '2026-05-15' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-05-01');
expect(store.getById(2)?.endDate).toBe('2026-05-15');
});
it('uses the latest endDate among multiple dependencies', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, endDate: '2026-06-05' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '', estimatedTime: 8, endDate: '' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(3)?.startDate).toBe('2026-06-10');
expect(store.getById(3)?.endDate).toBe('2026-06-10');
});
it('recalculates after deleteById removes a dependency', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-10' }),
makeIssue({ id: 2, endDate: '2026-06-05' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
]);
const p = store.deleteById(1);
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
await p;
expect(store.getById(3)?.startDate).toBe('2026-06-05');
expect(store.getById(3)?.endDate).toBe('2026-06-05');
});
}); });
describe('deleteById', () => { describe('deleteById', () => {
+47
View File
@@ -20,6 +20,9 @@ export type IssueEntity = {
assignee: string; assignee: string;
epic: string; epic: string;
name: string; name: string;
startDate: string;
startDateMode: 'forced' | 'calculated';
endDate: string;
dueDate: string; dueDate: string;
description: string; description: string;
estimatedTime: number | null; estimatedTime: number | null;
@@ -66,6 +69,7 @@ export class IssuesStore {
const { id: _id, ...body } = normalized; const { id: _id, ...body } = normalized;
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
this.data.update((issues) => [...issues, created]); this.data.update((issues) => [...issues, created]);
this.recalculateCalculatedIssues();
return created; return created;
} else { } else {
const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized)); const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized));
@@ -78,6 +82,10 @@ export class IssuesStore {
return { ...c, linkedIssueIds: sent?.linkedIssueIds ?? [] }; return { ...c, linkedIssueIds: sent?.linkedIssueIds ?? [] };
}); });
} }
// L'API ne retourne pas startDateMode : on le restaure depuis les données envoyées.
if (apiResult.startDateMode == null) {
apiResult.startDateMode = normalized.startDateMode;
}
const updated = this.normalizeIssue(apiResult); const updated = this.normalizeIssue(apiResult);
this.data.update((issues) => { this.data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id); const idx = issues.findIndex((i) => i.id === normalized.id);
@@ -86,6 +94,7 @@ export class IssuesStore {
copy[idx] = updated; copy[idx] = updated;
return copy; return copy;
}); });
this.recalculateCalculatedIssues();
return updated; return updated;
} }
} }
@@ -97,6 +106,41 @@ export class IssuesStore {
.filter((i) => i.id !== id) .filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
); );
this.recalculateCalculatedIssues();
}
private recalculateCalculatedIssues(): void {
let anyChanged: boolean;
do {
anyChanged = false;
this.data.update((issues) => {
const result = issues.map((issue) => {
if (issue.startDateMode !== 'calculated') return issue;
const newStart = this.computeStartDate(issue, issues);
const newEnd = this.computeEndDate(newStart, issue.estimatedTime);
if (issue.startDate === newStart && issue.endDate === newEnd) return issue;
anyChanged = true;
return { ...issue, startDate: newStart, endDate: newEnd };
});
return anyChanged ? result : issues;
});
} while (anyChanged);
}
private computeStartDate(issue: IssueEntity, allIssues: IssueEntity[]): string {
const dates = issue.dependsOnIds
.map((id) => allIssues.find((i) => i.id === id)?.endDate)
.filter((d): d is string => !!d);
if (dates.length === 0) return '';
return dates.reduce((max, d) => (d > max ? d : max));
}
private computeEndDate(startDate: string, estimatedTime: number | null): string {
if (!startDate || estimatedTime === null || estimatedTime <= 0) return '';
const start = new Date(startDate);
const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1);
start.setDate(start.getDate() + extraDays);
return start.toISOString().split('T')[0];
} }
private normalizeIssue( private normalizeIssue(
@@ -110,6 +154,9 @@ export class IssuesStore {
return { return {
...issue, ...issue,
type: issue.type ?? 'Story', type: issue.type ?? 'Story',
startDate: issue.startDate ?? '',
startDateMode: issue.startDateMode === 'calculated' ? 'calculated' : 'forced',
endDate: issue.endDate ?? '',
estimatedTime: issue.estimatedTime ?? null, estimatedTime: issue.estimatedTime ?? null,
dependsOnIds: normalizedDependencies, dependsOnIds: normalizedDependencies,
comments: Array.isArray(issue.comments) comments: Array.isArray(issue.comments)
@@ -98,6 +98,24 @@
background: #f3f4f6; background: #f3f4f6;
} }
.dep-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.6rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 999px;
font-size: 0.85rem;
color: #374151;
}
.dep-id {
font-weight: 700;
color: #6b7280;
font-size: 0.78rem;
}
.dep-remove { .dep-remove {
border: none; border: none;
background: transparent; background: transparent;
@@ -47,6 +47,26 @@
<div class="card-header section-header">Informations</div> <div class="card-header section-header">Informations</div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <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"> <div class="col-md-4">
<label class="field-label">Date d'échéance</label> <label class="field-label">Date d'échéance</label>
<input <input
@@ -74,6 +94,10 @@
<span class="text-secondary small" style="min-width: 2.5rem; text-align: right;">{{ progress }}%</span> <span class="text-secondary small" style="min-width: 2.5rem; text-align: right;">{{ progress }}%</span>
</div> </div>
</div> </div>
<div class="col-md-4">
<label class="field-label">Temps estimé total (h)</label>
<div class="form-control form-control-sm bg-body-secondary text-secondary">{{ totalEstimatedTime !== null ? totalEstimatedTime : '—' }}</div>
</div>
} }
</div> </div>
</div> </div>
@@ -108,6 +132,40 @@
</div> </div>
</div> </div>
<!-- Dépendances -->
@if (!isNewRoute) {
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Dépendances</div>
<div class="card-body">
@if (hasDependencies) {
<div class="d-flex flex-wrap gap-2 mb-3">
@for (depId of dependencyIds; track depId) {
<span class="dep-badge">
<span class="dep-id">#{{ depId }}</span>
{{ resolveDependency(depId)?.name || 'Sans nom' }}
<button type="button" class="dep-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
</span>
}
</div>
}
@if (showAddDependency) {
<div class="d-flex gap-2 flex-wrap">
<select aria-label="Choisir un milestone" class="form-select form-select-sm dep-select" [(ngModel)]="selectedCandidateMilestoneId">
<option [ngValue]="null">Choisir un milestone...</option>
@for (candidate of availableCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddDependency()" [disabled]="selectedCandidateMilestoneId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddDependency()">Annuler</button>
</div>
} @else {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openAddDependency()">+ Ajouter une dépendance</button>
}
</div>
</div>
}
<!-- Issues liées --> <!-- Issues liées -->
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header section-header d-flex align-items-center justify-content-between"> <div class="card-header section-header d-flex align-items-center justify-content-between">
@@ -237,6 +295,16 @@
</div> </div>
</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 --> <!-- Boutons de création -->
@if (isNewRoute) { @if (isNewRoute) {
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -14,6 +14,9 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
assignee: '', assignee: '',
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -29,8 +32,11 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1, id: 1,
name: 'Sprint 1', name: 'Sprint 1',
description: '', description: '',
startDate: '',
endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -273,6 +279,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', () => { describe('issueSuggestions', () => {
beforeEach(() => { beforeEach(() => {
issuesStore.seed([ issuesStore.seed([
@@ -456,6 +502,87 @@ describe('MilestoneDetail', () => {
}); });
}); });
describe('dependencies', () => {
beforeEach(() => {
milestonesStore.seed([
makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }),
makeMilestone({ id: 2, name: 'Sprint 2', dependsOnIds: [] }),
makeMilestone({ id: 3, name: 'Sprint 3', dependsOnIds: [] }),
]);
(component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] });
});
it('hasDependencies returns false when dependsOnIds is empty', () => {
expect((component as any).hasDependencies).toBe(false);
});
it('hasDependencies returns true when dependsOnIds is non-empty', () => {
(component as any).milestone.dependsOnIds = [2];
expect((component as any).hasDependencies).toBe(true);
});
it('dependencyIds returns the dependsOnIds array', () => {
(component as any).milestone.dependsOnIds = [2, 3];
expect((component as any).dependencyIds).toEqual([2, 3]);
});
it('availableCandidates excludes the current milestone', () => {
const candidates: MilestoneEntity[] = (component as any).availableCandidates;
expect(candidates.some((m: MilestoneEntity) => m.id === 1)).toBe(false);
});
it('availableCandidates excludes already-added dependencies', () => {
(component as any).milestone.dependsOnIds = [2];
const candidates: MilestoneEntity[] = (component as any).availableCandidates;
expect(candidates.some((m: MilestoneEntity) => m.id === 2)).toBe(false);
expect(candidates.some((m: MilestoneEntity) => m.id === 3)).toBe(true);
});
it('resolveDependency returns the milestone with the given id', () => {
expect((component as any).resolveDependency(2)?.name).toBe('Sprint 2');
});
it('resolveDependency returns undefined for unknown id', () => {
expect((component as any).resolveDependency(999)).toBeUndefined();
});
it('openAddDependency shows the form and resets selection', () => {
(component as any).selectedCandidateMilestoneId = 2;
(component as any).openAddDependency();
expect((component as any).showAddDependency).toBe(true);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('cancelAddDependency hides the form and resets selection', () => {
(component as any).showAddDependency = true;
(component as any).selectedCandidateMilestoneId = 2;
(component as any).cancelAddDependency();
expect((component as any).showAddDependency).toBe(false);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('confirmAddDependency does nothing when no candidate is selected', async () => {
(component as any).selectedCandidateMilestoneId = null;
await (component as any).confirmAddDependency();
expect((component as any).milestone.dependsOnIds).toHaveLength(0);
});
it('confirmAddDependency adds the id and saves', async () => {
(component as any).selectedCandidateMilestoneId = 2;
await (component as any).confirmAddDependency();
expect((component as any).milestone.dependsOnIds).toContain(2);
expect((component as any).showAddDependency).toBe(false);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('removeDependency removes the id and saves', async () => {
(component as any).milestone.dependsOnIds = [2, 3];
await (component as any).removeDependency(2);
expect((component as any).milestone.dependsOnIds).not.toContain(2);
expect((component as any).milestone.dependsOnIds).toContain(3);
});
});
describe('deleteMilestone', () => { describe('deleteMilestone', () => {
it('removes the milestone and navigates to /milestones', async () => { it('removes the milestone and navigates to /milestones', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
@@ -536,6 +663,69 @@ describe('MilestoneDetail', () => {
expect((component as any).milestone.description).toContain('![image]'); expect((component as any).milestone.description).toContain('![image]');
}); });
}); });
describe('totalEstimatedTime', () => {
it('returns null when no linked issues', () => {
issuesStore.seed([]);
(component as any).milestone.issueIds = [];
expect((component as any).totalEstimatedTime).toBeNull();
});
it('returns null when all linked issues have null estimatedTime', () => {
issuesStore.seed([
makeIssue({ id: 1, estimatedTime: null }),
makeIssue({ id: 2, estimatedTime: null }),
]);
(component as any).milestone.issueIds = [1, 2];
expect((component as any).totalEstimatedTime).toBeNull();
});
it('returns the sum of estimatedTime for non-Epic issues', () => {
issuesStore.seed([
makeIssue({ id: 1, estimatedTime: 8 }),
makeIssue({ id: 2, estimatedTime: 4 }),
]);
(component as any).milestone.issueIds = [1, 2];
expect((component as any).totalEstimatedTime).toBe(12);
});
it('ignores null estimatedTime in the sum', () => {
issuesStore.seed([
makeIssue({ id: 1, estimatedTime: 8 }),
makeIssue({ id: 2, estimatedTime: null }),
]);
(component as any).milestone.issueIds = [1, 2];
expect((component as any).totalEstimatedTime).toBe(8);
});
it('uses the Epic own estimatedTime, not its children', () => {
issuesStore.seed([
makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 10 }),
makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }),
makeIssue({ id: 3, epic: 'My Epic', estimatedTime: 3 }),
]);
(component as any).milestone.issueIds = [1];
expect((component as any).totalEstimatedTime).toBe(10);
});
it('returns null for an Epic with null estimatedTime', () => {
issuesStore.seed([
makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: null }),
makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }),
]);
(component as any).milestone.issueIds = [1];
expect((component as any).totalEstimatedTime).toBeNull();
});
it('mixes Epics and plain issues correctly', () => {
issuesStore.seed([
makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 8 }),
makeIssue({ id: 3, type: 'Story', estimatedTime: 6 }),
]);
(component as any).milestone.issueIds = [1, 3];
expect((component as any).totalEstimatedTime).toBe(14);
});
});
}); });
describe('MilestoneDetail — new route', () => { describe('MilestoneDetail — new route', () => {
@@ -8,10 +8,11 @@ import { IssueEntity, IssuesStore } from '../../issues/issues.store';
import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util'; import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
@Component({ @Component({
selector: 'app-milestone-detail', selector: 'app-milestone-detail',
imports: [FormsModule], imports: [FormsModule, GanttDiagram],
templateUrl: './milestone-detail.html', templateUrl: './milestone-detail.html',
styleUrl: './milestone-detail.css', styleUrl: './milestone-detail.css',
}) })
@@ -34,6 +35,8 @@ export class MilestoneDetail {
protected issueSearchQuery = ''; protected issueSearchQuery = '';
protected showIssueSuggestions = false; protected showIssueSuggestions = false;
protected moreMenuOpen = false; protected moreMenuOpen = false;
protected showAddDependency = false;
protected selectedCandidateMilestoneId: number | null = null;
constructor() { constructor() {
this.milestonesStore.load().then(() => { this.milestonesStore.load().then(() => {
@@ -59,6 +62,47 @@ export class MilestoneDetail {
}); });
} }
protected get hasDependencies(): boolean {
return this.milestone.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] {
return this.milestone.dependsOnIds;
}
protected get availableCandidates(): MilestoneEntity[] {
return this.milestonesStore.milestones().filter(
(m) => m.id !== this.milestone.id && !this.milestone.dependsOnIds.includes(m.id),
);
}
protected resolveDependency(id: number): MilestoneEntity | undefined {
return this.milestonesStore.getById(id);
}
protected openAddDependency(): void {
this.selectedCandidateMilestoneId = null;
this.showAddDependency = true;
}
protected cancelAddDependency(): void {
this.showAddDependency = false;
this.selectedCandidateMilestoneId = null;
}
protected async confirmAddDependency(): Promise<void> {
if (this.selectedCandidateMilestoneId === null) return;
this.milestone.dependsOnIds = [...this.milestone.dependsOnIds, this.selectedCandidateMilestoneId];
this.selectedCandidateMilestoneId = null;
this.showAddDependency = false;
await this.saveMilestone();
}
protected async removeDependency(id: number): Promise<void> {
this.milestone.dependsOnIds = this.milestone.dependsOnIds.filter((depId) => depId !== id);
await this.saveMilestone();
}
protected get linkedIssues(): IssueEntity[] { protected get linkedIssues(): IssueEntity[] {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id)); return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
} }
@@ -94,6 +138,41 @@ export class MilestoneDetail {
return this.sanitizer.bypassSecurityTrustHtml(html); return this.sanitizer.bypassSecurityTrustHtml(html);
} }
protected get milestoneGanttTasks(): GanttTask[] {
const tasks: GanttTask[] = [];
const taskIds = new Set<string>();
const sorted = [...this.linkedIssues].sort((a, b) => {
if (!a.startDate && !b.startDate) return 0;
if (!a.startDate) return 1;
if (!b.startDate) return -1;
return a.startDate.localeCompare(b.startDate);
});
for (const issue of sorted) {
if (!issue.startDate || !issue.endDate) continue;
const deps = issue.dependsOnIds
.map((id) => `issue-${id}`)
.filter((id) => taskIds.has(id))
.join(',');
tasks.push({
id: `issue-${issue.id}`,
name: `#${issue.id} ${issue.name}`,
start: issue.startDate,
end: issue.endDate,
progress: issue.progress,
...(deps ? { dependencies: deps } : {}),
});
taskIds.add(`issue-${issue.id}`);
}
return tasks;
}
protected get totalEstimatedTime(): number | null {
const times = this.linkedIssues
.filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null)
.map((i) => i.estimatedTime);
return times.length === 0 ? null : times.reduce((a, b) => a + b, 0);
}
protected get progress(): number { protected get progress(): number {
if (this.linkedIssues.length === 0) return 0; if (this.linkedIssues.length === 0) return 0;
return Math.round( return Math.round(
@@ -141,6 +220,9 @@ export class MilestoneDetail {
assignee: '', assignee: '',
epic: '', epic: '',
name, name,
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
estimatedTime: null, estimatedTime: null,
@@ -243,9 +325,9 @@ export class MilestoneDetail {
private buildMilestone(): MilestoneEntity { private buildMilestone(): MilestoneEntity {
if (this.route.snapshot.routeConfig?.path === 'milestones/new') { if (this.route.snapshot.routeConfig?.path === 'milestones/new') {
return { id: 0, name: '', description: '', dueDate: '', issueIds: [] }; return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
} }
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); 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: [], dependsOnIds: [] };
} }
} }
@@ -10,8 +10,11 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1, id: 1,
name: 'Sprint 1', name: 'Sprint 1',
description: '', description: '',
startDate: '',
endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -43,7 +46,7 @@ describe('MilestonesApiService', () => {
describe('create', () => { describe('create', () => {
it('sends POST /api/milestones with the body and returns the created milestone', () => { 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: [], dependsOnIds: [] };
const response = makeMilestone({ id: 2, name: 'Sprint 2' }); const response = makeMilestone({ id: 2, name: 'Sprint 2' });
let result: MilestoneEntity | undefined; let result: MilestoneEntity | undefined;
service.create(body).subscribe((data) => (result = data)); service.create(body).subscribe((data) => (result = data));
+25 -2
View File
@@ -4,9 +4,16 @@
<h1 class="h2 mb-2">Milestones</h1> <h1 class="h2 mb-2">Milestones</h1>
<p class="text-secondary mb-0">Objectifs et jalons du projet.</p> <p class="text-secondary mb-0">Objectifs et jalons du projet.</p>
</div> </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> </div>
@if (viewMode === 'list') {
<div class="mb-3"> <div class="mb-3">
<input <input
type="search" type="search"
@@ -16,7 +23,18 @@
style="max-width: 300px;" style="max-width: 300px;"
/> />
</div> </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="card shadow-sm">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
@@ -25,6 +43,8 @@
<th>#</th> <th>#</th>
<th>Nom</th> <th>Nom</th>
<th>Description</th> <th>Description</th>
<th>Début</th>
<th>Fin</th>
<th>Échéance</th> <th>Échéance</th>
<th>Issues</th> <th>Issues</th>
<th>Progression</th> <th>Progression</th>
@@ -41,6 +61,8 @@
<td class="text-secondary small">#{{ milestone.id }}</td> <td class="text-secondary small">#{{ milestone.id }}</td>
<td class="fw-semibold">{{ milestone.name }}</td> <td class="fw-semibold">{{ milestone.name }}</td>
<td class="text-secondary small description-cell">{{ milestone.description }}</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 class="text-nowrap small">{{ formatDate(milestone.dueDate) }}</td>
<td> <td>
<span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span> <span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span>
@@ -64,10 +86,11 @@
} }
@if (filteredMilestones.length === 0) { @if (filteredMilestones.length === 0) {
<tr> <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> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
}
@@ -9,8 +9,11 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1, id: 1,
name: 'Sprint 1', name: 'Sprint 1',
description: '', description: '',
startDate: '',
endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -142,5 +145,21 @@ describe('MilestonesStore', () => {
const result = await p; const result = await p;
expect(result.issueIds).toEqual([1]); expect(result.issueIds).toEqual([1]);
}); });
it('normalizes dependsOnIds to empty array when not an array', async () => {
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: null } as any;
const p = store.upsert(raw);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, dependsOnIds: null });
const result = await p;
expect(result.dependsOnIds).toEqual([]);
});
it('filters non-number values from dependsOnIds', async () => {
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2, 'bad', null] } as any;
const p = store.upsert(raw);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] });
const result = await p;
expect(result.dependsOnIds).toEqual([2]);
});
}); });
}); });
+8
View File
@@ -6,8 +6,11 @@ export type MilestoneEntity = {
id: number; id: number;
name: string; name: string;
description: string; description: string;
startDate: string;
endDate: string;
dueDate: string; dueDate: string;
issueIds: number[]; issueIds: number[];
dependsOnIds: number[];
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -67,10 +70,15 @@ export class MilestonesStore {
id: milestone.id ?? 0, id: milestone.id ?? 0,
name: milestone.name ?? '', name: milestone.name ?? '',
description: milestone.description ?? '', description: milestone.description ?? '',
startDate: milestone.startDate ?? '',
endDate: milestone.endDate ?? '',
dueDate: milestone.dueDate ?? '', dueDate: milestone.dueDate ?? '',
issueIds: Array.isArray(milestone.issueIds) issueIds: Array.isArray(milestone.issueIds)
? milestone.issueIds.filter((v): v is number => typeof v === 'number') ? milestone.issueIds.filter((v): v is number => typeof v === 'number')
: [], : [],
dependsOnIds: Array.isArray(milestone.dependsOnIds)
? milestone.dependsOnIds.filter((v): v is number => typeof v === 'number')
: [],
}; };
} }
} }
+58 -3
View File
@@ -3,10 +3,11 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IssuesStore } from '../issues/issues.store'; import { IssuesStore } from '../issues/issues.store';
import { MilestoneEntity, MilestonesStore } from './milestones.store'; import { MilestoneEntity, MilestonesStore } from './milestones.store';
import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram';
@Component({ @Component({
selector: 'app-milestones', selector: 'app-milestones',
imports: [FormsModule], imports: [FormsModule, GanttDiagram],
templateUrl: './milestones.html', templateUrl: './milestones.html',
styleUrl: './milestones.css', styleUrl: './milestones.css',
}) })
@@ -22,11 +23,65 @@ export class Milestones {
protected readonly milestones = this.milestonesStore.milestones; protected readonly milestones = this.milestonesStore.milestones;
protected searchQuery = ''; protected searchQuery = '';
protected viewMode: 'list' | 'gantt' = 'list';
protected get ganttTasks(): GanttTask[] {
const today = new Date().toISOString().slice(0, 10);
const tasks: GanttTask[] = [];
const taskIds = new Set<string>();
const sorted = [...this.milestones()].sort((a, b) => {
if (!a.startDate && !b.startDate) return 0;
if (!a.startDate) return 1;
if (!b.startDate) return -1;
return a.startDate.localeCompare(b.startDate);
});
for (const milestone of sorted) {
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;
const deps = milestone.dependsOnIds
.map((id) => `milestone-${id}`)
.filter((id) => taskIds.has(id))
.join(',');
tasks.push({
id: `milestone-${milestone.id}`,
name: milestone.name,
start,
end: clampedEnd,
progress: this.getProgress(milestone),
...(deps ? { dependencies: deps } : {}),
});
taskIds.add(`milestone-${milestone.id}`);
}
return tasks;
}
protected get filteredMilestones(): MilestoneEntity[] { protected get filteredMilestones(): MilestoneEntity[] {
const q = this.searchQuery.trim().toLowerCase(); const q = this.searchQuery.trim().toLowerCase();
if (!q) return this.milestones(); const list = q
return this.milestones().filter((m) => m.name.toLowerCase().includes(q)); ? this.milestones().filter((m) => m.name.toLowerCase().includes(q))
: this.milestones();
return [...list].sort((a, b) => {
if (!a.startDate && !b.startDate) return 0;
if (!a.startDate) return 1;
if (!b.startDate) return -1;
return a.startDate.localeCompare(b.startDate);
});
} }
protected getProgress(milestone: MilestoneEntity): number { protected getProgress(milestone: MilestoneEntity): number {
@@ -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,
},
);
});
});
}
}
+28
View File
@@ -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;
}
}