From 1d41e29d07f33b274b2903c83e75a65a2dd188ab Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 21:24:03 +0200 Subject: [PATCH 1/8] Icone dans onglets navigateur --- public/favicon.svg | 24 ++++++++++++++++++++++++ src/index.html | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 public/favicon.svg diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..7198282 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/index.html b/src/index.html index bcda8ae..be337ad 100644 --- a/src/index.html +++ b/src/index.html @@ -2,9 +2,10 @@ - BonsaiWebapp + Bonsai + From 6def78d30f8e8d80aa4290a8d9500f46a369c41a Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 22:04:13 +0200 Subject: [PATCH 2/8] Ajout identifant issue dans tableau des issues --- src/app/issues/issues.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index 7ed0b19..20e5713 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -31,6 +31,7 @@ + @@ -47,6 +48,7 @@ (click)="openIssue(issue.id)" (keydown.enter)="openIssue(issue.id)" > + From 9fc88d887596ebf2065229626b62e9ae42796967 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 22:46:30 +0200 Subject: [PATCH 3/8] =?UTF-8?q?Ajoute=20icone=20pour=20priorite=20+=20chan?= =?UTF-8?q?gement=20donn=C3=A9e=20envoy=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +++- .../issue-comments/issue-comments.spec.ts | 2 +- src/app/issues/issue-detail/issue-detail.html | 14 ++++++++++---- .../issues/issue-detail/issue-detail.spec.ts | 2 +- src/app/issues/issue-detail/issue-detail.ts | 17 ++++++++++++++--- src/app/issues/issues.html | 8 +++++++- src/app/issues/issues.spec.ts | 4 ++-- src/app/issues/issues.store.spec.ts | 2 +- src/app/issues/issues.store.ts | 2 +- src/app/issues/issues.ts | 11 +++++++++++ 10 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 97e5598..8594700 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(npm list *)", "Bash(./node_modules/.bin/ng test *)", "Bash(npx ng *)", - "Bash(npm start *)" + "Bash(npm start *)", + "Bash(xargs cat -n)", + "Bash(xargs ls -la)" ] } } diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 4bb3bea..1a1fbb9 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -14,7 +14,7 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, ...overrides, diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index 7f99959..473331f 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -67,9 +67,11 @@
@if (!isEpicIssue) { @@ -204,7 +206,11 @@ #{{ composedIssue.id }} – {{ composedIssue.name || 'Sans nom' }}
- {{ composedIssue.priority }} + {{ priorityDisplay(composedIssue.priority).symbol }} {{ composedIssue.assignee || 'Non assigné' }}
diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 187c461..6e4e02b 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -18,7 +18,7 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, ...overrides, diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 4dc99ae..43413d8 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -171,7 +171,7 @@ export class IssueDetail { estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, }); @@ -213,6 +213,17 @@ export class IssueDetail { return this.getBadgeClass(this.issueTypeValue); } + protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } { + const map: Record = { + 'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545', label: 'Très haute' }, + 'HAUTE': { symbol: '↑', color: '#fd7e14', label: 'Haute' }, + 'MOYENNE': { symbol: '–', color: '#ffc107', label: 'Moyenne' }, + 'BASSE': { symbol: '↓', color: '#0d6efd', label: 'Basse' }, + 'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d', label: 'Très faible'}, + }; + return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority }; + } + protected getBadgeClass(type: IssueEntity['type']): string { const map: Record = { Bug: 'text-bg-danger', @@ -290,7 +301,7 @@ export class IssueDetail { estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, }; @@ -311,7 +322,7 @@ export class IssueDetail { estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, } diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index 20e5713..aeaccaf 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -51,7 +51,13 @@
- + - + - +
# Titre Type Priorite#{{ issue.id }} {{ issue.name }} {{ issue.type }} {{ issue.priority }}#{{ issue.id }} {{ issue.name }} {{ issue.type }}{{ issue.priority }} + {{ priorityDisplay(issue.priority).symbol }} + {{ issue.status }} {{ issue.assignee }} diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index baa25dc..689c428 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -17,7 +17,7 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 50, ...overrides, @@ -58,7 +58,7 @@ class FakeIssuesStore { description: '', estimatedTime: et ?? null, comments: Array.isArray(c) ? c : [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, ...rest, diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index fe02fd6..a176185 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -15,7 +15,7 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ estimatedTime: null, dependsOnIds: [], comments: [], - priority: 'Moyenne', + priority: 'MOYENNE', status: 'draft', progress: 0, ...overrides, diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index c6bfb19..464a6b8 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -3,7 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { IssuesApiService } from './issues-api.service'; export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress'; -export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute'; +export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE'; export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story'; export type IssueComment = { diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index 4486d2f..0843bb7 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -52,6 +52,17 @@ export class Issues { return Math.round((done / children.length) * 100); } + protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } { + const map: Record = { + 'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545', label: 'Très haute' }, + 'HAUTE': { symbol: '↑', color: '#fd7e14', label: 'Haute' }, + 'MOYENNE': { symbol: '–', color: '#ffc107', label: 'Moyenne' }, + 'BASSE': { symbol: '↓', color: '#0d6efd', label: 'Basse' }, + 'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d', label: 'Très faible'}, + }; + return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority }; + } + protected typeBadgeClass(type: IssueEntity['type']): string { const map: Record = { Bug: 'text-bg-danger', From 531c31093ecceab3864cb49f5f637a29788fc8f3 Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 07:54:10 +0200 Subject: [PATCH 4/8] Modification visuel status et type issue --- .claude/settings.local.json | 10 +++- src/app/issues/issue-detail/issue-detail.css | 43 +++++++++++++++ src/app/issues/issue-detail/issue-detail.html | 53 +++++++++++++++---- .../issues/issue-detail/issue-detail.spec.ts | 30 +++++------ src/app/issues/issue-detail/issue-detail.ts | 36 ++++++++++++- src/app/issues/issues.css | 1 + src/app/issues/issues.html | 29 +++++++--- src/app/issues/issues.ts | 22 ++++++++ src/styles.css | 33 ++++++++++++ 9 files changed, 220 insertions(+), 37 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8594700..6b9aac8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,15 @@ "Bash(npx ng *)", "Bash(npm start *)", "Bash(xargs cat -n)", - "Bash(xargs ls -la)" + "Bash(xargs ls -la)", + "Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)", + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)" + ], + "additionalDirectories": [ + "/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", + "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", + "/home/Gato/IdeaProjects/Bonsai-webapp/src", + "/var/home/Gato/IdeaProjects/Bonsai-webapp/src" ] } } diff --git a/src/app/issues/issue-detail/issue-detail.css b/src/app/issues/issue-detail/issue-detail.css index ea570c7..bdd9d8c 100644 --- a/src/app/issues/issue-detail/issue-detail.css +++ b/src/app/issues/issue-detail/issue-detail.css @@ -27,6 +27,49 @@ font-weight: 400; } +/* Status split button */ +.status-split-wrapper { + position: relative; +} + +.status-main-btn { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.28rem 0.6rem; + border: 1px solid; + border-right: none; + border-radius: 3px 0 0 3px; + white-space: nowrap; + cursor: default; +} + +.status-toggle-btn { + padding: 0.28rem 0.45rem; + border: 1px solid; + border-radius: 0 3px 3px 0; + cursor: pointer; + transition: filter 0.1s; +} + +.status-toggle-btn:hover { + filter: brightness(0.92); +} + +.status-backdrop { + position: fixed; + inset: 0; + z-index: 9; +} + +.status-dropdown { + position: absolute; + right: 0; + top: calc(100% + 0.3rem); + min-width: 10rem; + z-index: 10; +} + /* More menu */ .more-wrapper { position: relative; diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index 473331f..cfe8de5 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -3,20 +3,51 @@
- {{ issue.type }} + {{ typeIcon(issue.type).letter }} #{{ issue.id }}
- +
@if (!isNewIssueRoute) {
@@ -202,7 +233,7 @@ (click)="openComposedIssue(composedIssue.id)" >
- {{ composedIssue.type }} + {{ typeIcon(composedIssue.type).letter }} #{{ composedIssue.id }} – {{ composedIssue.name || 'Sans nom' }}
diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 6e4e02b..9a38190 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -287,34 +287,30 @@ describe('IssueDetail — existing issue', () => { }); }); - describe('getBadgeClass / typeBadgeClass', () => { - it('typeBadgeClass returns class for current issue type', () => { + describe('typeIcon', () => { + it('typeIcon returns correct icon for current issue type', () => { (component as any).issue.type = 'Bug'; - expect((component as any).typeBadgeClass).toBe('text-bg-danger'); + expect((component as any).typeIcon('Bug')).toEqual({ letter: 'B', bg: '#dc2626' }); }); - it('getBadgeClass maps Bug to text-bg-danger', () => { - expect((component as any).getBadgeClass('Bug')).toBe('text-bg-danger'); + it('typeIcon maps Epic correctly', () => { + expect((component as any).typeIcon('Epic')).toEqual({ letter: 'E', bg: '#7c3aed' }); }); - it('getBadgeClass maps Study to text-bg-secondary', () => { - expect((component as any).getBadgeClass('Study')).toBe('text-bg-secondary'); + it('typeIcon maps Story correctly', () => { + expect((component as any).typeIcon('Story')).toEqual({ letter: 'S', bg: '#16a34a' }); }); - it('getBadgeClass maps Story to text-bg-success', () => { - expect((component as any).getBadgeClass('Story')).toBe('text-bg-success'); + it('typeIcon maps Task correctly', () => { + expect((component as any).typeIcon('Task')).toEqual({ letter: 'T', bg: '#2563eb' }); }); - it('getBadgeClass maps Task to text-bg-primary', () => { - expect((component as any).getBadgeClass('Task')).toBe('text-bg-primary'); + it('typeIcon maps Technical Story correctly', () => { + expect((component as any).typeIcon('Technical Story')).toEqual({ letter: 'TS', bg: '#d97706' }); }); - it('getBadgeClass maps Technical Story to text-bg-warning', () => { - expect((component as any).getBadgeClass('Technical Story')).toBe('text-bg-warning'); - }); - - it('getBadgeClass maps Epic to text-bg-info', () => { - expect((component as any).getBadgeClass('Epic')).toBe('text-bg-info'); + it('typeIcon maps Study correctly', () => { + expect((component as any).typeIcon('Study')).toEqual({ letter: 'St', bg: '#6b7280' }); }); }); diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 43413d8..df1537c 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -23,6 +23,7 @@ export class IssueDetail { protected issue: IssueEntity = this.buildIssue(); protected readonly issues = this.issuesStore.issues; protected moreMenuOpen = false; + protected statusMenuOpen = false; constructor() { const idParam = this.route.snapshot.paramMap.get('id'); @@ -209,8 +210,26 @@ export class IssueDetail { return this.sanitizer.bypassSecurityTrustHtml(html); } - protected get typeBadgeClass(): string { - return this.getBadgeClass(this.issueTypeValue); + protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } { + const map: Record = { + Epic: { letter: 'E', bg: '#7c3aed' }, + Bug: { letter: 'B', bg: '#dc2626' }, + Story: { letter: 'S', bg: '#16a34a' }, + Task: { letter: 'T', bg: '#2563eb' }, + Study: { letter: 'St', bg: '#6b7280' }, + 'Technical Story':{ letter: 'TS', bg: '#d97706' }, + }; + return map[type] ?? { letter: '?', bg: '#6b7280' }; + } + + protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { + const map: Record = { + draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' }, + todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' }, + 'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' }, + done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' }, + }; + return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' }; } protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } { @@ -284,6 +303,19 @@ export class IssueDetail { this.moreMenuOpen = false; } + protected toggleStatusMenu(): void { + this.statusMenuOpen = !this.statusMenuOpen; + } + + protected closeStatusMenu(): void { + this.statusMenuOpen = false; + } + + protected async selectStatus(status: IssueEntity['status']): Promise { + this.statusMenuOpen = false; + await this.updateStatus(status); + } + private buildIssue(): IssueEntity { const idParam = this.route.snapshot.paramMap.get('id'); const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; diff --git a/src/app/issues/issues.css b/src/app/issues/issues.css index 13ebb0b..99ee86a 100644 --- a/src/app/issues/issues.css +++ b/src/app/issues/issues.css @@ -25,3 +25,4 @@ } + diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index aeaccaf..1c3c238 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -18,11 +18,16 @@ @for (type of typeOptions; track type) { + > + {{ typeIcon(type).letter }} + {{ type }} + }
@@ -50,7 +55,13 @@ >
#{{ issue.id }} {{ issue.name }}{{ issue.type }} + {{ typeIcon(issue.type).letter }} + {{ priorityDisplay(issue.priority).symbol }} {{ issue.status }} + {{ statusBadge(issue.status).label }} + {{ issue.assignee }}
diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index 0843bb7..f46b37d 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -63,6 +63,18 @@ export class Issues { return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority }; } + protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } { + const map: Record = { + Epic: { letter: 'E', bg: '#7c3aed' }, + Bug: { letter: 'B', bg: '#dc2626' }, + Story: { letter: 'S', bg: '#16a34a' }, + Task: { letter: 'T', bg: '#2563eb' }, + Study: { letter: 'St', bg: '#6b7280' }, + 'Technical Story':{ letter: 'TS', bg: '#d97706' }, + }; + return map[type] ?? { letter: '?', bg: '#6b7280' }; + } + protected typeBadgeClass(type: IssueEntity['type']): string { const map: Record = { Bug: 'text-bg-danger', @@ -74,4 +86,14 @@ export class Issues { }; return map[type] ?? 'text-bg-secondary'; } + + protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { + const map: Record = { + draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' }, + todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' }, + 'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' }, + done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' }, + }; + return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' }; + } } diff --git a/src/styles.css b/src/styles.css index 31dc510..b0f0a3e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9,3 +9,36 @@ body { margin: 0; background-color: #f8f9fa; } + +.markdown-body li { + margin-top: 0; + margin-bottom: 0.15rem; +} + +.markdown-body li > p { + margin: 0; +} + +.type-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + flex-shrink: 0; +} + +.status-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; +} From ef76055339d1ba059c6db1cccddc2f2a89cb679b Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 18:26:18 +0200 Subject: [PATCH 5/8] Filtre sur les issues --- .claude/settings.local.json | 4 +- src/app/issues/issues.css | 8 +++ src/app/issues/issues.html | 88 ++++++++++++++++++++++++++------- src/app/issues/issues.spec.ts | 92 ++++++++++++++++++++++++++--------- src/app/issues/issues.ts | 71 ++++++++++++++++++++++++--- 5 files changed, 214 insertions(+), 49 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6b9aac8..2c1ee00 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "Bash(xargs cat -n)", "Bash(xargs ls -la)", "Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)", - "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)" + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)", + "Read(//home/Gato/IdeaProjects/Bonsai-webapp/**)", + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)" ], "additionalDirectories": [ "/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", diff --git a/src/app/issues/issues.css b/src/app/issues/issues.css index 99ee86a..6a9f3bf 100644 --- a/src/app/issues/issues.css +++ b/src/app/issues/issues.css @@ -24,5 +24,13 @@ text-align: right; } +.filter-check { + display: inline-block; + width: 1rem; + text-align: center; + color: #2563eb; + font-weight: 700; +} + diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index 1c3c238..7c29ff5 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -7,28 +7,80 @@
-
- - @for (type of typeOptions; track type) { +
+ + + + + + +
diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 689c428..ebe2637 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -114,48 +114,96 @@ describe('Issues', () => { expect(component).toBeTruthy(); }); + const mockEvent = { stopPropagation: () => {} } as unknown as Event; + describe('filteredIssues', () => { - it('returns all issues when no type is selected', () => { - (component as any).selectedType = null; + it('returns all issues when no types are selected', () => { + (component as any).selectedTypes = new Set(); expect((component as any).filteredIssues.length).toBe(store.issues().length); }); it('returns only issues matching the selected type', () => { - (component as any).selectedType = 'Bug'; + (component as any).selectedTypes = new Set(['Bug']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.type === 'Bug')).toBe(true); }); - it('returns empty array when no issues match the selected type', () => { - (component as any).selectedType = 'Epic'; + it('returns empty array when no issues match the selected types', () => { + (component as any).selectedTypes = new Set(['Epic']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.type === 'Epic')).toBe(true); }); + + it('returns issues matching any of multiple selected types', () => { + (component as any).selectedTypes = new Set(['Bug', 'Story']); + const filtered: IssueEntity[] = (component as any).filteredIssues; + expect(filtered.every((i) => i.type === 'Bug' || i.type === 'Story')).toBe(true); + }); + + it('filters by status when selectedStatuses is set', () => { + (component as any).selectedStatuses = new Set(['done']); + const filtered: IssueEntity[] = (component as any).filteredIssues; + expect(filtered.every((i) => i.status === 'done')).toBe(true); + }); + + it('filters by search query on issue name', () => { + (component as any).searchQuery = 'Issue 1'; + const filtered: IssueEntity[] = (component as any).filteredIssues; + expect(filtered.length).toBe(1); + expect(filtered[0].name).toBe('Issue 1'); + }); }); - describe('selectType', () => { - it('sets selectedType when none is active', () => { - (component as any).selectedType = null; - (component as any).selectType('Bug'); - expect((component as any).selectedType).toBe('Bug'); + describe('toggleType', () => { + it('adds a type when not already selected', () => { + (component as any).selectedTypes = new Set(); + (component as any).toggleType('Bug', mockEvent); + expect((component as any).selectedTypes.has('Bug')).toBe(true); }); - it('clears selectedType when the same type is selected again (toggle off)', () => { - (component as any).selectedType = 'Bug'; - (component as any).selectType('Bug'); - expect((component as any).selectedType).toBeNull(); + it('removes a type when already selected (toggle off)', () => { + (component as any).selectedTypes = new Set(['Bug']); + (component as any).toggleType('Bug', mockEvent); + expect((component as any).selectedTypes.has('Bug')).toBe(false); }); - it('switches to a different type', () => { - (component as any).selectedType = 'Bug'; - (component as any).selectType('Story'); - expect((component as any).selectedType).toBe('Story'); + it('can select multiple types simultaneously', () => { + (component as any).selectedTypes = new Set(); + (component as any).toggleType('Bug', mockEvent); + (component as any).toggleType('Story', mockEvent); + expect((component as any).selectedTypes.size).toBe(2); + expect((component as any).selectedTypes.has('Bug')).toBe(true); + expect((component as any).selectedTypes.has('Story')).toBe(true); + }); + }); + + describe('clearTypes', () => { + it('empties the type selection', () => { + (component as any).selectedTypes = new Set(['Bug', 'Story']); + (component as any).clearTypes(mockEvent); + expect((component as any).selectedTypes.size).toBe(0); + }); + }); + + describe('toggleStatus', () => { + it('adds a status when not already selected', () => { + (component as any).selectedStatuses = new Set(); + (component as any).toggleStatus('done', mockEvent); + expect((component as any).selectedStatuses.has('done')).toBe(true); }); - it('selectType(null) clears the filter', () => { - (component as any).selectedType = 'Bug'; - (component as any).selectType(null); - expect((component as any).selectedType).toBeNull(); + it('removes a status when already selected (toggle off)', () => { + (component as any).selectedStatuses = new Set(['done']); + (component as any).toggleStatus('done', mockEvent); + expect((component as any).selectedStatuses.has('done')).toBe(false); + }); + }); + + describe('clearStatuses', () => { + it('empties the status selection', () => { + (component as any).selectedStatuses = new Set(['todo', 'done']); + (component as any).clearStatuses(mockEvent); + expect((component as any).selectedStatuses.size).toBe(0); }); }); diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index f46b37d..39b760b 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -1,10 +1,11 @@ -import { Component, inject } from '@angular/core'; +import { Component, HostListener, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { IssueEntity, IssuesStore } from './issues.store'; +import { IssueEntity, IssueStatus, IssuesStore } from './issues.store'; @Component({ selector: 'app-issues', - imports: [], + imports: [FormsModule], templateUrl: './issues.html', styleUrl: './issues.css', }) @@ -17,19 +18,73 @@ export class Issues { } protected readonly issues = this.issuesStore.issues; - protected selectedType: IssueEntity['type'] | null = null; + protected searchQuery = ''; + protected selectedTypes = new Set(); + protected selectedStatuses = new Set(); + protected openDropdown: 'type' | 'status' | null = null; protected readonly typeOptions: IssueEntity['type'][] = [ 'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story', ]; + protected readonly statusOptions: IssueStatus[] = [ + 'draft', 'todo', 'in-progress', 'done', + ]; + protected get filteredIssues(): IssueEntity[] { - if (this.selectedType === null) return this.issues(); - return this.issues().filter((i) => i.type === this.selectedType); + const q = this.searchQuery.trim().toLowerCase(); + return this.issues().filter((i) => { + if (this.selectedTypes.size > 0 && !this.selectedTypes.has(i.type)) return false; + if (this.selectedStatuses.size > 0 && !this.selectedStatuses.has(i.status)) return false; + if (q && !i.name.toLowerCase().includes(q)) return false; + return true; + }); } - protected selectType(type: IssueEntity['type'] | null): void { - this.selectedType = this.selectedType === type ? null : type; + protected toggleDropdown(name: 'type' | 'status', event: Event): void { + event.stopPropagation(); + this.openDropdown = this.openDropdown === name ? null : name; + } + + @HostListener('document:click') + protected closeDropdowns(): void { + this.openDropdown = null; + } + + protected toggleType(type: IssueEntity['type'], event: Event): void { + event.stopPropagation(); + const next = new Set(this.selectedTypes); + next.has(type) ? next.delete(type) : next.add(type); + this.selectedTypes = next; + } + + protected clearTypes(event: Event): void { + event.stopPropagation(); + this.selectedTypes = new Set(); + } + + protected toggleStatus(status: IssueStatus, event: Event): void { + event.stopPropagation(); + const next = new Set(this.selectedStatuses); + next.has(status) ? next.delete(status) : next.add(status); + this.selectedStatuses = next; + } + + protected clearStatuses(event: Event): void { + event.stopPropagation(); + this.selectedStatuses = new Set(); + } + + protected typeDropdownLabel(): string { + if (this.selectedTypes.size === 0) return 'Type'; + if (this.selectedTypes.size === 1) return [...this.selectedTypes][0]; + return `Type (${this.selectedTypes.size})`; + } + + protected statusDropdownLabel(): string { + if (this.selectedStatuses.size === 0) return 'Statut'; + if (this.selectedStatuses.size === 1) return this.statusBadge([...this.selectedStatuses][0]).label; + return `Statut (${this.selectedStatuses.size})`; } protected createIssue(): void { From 600f2b619624ebccfe8c9bf7871bf9c78603752c Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 18:48:25 +0200 Subject: [PATCH 6/8] ajout d'image --- .../issues/issue-comments/issue-comments.html | 4 +- .../issue-comments/issue-comments.spec.ts | 52 +++++++++++ .../issues/issue-comments/issue-comments.ts | 14 +++ src/app/issues/issue-detail/issue-detail.html | 1 + .../issues/issue-detail/issue-detail.spec.ts | 31 +++++++ src/app/issues/issue-detail/issue-detail.ts | 10 +++ src/app/issues/paste-image.util.spec.ts | 88 +++++++++++++++++++ src/app/issues/paste-image.util.ts | 27 ++++++ 8 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/app/issues/paste-image.util.spec.ts create mode 100644 src/app/issues/paste-image.util.ts diff --git a/src/app/issues/issue-comments/issue-comments.html b/src/app/issues/issue-comments/issue-comments.html index 0b56166..70e8705 100644 --- a/src/app/issues/issue-comments/issue-comments.html +++ b/src/app/issues/issue-comments/issue-comments.html @@ -23,6 +23,7 @@ rows="3" [(ngModel)]="editingCommentText" (keydown.escape)="cancelEditComment()" + (paste)="onPaste($event, 'edit')" >
@@ -39,8 +40,9 @@ aria-label="Nouveau commentaire" class="form-control form-control-sm" rows="3" - placeholder="Ajouter un commentaire... (Markdown supporté)" + placeholder="Ajouter un commentaire... (Markdown supporté, coller une image avec Ctrl+V)" [(ngModel)]="newCommentText" + (paste)="onPaste($event, 'new')" >
diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 1a1fbb9..3b7d8f3 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { afterEach, vi } from 'vitest'; import { IssueComments } from './issue-comments'; import { IssueEntity, IssuesStore } from '../issues.store'; @@ -231,6 +232,57 @@ describe('IssueComments', () => { }); }); + describe('onPaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + function mockFileReader(result: string) { + vi.stubGlobal('FileReader', class { + readonly result = result; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); + } + + function makePasteEvent(ta: HTMLTextAreaElement): ClipboardEvent { + const file = new File([''], 'img.png', { type: 'image/png' }); + return { + clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + } + + it('inserts image markdown into newCommentText at cursor', async () => { + mockFileReader('data:image/png;base64,test'); + (component as any).newCommentText = 'hello world'; + const ta = document.createElement('textarea'); + ta.value = 'hello world'; + ta.selectionStart = 5; + ta.selectionEnd = 5; + + (component as any).onPaste(makePasteEvent(ta), 'new'); + await Promise.resolve(); + + expect((component as any).newCommentText).toBe('hello![image](data:image/png;base64,test) world'); + }); + + it('inserts image markdown into editingCommentText at cursor', async () => { + mockFileReader('data:image/png;base64,test'); + (component as any).editingCommentText = 'some text'; + const ta = document.createElement('textarea'); + ta.value = 'some text'; + ta.selectionStart = 0; + ta.selectionEnd = 0; + + (component as any).onPaste(makePasteEvent(ta), 'edit'); + await Promise.resolve(); + + expect((component as any).editingCommentText).toContain('![image](data:image/png;base64,test)'); + }); + }); + describe('deleteComment', () => { it('removes the comment from the issue', () => { (component as any).newCommentText = 'To delete'; diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index a092319..1408481 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -2,6 +2,7 @@ import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { marked } from 'marked'; +import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { IssueComment, IssuesStore } from '../issues.store'; @Component({ @@ -68,6 +69,19 @@ export class IssueComments { this.editingCommentText = ''; } + protected onPaste(event: ClipboardEvent, field: 'new' | 'edit'): void { + const ta = event.target as HTMLTextAreaElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + handleImagePaste(event, (md) => { + if (field === 'new') { + this.newCommentText = insertAtSelection(ta, this.newCommentText, start, end, md); + } else { + this.editingCommentText = insertAtSelection(ta, this.editingCommentText, start, end, md); + } + }); + } + protected cancelEditComment(): void { this.editingCommentId = null; this.editingCommentText = ''; diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index cfe8de5..c53941a 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -165,6 +165,7 @@ placeholder="Ajouter une description..." [(ngModel)]="issue.description" (blur)="editingDescription = false; saveIssue()" + (paste)="onDescriptionPaste($event)" > } @else {
{ }); }); + describe('onDescriptionPaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('inserts image markdown into issue.description', async () => { + vi.stubGlobal('FileReader', class { + readonly result = 'data:image/png;base64,xyz'; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); + + (component as any).issue.description = 'existing'; + const ta = document.createElement('textarea'); + ta.value = 'existing'; + ta.selectionStart = 0; + ta.selectionEnd = 0; + const file = new File([''], 'img.png', { type: 'image/png' }); + const event = { + clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + + (component as any).onDescriptionPaste(event); + await Promise.resolve(); + + expect((component as any).issue.description).toContain('![image](data:image/png;base64,xyz)'); + }); + }); + describe('descriptionHtml', () => { it('returns a truthy SafeHtml for markdown input', () => { (component as any).issue.description = '# Title\n**bold**'; diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index df1537c..a947f87 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -6,6 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { marked } from 'marked'; import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueComments } from '../issue-comments/issue-comments'; +import { handleImagePaste, insertAtSelection } from '../paste-image.util'; @Component({ selector: 'app-issue-detail', @@ -205,6 +206,15 @@ export class IssueDetail { return this.issueTypeValue === 'Epic'; } + protected onDescriptionPaste(event: ClipboardEvent): void { + const ta = event.target as HTMLTextAreaElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + handleImagePaste(event, (md) => { + this.issue.description = insertAtSelection(ta, this.issue.description, start, end, md); + }); + } + protected get descriptionHtml(): SafeHtml { const html = marked.parse(this.issue.description || '') as string; return this.sanitizer.bypassSecurityTrustHtml(html); diff --git a/src/app/issues/paste-image.util.spec.ts b/src/app/issues/paste-image.util.spec.ts new file mode 100644 index 0000000..0910583 --- /dev/null +++ b/src/app/issues/paste-image.util.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { handleImagePaste, insertAtSelection } from './paste-image.util'; + +function makePasteEvent(items: Partial[]): ClipboardEvent { + return { + clipboardData: { items }, + preventDefault: vi.fn(), + target: document.createElement('textarea'), + } as unknown as ClipboardEvent; +} + +function mockFileReader(result: string) { + vi.stubGlobal('FileReader', class { + readonly result = result; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); +} + +describe('insertAtSelection', () => { + it('inserts at cursor position', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello world', 5, 5, '!')).toBe('hello! world'); + }); + + it('replaces selected text', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello world', 6, 11, 'Angular')).toBe('hello Angular'); + }); + + it('inserts at the start', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'world', 0, 0, 'hello ')).toBe('hello world'); + }); + + it('appends at the end', () => { + const ta = document.createElement('textarea'); + expect(insertAtSelection(ta, 'hello', 5, 5, ' world')).toBe('hello world'); + }); +}); + +describe('handleImagePaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('calls onImage with markdown and calls preventDefault', async () => { + mockFileReader('data:image/png;base64,abc123'); + const file = new File([''], 'img.png', { type: 'image/png' }); + const event = makePasteEvent([{ type: 'image/png', getAsFile: () => file }]); + + const onImage = vi.fn(); + handleImagePaste(event, onImage); + + expect((event as any).preventDefault).toHaveBeenCalled(); + await Promise.resolve(); + expect(onImage).toHaveBeenCalledWith('![image](data:image/png;base64,abc123)'); + }); + + it('does nothing when clipboard has no image', () => { + const event = makePasteEvent([{ type: 'text/plain', getAsFile: () => null }]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + expect((event as any).preventDefault).not.toHaveBeenCalled(); + expect(onImage).not.toHaveBeenCalled(); + }); + + it('does nothing when clipboardData is null', () => { + const event = { clipboardData: null, preventDefault: vi.fn() } as unknown as ClipboardEvent; + handleImagePaste(event, vi.fn()); + expect((event as any).preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing when items is empty', () => { + const event = makePasteEvent([]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + expect(onImage).not.toHaveBeenCalled(); + }); + + it('skips items where getAsFile returns null', async () => { + const event = makePasteEvent([{ type: 'image/png', getAsFile: () => null }]); + const onImage = vi.fn(); + handleImagePaste(event, onImage); + await Promise.resolve(); + expect(onImage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/issues/paste-image.util.ts b/src/app/issues/paste-image.util.ts new file mode 100644 index 0000000..6e8adc7 --- /dev/null +++ b/src/app/issues/paste-image.util.ts @@ -0,0 +1,27 @@ +export function handleImagePaste(event: ClipboardEvent, onImage: (markdown: string) => void): void { + const items = event.clipboardData?.items; + if (!items) return; + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + event.preventDefault(); + const reader = new FileReader(); + reader.onload = (e) => onImage(`![image](${e.target!.result as string})`); + reader.readAsDataURL(file); + return; + } + } +} + +export function insertAtSelection( + textarea: HTMLTextAreaElement, + currentValue: string, + start: number, + end: number, + insertion: string, +): string { + const next = currentValue.slice(0, start) + insertion + currentValue.slice(end); + setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + insertion.length; }); + return next; +} From 15049c4fe37f81e7826c1a914d1bf1ac741cb2f7 Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 21:26:32 +0200 Subject: [PATCH 7/8] Ajout milestone --- src/app/app.routes.ts | 5 + src/app/menu/menu.spec.ts | 9 +- src/app/menu/menu.ts | 1 + .../milestone-detail/milestone-detail.css | 203 +++++++++++++++ .../milestone-detail/milestone-detail.html | 233 ++++++++++++++++++ .../milestone-detail/milestone-detail.ts | 200 +++++++++++++++ src/app/milestones/milestones-api.service.ts | 26 ++ src/app/milestones/milestones.css | 32 +++ src/app/milestones/milestones.html | 73 ++++++ src/app/milestones/milestones.store.ts | 76 ++++++ src/app/milestones/milestones.ts | 55 +++++ 11 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 src/app/milestones/milestone-detail/milestone-detail.css create mode 100644 src/app/milestones/milestone-detail/milestone-detail.html create mode 100644 src/app/milestones/milestone-detail/milestone-detail.ts create mode 100644 src/app/milestones/milestones-api.service.ts create mode 100644 src/app/milestones/milestones.css create mode 100644 src/app/milestones/milestones.html create mode 100644 src/app/milestones/milestones.store.ts create mode 100644 src/app/milestones/milestones.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 71ef07f..f9c1def 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,6 +2,8 @@ import { Routes } from '@angular/router'; import { Home } from './home/home'; import { IssueDetail } from './issues/issue-detail/issue-detail'; import { Issues } from './issues/issues'; +import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail'; +import { Milestones } from './milestones/milestones'; import { Projects } from './projects/projects'; import { authGuard } from './auth/auth.guard'; @@ -13,5 +15,8 @@ export const routes: Routes = [ { path: 'issues/new', component: IssueDetail, canActivate: [authGuard] }, { path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] }, { path: 'issues', component: Issues, canActivate: [authGuard] }, + { path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] }, + { path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] }, + { path: 'milestones', component: Milestones, canActivate: [authGuard] }, { path: '**', redirectTo: 'home' }, ]; diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts index f8ec9c6..0d47234 100644 --- a/src/app/menu/menu.spec.ts +++ b/src/app/menu/menu.spec.ts @@ -21,13 +21,18 @@ describe('Menu', () => { expect(component).toBeTruthy(); }); - it('should have three menu items', () => { + it('should have four menu items', () => { const items = (component as any).menuItems as { label: string; path: string }[]; - expect(items.length).toBe(3); + expect(items.length).toBe(4); }); it('should contain Issues link', () => { const items = (component as any).menuItems as { label: string; path: string }[]; expect(items.some((i) => i.path === '/issues')).toBe(true); }); + + it('should contain Milestones link', () => { + const items = (component as any).menuItems as { label: string; path: string }[]; + expect(items.some((i) => i.path === '/milestones')).toBe(true); + }); }); diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts index ea4735c..bf54844 100644 --- a/src/app/menu/menu.ts +++ b/src/app/menu/menu.ts @@ -17,6 +17,7 @@ export class Menu { { label: 'Accueil', path: '/home' }, { label: 'Projet', path: '/project' }, { label: 'Issues', path: '/issues' }, + { label: 'Milestones', path: '/milestones' }, ]; protected logout(): void { diff --git a/src/app/milestones/milestone-detail/milestone-detail.css b/src/app/milestones/milestone-detail/milestone-detail.css new file mode 100644 index 0000000..64f5182 --- /dev/null +++ b/src/app/milestones/milestone-detail/milestone-detail.css @@ -0,0 +1,203 @@ +:host { + display: block; +} + +.section-header { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #6b7280; + background-color: #f9fafb; +} + +.field-label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.3rem; +} + +.title-input::placeholder { + color: #9ca3af; + font-weight: 400; +} + +/* More menu */ +.more-wrapper { + position: relative; +} + +.more-menu { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + min-width: 10rem; + z-index: 10; +} + +.more-backdrop { + position: fixed; + inset: 0; + z-index: 9; +} + +/* Issue name link in table */ +.issue-name-btn { + border: none; + background: transparent; + padding: 0; + cursor: pointer; + color: #374151; + font-size: 0.9rem; + text-align: left; +} + +.issue-name-btn:hover { + color: #2563eb; + text-decoration: underline; +} + +/* Issue search */ +.issue-search-wrapper { + position: relative; + max-width: 380px; +} + +.issue-suggestions { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + list-style: none; + margin: 0; + padding: 0.25rem 0; + z-index: 20; +} + +.issue-suggestion-item { + display: flex; + align-items: center; + width: 100%; + padding: 0.45rem 0.75rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + text-align: left; + color: #374151; + gap: 0.25rem; +} + +.issue-suggestion-item:hover { + background: #f3f4f6; +} + +.dep-remove { + border: none; + background: transparent; + color: #9ca3af; + font-size: 1rem; + line-height: 1; + cursor: pointer; + padding: 0; + border-radius: 50%; + width: 1.1rem; + height: 1.1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.dep-remove:hover { + color: #b91c1c; + background: #fee2e2; +} + +.dep-select { + flex: 1; + min-width: 200px; +} + +/* Description */ +.description-textarea { + min-height: 12rem; + resize: vertical; +} + +.description-preview { + min-height: 5rem; + white-space: pre-wrap; + font-size: 0.9rem; + color: #374151; + cursor: text; + border-radius: 0.375rem; + padding: 0.25rem 0.35rem; + line-height: 1.6; +} + +.description-preview:hover { + background: #f9fafb; +} + +.description-placeholder { + color: #9ca3af; +} + +.markdown-body { + font-size: 0.9rem; + line-height: 1.7; + color: #374151; +} + +.markdown-body :is(h1, h2, h3, h4, h5, h6) { + margin-top: 1rem; + margin-bottom: 0.4rem; + font-weight: 700; +} + +.markdown-body h1 { font-size: 1.4rem; } +.markdown-body h2 { font-size: 1.2rem; } +.markdown-body h3 { font-size: 1rem; } + +.markdown-body p { margin-bottom: 0.6rem; } + +.markdown-body ul, +.markdown-body ol { + padding-left: 1.4rem; + margin-bottom: 0.6rem; +} + +.markdown-body code { + background: #f3f4f6; + border-radius: 0.25rem; + padding: 0.1em 0.35em; + font-size: 0.85em; +} + +.markdown-body pre { + background: #f3f4f6; + border-radius: 0.4rem; + padding: 0.75rem 1rem; + overflow-x: auto; + margin-bottom: 0.6rem; +} + +.markdown-body pre code { + background: none; + padding: 0; +} + +.markdown-body img { + max-width: 100%; + border-radius: 0.375rem; +} + +.markdown-body > *:last-child { + margin-bottom: 0; +} diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html new file mode 100644 index 0000000..e2a6ec7 --- /dev/null +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -0,0 +1,233 @@ + + + +
+ +
+ @if (!isNewRoute) { +
+ + @if (moreMenuOpen) { + + } + @if (moreMenuOpen) { +
+ } +
+ } +
+
+ + +
+
+ + @if (nameInput.invalid && nameInput.touched) { +
Le nom est obligatoire.
+ } +
+
+ + +
+
Informations
+
+
+
+ + +
+ @if (!isNewRoute) { +
+ +
+
+
+
+ {{ progress }}% +
+
+ } +
+
+
+ + +
+
Description
+
+ @if (editingDescription) { + + } @else { +
+ @if (milestone.description) { +
+ } @else { + Ajouter une description... + } +
+ } +
+
+ + +
+
+ Issues liées + {{ milestone.issueIds.length }} +
+ + @if (linkedIssues.length > 0) { +
+ + + + + + + + + + + + + @for (issue of linkedIssues; track issue.id) { + + + + + + + + + } + +
#TypeTitrePrioritéStatut
#{{ issue.id }} + + {{ typeIcon(issue.type).letter }} + + + + + {{ priorityDisplay(issue.priority).symbol }} + + {{ statusBadge(issue.status).label }} + + +
+
+ } + +
+ @if (showAddIssue) { +
+
+ + +
+ @if (showIssueSuggestions && issueSuggestions.length > 0) { +
    + @for (issue of issueSuggestions; track issue.id) { +
  • + +
  • + } +
+ } + @if (showIssueSuggestions && issueSearchQuery && issueSuggestions.length === 0) { +
Aucune issue trouvée.
+ } +
+ } @else { + + } +
+
+ + +@if (isNewRoute) { +
+ + +
+} diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts new file mode 100644 index 0000000..b507f7f --- /dev/null +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -0,0 +1,200 @@ +import { Component, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; +import { marked } from 'marked'; +import { IssueEntity, IssuesStore } from '../../issues/issues.store'; +import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util'; +import { MilestoneEntity, MilestonesStore } from '../milestones.store'; + +@Component({ + selector: 'app-milestone-detail', + imports: [FormsModule], + templateUrl: './milestone-detail.html', + styleUrl: './milestone-detail.css', +}) +export class MilestoneDetail { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly milestonesStore = inject(MilestonesStore); + private readonly issuesStore = inject(IssuesStore); + private readonly sanitizer = inject(DomSanitizer); + + protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new'; + protected milestone: MilestoneEntity = this.buildMilestone(); + protected readonly issues = this.issuesStore.issues; + + protected editingDescription = false; + protected showAddIssue = false; + protected issueSearchQuery = ''; + protected showIssueSuggestions = false; + protected moreMenuOpen = false; + + constructor() { + this.milestonesStore.load().then(() => { + if (!this.isNewRoute) { + const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); + const found = this.milestonesStore.getById(id); + if (found) this.milestone = { ...found }; + } + }); + this.issuesStore.load(); + + this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { + const id = Number(params.get('id')); + if (!id || isNaN(id)) return; + const found = this.milestonesStore.getById(id); + if (found) { + this.milestone = { ...found }; + this.editingDescription = false; + this.showAddIssue = false; + } + }); + } + + protected get linkedIssues(): IssueEntity[] { + return this.issues().filter((i) => this.milestone.issueIds.includes(i.id)); + } + + protected get availableIssues(): IssueEntity[] { + const assignedElsewhere = new Set( + this.milestonesStore.milestones() + .filter((m) => m.id !== this.milestone.id) + .flatMap((m) => m.issueIds), + ); + return this.issues().filter( + (i) => !this.milestone.issueIds.includes(i.id) && !assignedElsewhere.has(i.id), + ); + } + + protected get descriptionHtml(): SafeHtml { + const html = marked.parse(this.milestone.description || '') as string; + return this.sanitizer.bypassSecurityTrustHtml(html); + } + + protected get progress(): number { + if (this.linkedIssues.length === 0) return 0; + return Math.round( + (this.linkedIssues.filter((i) => i.status === 'done').length / this.linkedIssues.length) * 100, + ); + } + + protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } { + const map: Record = { + Epic: { letter: 'E', bg: '#7c3aed' }, + Bug: { letter: 'B', bg: '#dc2626' }, + Story: { letter: 'S', bg: '#16a34a' }, + Task: { letter: 'T', bg: '#2563eb' }, + Study: { letter: 'St', bg: '#6b7280' }, + 'Technical Story':{ letter: 'TS', bg: '#d97706' }, + }; + return map[type] ?? { letter: '?', bg: '#6b7280' }; + } + + protected get issueSuggestions(): IssueEntity[] { + const q = this.issueSearchQuery.trim().toLowerCase(); + if (!q) return this.availableIssues.slice(0, 8); + return this.availableIssues.filter( + (i) => i.name.toLowerCase().includes(q) || String(i.id).includes(q), + ).slice(0, 8); + } + + protected openAddIssue(): void { + this.issueSearchQuery = ''; + this.showIssueSuggestions = false; + this.showAddIssue = true; + } + + protected cancelAddIssue(): void { + this.showAddIssue = false; + this.issueSearchQuery = ''; + this.showIssueSuggestions = false; + } + + protected async addIssueFromSearch(id: number): Promise { + this.milestone.issueIds = [...this.milestone.issueIds, id]; + this.issueSearchQuery = ''; + this.showIssueSuggestions = false; + this.showAddIssue = false; + await this.saveMilestone(); + } + + protected hideIssueSuggestions(): void { + setTimeout(() => { this.showIssueSuggestions = false; }, 150); + } + + protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { + const map: Record = { + draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' }, + todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' }, + 'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' }, + done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' }, + }; + return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' }; + } + + protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } { + const map: Record = { + 'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545' }, + 'HAUTE': { symbol: '↑', color: '#fd7e14' }, + 'MOYENNE': { symbol: '–', color: '#ffc107' }, + 'BASSE': { symbol: '↓', color: '#0d6efd' }, + 'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d' }, + }; + return map[priority] ?? { symbol: '?', color: '#6c757d' }; + } + + protected async removeIssue(id: number): Promise { + this.milestone.issueIds = this.milestone.issueIds.filter((i) => i !== id); + await this.saveMilestone(); + } + + protected onDescriptionPaste(event: ClipboardEvent): void { + const ta = event.target as HTMLTextAreaElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + handleImagePaste(event, (md) => { + this.milestone.description = insertAtSelection(ta, this.milestone.description, start, end, md); + }); + } + + protected async saveMilestone(explicit = false): Promise { + if (this.isNewRoute && !explicit) return; + if (!this.milestone.name.trim()) return; + const saved = await this.milestonesStore.upsert(this.milestone); + this.milestone = { ...saved }; + if (this.isNewRoute) { + this.router.navigate(['/milestones', saved.id]); + } + } + + protected cancelCreation(): void { + this.router.navigate(['/milestones']); + } + + protected async deleteMilestone(): Promise { + await this.milestonesStore.deleteById(this.milestone.id); + this.router.navigate(['/milestones']); + } + + protected toggleMoreMenu(): void { + this.moreMenuOpen = !this.moreMenuOpen; + } + + protected closeMoreMenu(): void { + this.moreMenuOpen = false; + } + + protected navigateToIssue(id: number): void { + this.router.navigate(['/issues', id]); + } + + private buildMilestone(): MilestoneEntity { + if (this.route.snapshot.routeConfig?.path === 'milestones/new') { + return { id: 0, name: '', description: '', dueDate: '', issueIds: [] }; + } + const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); + return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] }; + } +} diff --git a/src/app/milestones/milestones-api.service.ts b/src/app/milestones/milestones-api.service.ts new file mode 100644 index 0000000..dd7ea23 --- /dev/null +++ b/src/app/milestones/milestones-api.service.ts @@ -0,0 +1,26 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { API_BASE_URL } from '../issues/issues-api.service'; +import { MilestoneEntity } from './milestones.store'; + +@Injectable({ providedIn: 'root' }) +export class MilestonesApiService { + private readonly http = inject(HttpClient); + + getAll(): Observable { + return this.http.get(`${API_BASE_URL}/milestones`); + } + + create(milestone: Omit): Observable { + return this.http.post(`${API_BASE_URL}/milestones`, milestone); + } + + update(id: number, milestone: MilestoneEntity): Observable { + return this.http.put(`${API_BASE_URL}/milestones/${id}`, milestone); + } + + remove(id: number): Observable { + return this.http.delete(`${API_BASE_URL}/milestones/${id}`); + } +} diff --git a/src/app/milestones/milestones.css b/src/app/milestones/milestones.css new file mode 100644 index 0000000..641c42a --- /dev/null +++ b/src/app/milestones/milestones.css @@ -0,0 +1,32 @@ +:host { + display: block; +} + +.clickable-row { + cursor: pointer; +} + +.clickable-row:hover { + background-color: #f3f4f6; +} + +.clickable-row:focus-visible { + outline: 2px solid #2563eb; + outline-offset: -2px; +} + +.description-cell { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-cell { + min-width: 130px; +} + +.progress-label { + min-width: 2.5rem; + text-align: right; +} diff --git a/src/app/milestones/milestones.html b/src/app/milestones/milestones.html new file mode 100644 index 0000000..86ea69d --- /dev/null +++ b/src/app/milestones/milestones.html @@ -0,0 +1,73 @@ + +
+
+

Milestones

+

Objectifs et jalons du projet.

+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + @for (milestone of filteredMilestones; track milestone.id) { + + + + + + + + + } + @if (filteredMilestones.length === 0) { + + + + } + +
#NomDescriptionÉchéanceIssuesProgression
#{{ milestone.id }}{{ milestone.name }}{{ milestone.description }}{{ formatDate(milestone.dueDate) }} + {{ milestone.issueIds.length }} + +
+
+
+
+ {{ getProgress(milestone) }}% +
+
Aucun milestone trouvé.
+
+
diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts new file mode 100644 index 0000000..d844c89 --- /dev/null +++ b/src/app/milestones/milestones.store.ts @@ -0,0 +1,76 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { MilestonesApiService } from './milestones-api.service'; + +export type MilestoneEntity = { + id: number; + name: string; + description: string; + dueDate: string; + issueIds: number[]; +}; + +@Injectable({ providedIn: 'root' }) +export class MilestonesStore { + private readonly api = inject(MilestonesApiService); + private readonly data = signal([]); + + readonly loading = signal(false); + readonly loaded = signal(false); + readonly milestones = this.data.asReadonly(); + + getById(id: number): MilestoneEntity | undefined { + return this.data().find((m) => m.id === id); + } + + async load(): Promise { + if (this.loaded()) return; + this.loading.set(true); + try { + const milestones = await firstValueFrom(this.api.getAll()); + this.data.set(milestones.map((m) => this.normalize(m))); + this.loaded.set(true); + } finally { + this.loading.set(false); + } + } + + async upsert(milestone: MilestoneEntity): Promise { + const normalized = this.normalize(milestone); + if (!normalized.id) { + const { id: _id, ...body } = normalized; + const created = this.normalize(await firstValueFrom(this.api.create(body))); + this.data.update((list) => [...list, created]); + return created; + } else { + const updated = this.normalize( + await firstValueFrom(this.api.update(normalized.id, normalized)), + ); + this.data.update((list) => { + const idx = list.findIndex((m) => m.id === normalized.id); + if (idx === -1) return list; + const copy = [...list]; + copy[idx] = updated; + return copy; + }); + return updated; + } + } + + async deleteById(id: number): Promise { + await firstValueFrom(this.api.remove(id)); + this.data.update((list) => list.filter((m) => m.id !== id)); + } + + private normalize(milestone: Partial): MilestoneEntity { + return { + id: milestone.id ?? 0, + name: milestone.name ?? '', + description: milestone.description ?? '', + dueDate: milestone.dueDate ?? '', + issueIds: Array.isArray(milestone.issueIds) + ? milestone.issueIds.filter((v): v is number => typeof v === 'number') + : [], + }; + } +} diff --git a/src/app/milestones/milestones.ts b/src/app/milestones/milestones.ts new file mode 100644 index 0000000..8ad6bb5 --- /dev/null +++ b/src/app/milestones/milestones.ts @@ -0,0 +1,55 @@ +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { IssuesStore } from '../issues/issues.store'; +import { MilestoneEntity, MilestonesStore } from './milestones.store'; + +@Component({ + selector: 'app-milestones', + imports: [FormsModule], + templateUrl: './milestones.html', + styleUrl: './milestones.css', +}) +export class Milestones { + private readonly router = inject(Router); + private readonly milestonesStore = inject(MilestonesStore); + private readonly issuesStore = inject(IssuesStore); + + constructor() { + this.milestonesStore.load(); + this.issuesStore.load(); + } + + protected readonly milestones = this.milestonesStore.milestones; + protected searchQuery = ''; + + protected get filteredMilestones(): MilestoneEntity[] { + const q = this.searchQuery.trim().toLowerCase(); + if (!q) return this.milestones(); + return this.milestones().filter((m) => m.name.toLowerCase().includes(q)); + } + + protected getProgress(milestone: MilestoneEntity): number { + if (milestone.issueIds.length === 0) return 0; + const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id)); + if (linked.length === 0) return 0; + return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100); + } + + protected formatDate(iso: string): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } + + protected createMilestone(): void { + this.router.navigate(['/milestones/new']); + } + + protected openMilestone(id: number): void { + this.router.navigate(['/milestones', id]); + } +} From 89f01fd16bfba57beeadf2f888aa7038285cf1c4 Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 21:32:42 +0200 Subject: [PATCH 8/8] Change version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9dbc82..870d4d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bonsai-webapp", - "version": "0.1.5", + "version": "0.1.11", "scripts": { "ng": "ng", "start": "ng serve",