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:
@@ -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
@@ -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
@@ -31,7 +31,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css",
|
||||||
|
"node_modules/frappe-gantt/dist/frappe-gantt.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
Generated
+9
-2
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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')
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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