Merge pull request 'Gestion des status' (#30) from feat/11-configuration-status into develop
Reviewed-on: Bonsai/Bonsai-webapp#30
This commit is contained in:
@@ -6,6 +6,7 @@ import { Issues } from './issues/issues';
|
|||||||
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
|
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
|
||||||
import { Milestones } from './milestones/milestones';
|
import { Milestones } from './milestones/milestones';
|
||||||
import { Projects } from './projects/projects';
|
import { Projects } from './projects/projects';
|
||||||
|
import { Settings } from './settings/settings';
|
||||||
import { authGuard } from './auth/auth.guard';
|
import { authGuard } from './auth/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
@@ -20,5 +21,6 @@ export const routes: Routes = [
|
|||||||
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
|
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
|
||||||
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
|
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
|
||||||
{ path: 'milestones', component: Milestones, canActivate: [authGuard] },
|
{ path: 'milestones', component: Milestones, canActivate: [authGuard] },
|
||||||
|
{ path: 'settings', component: Settings, canActivate: [authGuard] },
|
||||||
{ path: '**', redirectTo: 'home' },
|
{ path: '**', redirectTo: 'home' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ describe('IssueComments', () => {
|
|||||||
describe('statusLabel', () => {
|
describe('statusLabel', () => {
|
||||||
it('returns label and colors for done status', () => {
|
it('returns label and colors for done status', () => {
|
||||||
const s = (component as any).statusLabel('done');
|
const s = (component as any).statusLabel('done');
|
||||||
expect(s.label).toBe('Terminé');
|
expect(s.label).toBe('TERMINÉ');
|
||||||
expect(s.color).toBe('#166534');
|
expect(s.color).toBe('#166534');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||||
import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
|
import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
|
||||||
|
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issue-comments',
|
selector: 'app-issue-comments',
|
||||||
@@ -15,6 +16,7 @@ import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
|
|||||||
export class IssueComments {
|
export class IssueComments {
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly statusesStore = inject(StatusesStore);
|
||||||
|
|
||||||
readonly issueId = input.required<number>();
|
readonly issueId = input.required<number>();
|
||||||
|
|
||||||
@@ -140,14 +142,8 @@ export class IssueComments {
|
|||||||
return map[type] ?? { letter: '?', bg: '#6b7280' };
|
return map[type] ?? { letter: '?', bg: '#6b7280' };
|
||||||
}
|
}
|
||||||
|
|
||||||
protected statusLabel(status: IssueEntity['status']): { label: string; bg: string; color: string } {
|
protected statusLabel(status: IssueEntity['status']): StatusEntity {
|
||||||
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
|
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||||
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 startCreateTask(commentId: number): void {
|
protected startCreateTask(commentId: number): void {
|
||||||
|
|||||||
@@ -29,19 +29,19 @@
|
|||||||
@if (statusMenuOpen) {
|
@if (statusMenuOpen) {
|
||||||
<div class="status-backdrop" (click)="closeStatusMenu()"></div>
|
<div class="status-backdrop" (click)="closeStatusMenu()"></div>
|
||||||
<ul class="status-dropdown dropdown-menu show">
|
<ul class="status-dropdown dropdown-menu show">
|
||||||
@for (status of statusOptions; track status) {
|
@for (status of statusOptions; track status.id) {
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="dropdown-item d-flex align-items-center gap-2"
|
class="dropdown-item d-flex align-items-center gap-2"
|
||||||
[class.active]="issue.status === status"
|
[class.active]="issue.status === status.id"
|
||||||
(click)="selectStatus(status)"
|
(click)="selectStatus(status.id)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="status-badge"
|
class="status-badge"
|
||||||
[style.background]="statusBadge(status).bg"
|
[style.background]="status.bg"
|
||||||
[style.color]="statusBadge(status).color"
|
[style.color]="status.color"
|
||||||
>{{ statusBadge(status).label }}</span>
|
>{{ status.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IssueEntity, IssuesStore } from '../issues.store';
|
|||||||
import { IssueComments } from '../issue-comments/issue-comments';
|
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 '../../settings/statuses/statuses.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issue-detail',
|
selector: 'app-issue-detail',
|
||||||
@@ -21,6 +22,7 @@ export class IssueDetail {
|
|||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
private readonly milestonesStore = inject(MilestonesStore);
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
protected readonly statusesStore = inject(StatusesStore);
|
||||||
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||||
|
|
||||||
protected issue: IssueEntity = this.buildIssue();
|
protected issue: IssueEntity = this.buildIssue();
|
||||||
@@ -64,12 +66,9 @@ export class IssueDetail {
|
|||||||
protected showCreateInEpic = false;
|
protected showCreateInEpic = false;
|
||||||
protected newIssueName = '';
|
protected newIssueName = '';
|
||||||
|
|
||||||
protected readonly statusOptions: IssueEntity['status'][] = [
|
protected get statusOptions(): StatusEntity[] {
|
||||||
'draft',
|
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
|
||||||
'todo',
|
}
|
||||||
'in-progress',
|
|
||||||
'done',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected readonly typeOptions: IssueEntity['type'][] = [
|
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||||
'Epic',
|
'Epic',
|
||||||
@@ -279,14 +278,8 @@ export class IssueDetail {
|
|||||||
return map[type] ?? { letter: '?', bg: '#6b7280' };
|
return map[type] ?? { letter: '?', bg: '#6b7280' };
|
||||||
}
|
}
|
||||||
|
|
||||||
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } {
|
protected statusBadge(status: IssueEntity['status']): StatusEntity {
|
||||||
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
|
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||||
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 } {
|
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } {
|
||||||
|
|||||||
@@ -69,12 +69,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
@for (status of statusOptions; track status) {
|
@for (status of statusOptions; track status.id) {
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status, $event)">
|
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status.id, $event)">
|
||||||
<span class="filter-check">@if (selectedStatuses.has(status)) { ✓ }</span>
|
<span class="filter-check">@if (selectedStatuses.has(status.id)) { ✓ }</span>
|
||||||
<span class="status-badge" [style.background]="statusBadge(status).bg" [style.color]="statusBadge(status).color">
|
<span class="status-badge" [style.background]="status.bg" [style.color]="status.color">
|
||||||
{{ statusBadge(status).label }}
|
{{ status.label }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { IssuesApiService } from './issues-api.service';
|
import { IssuesApiService } from './issues-api.service';
|
||||||
|
|
||||||
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
|
export type IssueStatus = string;
|
||||||
export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE';
|
export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE';
|
||||||
export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story';
|
export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story';
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
||||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||||
|
import { StatusEntity, StatusesStore } from '../settings/statuses/statuses.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issues',
|
selector: 'app-issues',
|
||||||
@@ -14,6 +15,7 @@ export class Issues {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
private readonly milestonesStore = inject(MilestonesStore);
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
|
protected readonly statusesStore = inject(StatusesStore);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.issuesStore.load();
|
this.issuesStore.load();
|
||||||
@@ -33,9 +35,9 @@ export class Issues {
|
|||||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected readonly statusOptions: IssueStatus[] = [
|
protected get statusOptions(): StatusEntity[] {
|
||||||
'draft', 'todo', 'in-progress', 'done',
|
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
|
||||||
];
|
}
|
||||||
|
|
||||||
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
||||||
return this.milestones().find((m) => m.issueIds.includes(issueId));
|
return this.milestones().find((m) => m.issueIds.includes(issueId));
|
||||||
@@ -192,13 +194,7 @@ export class Issues {
|
|||||||
return map[type] ?? 'text-bg-secondary';
|
return map[type] ?? 'text-bg-secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } {
|
protected statusBadge(status: IssueStatus): StatusEntity {
|
||||||
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
|
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe('Menu', () => {
|
|||||||
|
|
||||||
it('should have five menu items', () => {
|
it('should have five menu items', () => {
|
||||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
const items = (component as any).menuItems as { label: string; path: string }[];
|
||||||
expect(items.length).toBe(5);
|
expect(items.length).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain Issues link', () => {
|
it('should contain Issues link', () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class Menu {
|
|||||||
{ label: 'Projet', path: '/project' },
|
{ label: 'Projet', path: '/project' },
|
||||||
{ label: 'Issues', path: '/issues' },
|
{ label: 'Issues', path: '/issues' },
|
||||||
{ label: 'Milestones', path: '/milestones' },
|
{ label: 'Milestones', path: '/milestones' },
|
||||||
|
{ label: 'Paramètres', path: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
protected logout(): void {
|
protected logout(): void {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { marked } from 'marked';
|
|||||||
import { IssueEntity, IssuesStore } from '../../issues/issues.store';
|
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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-milestone-detail',
|
selector: 'app-milestone-detail',
|
||||||
@@ -20,6 +21,7 @@ export class MilestoneDetail {
|
|||||||
private readonly milestonesStore = inject(MilestonesStore);
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly statusesStore = inject(StatusesStore);
|
||||||
|
|
||||||
protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
|
protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
|
||||||
protected milestone: MilestoneEntity = this.buildMilestone();
|
protected milestone: MilestoneEntity = this.buildMilestone();
|
||||||
@@ -179,14 +181,8 @@ export class MilestoneDetail {
|
|||||||
setTimeout(() => { this.showIssueSuggestions = false; }, 150);
|
setTimeout(() => { this.showIssueSuggestions = false; }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } {
|
protected statusBadge(status: IssueEntity['status']): StatusEntity {
|
||||||
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
|
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||||
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 } {
|
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.settings-page {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="settings-page">
|
||||||
|
<h1 class="settings-page-title">Paramètres</h1>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-4">
|
||||||
|
@for (tab of tabs; track tab.id) {
|
||||||
|
<li class="nav-item">
|
||||||
|
<button
|
||||||
|
class="nav-link"
|
||||||
|
[class.active]="activeTab() === tab.id"
|
||||||
|
(click)="selectTab(tab.id)"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (activeTab() === 'statuses') {
|
||||||
|
<app-statuses />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||||
|
import { Settings } from './settings';
|
||||||
|
import { StatusesStore } from './statuses/statuses.store';
|
||||||
|
|
||||||
|
describe('Settings', () => {
|
||||||
|
let fixture: ComponentFixture<Settings>;
|
||||||
|
let component: Settings;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Settings],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: StatusesStore,
|
||||||
|
useValue: {
|
||||||
|
statuses: vi.fn().mockReturnValue([]),
|
||||||
|
getById: vi.fn().mockReturnValue(undefined),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(Settings);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the settings page title', () => {
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Paramètres');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Statuts tab', () => {
|
||||||
|
const tabs = fixture.nativeElement.querySelectorAll('.nav-link');
|
||||||
|
const labels = [...tabs].map((t: Element) => t.textContent?.trim());
|
||||||
|
expect(labels).toContain('Statuts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the statuses component by default', () => {
|
||||||
|
expect(fixture.nativeElement.querySelector('app-statuses')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates the clicked tab', () => {
|
||||||
|
component['selectTab']('statuses');
|
||||||
|
expect(component['activeTab']()).toBe('statuses');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { Statuses } from './statuses/statuses';
|
||||||
|
|
||||||
|
type SettingsTab = 'statuses';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
imports: [Statuses],
|
||||||
|
templateUrl: './settings.html',
|
||||||
|
styleUrl: './settings.css',
|
||||||
|
})
|
||||||
|
export class Settings {
|
||||||
|
protected readonly activeTab = signal<SettingsTab>('statuses');
|
||||||
|
|
||||||
|
protected readonly tabs: { id: SettingsTab; label: string }[] = [
|
||||||
|
{ id: 'statuses', label: 'Statuts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
protected selectTab(tab: SettingsTab): void {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { StatusesApiService } from './statuses-api.service';
|
||||||
|
import { StatusEntity } from './statuses.store';
|
||||||
|
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
const makeStatus = (overrides: Partial<StatusEntity> = {}): StatusEntity => ({
|
||||||
|
id: 'draft',
|
||||||
|
label: 'BROUILLON',
|
||||||
|
bg: '#e2e8f0',
|
||||||
|
color: '#475569',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatusesApiService', () => {
|
||||||
|
let service: StatusesApiService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(StatusesApiService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAll', () => {
|
||||||
|
it('sends GET /api/statuses', () => {
|
||||||
|
service.getAll().subscribe();
|
||||||
|
const req = httpMock.expectOne(`${API}/statuses`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush([makeStatus()]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('sends POST /api/statuses with the status payload', () => {
|
||||||
|
const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
|
||||||
|
service.create(payload).subscribe();
|
||||||
|
const req = httpMock.expectOne(`${API}/statuses`);
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual(payload);
|
||||||
|
req.flush(makeStatus({ ...payload, order: 4 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('sends PUT /api/statuses/:id with the changes', () => {
|
||||||
|
const changes = { label: 'MODIFIÉ', bg: '#abc', color: '#def' };
|
||||||
|
service.update('draft', changes).subscribe();
|
||||||
|
const req = httpMock.expectOne(`${API}/statuses/draft`);
|
||||||
|
expect(req.request.method).toBe('PUT');
|
||||||
|
expect(req.request.body).toEqual(changes);
|
||||||
|
req.flush(makeStatus({ label: 'MODIFIÉ' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 { StatusEntity } from './statuses.store';
|
||||||
|
|
||||||
|
// Ce service appellera l'API quand les endpoints /api/statuses seront disponibles.
|
||||||
|
// Voir api-issues/gestion-statuts.md
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class StatusesApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
getAll(): Observable<StatusEntity[]> {
|
||||||
|
return this.http.get<StatusEntity[]>(`${API_BASE_URL}/statuses`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(status: Omit<StatusEntity, 'order'>): Observable<StatusEntity> {
|
||||||
|
return this.http.post<StatusEntity>(`${API_BASE_URL}/statuses`, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): Observable<StatusEntity> {
|
||||||
|
return this.http.put<StatusEntity>(`${API_BASE_URL}/statuses/${id}`, changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.statuses-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statuses-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statuses-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em 0.7em;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-colors {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-color {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-hex {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<div class="statuses-header">
|
||||||
|
<h2 class="statuses-title">Statuts</h2>
|
||||||
|
@if (formMode() === null) {
|
||||||
|
<button class="btn btn-primary btn-sm" (click)="openCreate()">+ Nouveau statut</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (formMode() !== null) {
|
||||||
|
<div class="status-form card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title h6 mb-3">
|
||||||
|
{{ formMode() === 'create' ? 'Créer un statut' : 'Modifier le statut' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
@if (formMode() === 'create') {
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label form-label-sm" for="status-id">Identifiant</label>
|
||||||
|
<input
|
||||||
|
id="status-id"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="ex: en-attente"
|
||||||
|
[(ngModel)]="form.id"
|
||||||
|
/>
|
||||||
|
<div class="form-text">Généré automatiquement à partir du libellé si vide.</div>
|
||||||
|
@if (idError()) {
|
||||||
|
<div class="text-danger small mt-1">{{ idError() }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label form-label-sm" for="status-label">Libellé</label>
|
||||||
|
<input
|
||||||
|
id="status-label"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="ex: EN ATTENTE"
|
||||||
|
[(ngModel)]="form.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-form-colors mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label form-label-sm" for="status-bg">Couleur de fond</label>
|
||||||
|
<div class="color-input-row">
|
||||||
|
<input id="status-bg" class="form-control form-control-color" type="color" [(ngModel)]="form.bg" />
|
||||||
|
<span class="color-hex">{{ form.bg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label form-label-sm" for="status-color">Couleur du texte</label>
|
||||||
|
<div class="color-input-row">
|
||||||
|
<input id="status-color" class="form-control form-control-color" type="color" [(ngModel)]="form.color" />
|
||||||
|
<span class="color-hex">{{ form.color }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-form-preview">
|
||||||
|
<label class="form-label form-label-sm">Aperçu</label>
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
[style.background]="form.bg"
|
||||||
|
[style.color]="form.color"
|
||||||
|
>{{ form.label || 'LIBELLÉ' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-form-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" (click)="cancel()">Annuler</button>
|
||||||
|
<button class="btn btn-primary btn-sm" [disabled]="!isFormValid()" (click)="save()">
|
||||||
|
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="statuses-list">
|
||||||
|
@for (status of statuses; track status.id) {
|
||||||
|
<div class="status-row">
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
[style.background]="status.bg"
|
||||||
|
[style.color]="status.color"
|
||||||
|
>{{ status.label }}</span>
|
||||||
|
<span class="status-id text-muted">{{ status.id }}</span>
|
||||||
|
<div class="status-row-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
(click)="openEdit(status)"
|
||||||
|
[disabled]="formMode() !== null"
|
||||||
|
>Modifier</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="text-muted">Aucun statut configuré.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||||
|
import { Statuses } from './statuses';
|
||||||
|
import { StatusesStore } from './statuses.store';
|
||||||
|
|
||||||
|
const makeStoreMock = () => ({
|
||||||
|
statuses: vi.fn().mockReturnValue([
|
||||||
|
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
|
||||||
|
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
|
||||||
|
]),
|
||||||
|
getById: vi.fn((id: string) =>
|
||||||
|
[
|
||||||
|
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
|
||||||
|
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
|
||||||
|
].find((s) => s.id === id),
|
||||||
|
),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Statuses', () => {
|
||||||
|
let fixture: ComponentFixture<Statuses>;
|
||||||
|
let component: Statuses;
|
||||||
|
let storeMock: ReturnType<typeof makeStoreMock>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storeMock = makeStoreMock();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Statuses, FormsModule],
|
||||||
|
providers: [{ provide: StatusesStore, useValue: storeMock }],
|
||||||
|
}).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(Statuses);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the statuses list', () => {
|
||||||
|
const badges = fixture.nativeElement.querySelectorAll('.status-badge');
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the create form when clicking "Nouveau statut"', () => {
|
||||||
|
const btn = fixture.nativeElement.querySelector('.btn-primary');
|
||||||
|
btn.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.status-form')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the edit form when clicking "Modifier"', () => {
|
||||||
|
const editBtn = fixture.nativeElement.querySelector('.btn-outline-secondary');
|
||||||
|
editBtn.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.status-form')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls store.create when saving a new status', async () => {
|
||||||
|
component['openCreate']();
|
||||||
|
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
|
||||||
|
component['save']();
|
||||||
|
expect(storeMock.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ label: 'EN REVUE' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls store.update when saving an edited status', () => {
|
||||||
|
component['openEdit']({ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 });
|
||||||
|
component['form'] = { id: 'draft', label: 'MODIFIÉ', bg: '#e2e8f0', color: '#475569' };
|
||||||
|
component['save']();
|
||||||
|
expect(storeMock.update).toHaveBeenCalledWith('draft', expect.objectContaining({ label: 'MODIFIÉ' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels and hides the form', () => {
|
||||||
|
component['openCreate']();
|
||||||
|
component['cancel']();
|
||||||
|
expect(component['formMode']()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when creating a duplicate id', () => {
|
||||||
|
component['openCreate']();
|
||||||
|
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000' };
|
||||||
|
component['save']();
|
||||||
|
expect(component['idError']()).not.toBeNull();
|
||||||
|
expect(storeMock.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFormValid returns false when label is empty', () => {
|
||||||
|
component['form'] = { id: '', label: '', bg: '#fff', color: '#000' };
|
||||||
|
expect(component['isFormValid']()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFormValid returns true when label is set', () => {
|
||||||
|
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000' };
|
||||||
|
expect(component['isFormValid']()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'bonsai_statuses';
|
||||||
|
|
||||||
|
describe('StatusesStore', () => {
|
||||||
|
let store: StatusesStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
store = TestBed.inject(StatusesStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('loads default statuses when localStorage is empty', () => {
|
||||||
|
expect(store.statuses().length).toBe(DEFAULT_STATUSES.length);
|
||||||
|
expect(store.statuses()[0].id).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads statuses from localStorage when available', () => {
|
||||||
|
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0 }];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
const freshStore = TestBed.inject(StatusesStore);
|
||||||
|
expect(freshStore.statuses().length).toBe(1);
|
||||||
|
expect(freshStore.statuses()[0].id).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults when localStorage contains invalid JSON', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'not-json');
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
const freshStore = TestBed.inject(StatusesStore);
|
||||||
|
expect(freshStore.statuses().length).toBe(DEFAULT_STATUSES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults when localStorage contains empty array', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '[]');
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
const freshStore = TestBed.inject(StatusesStore);
|
||||||
|
expect(freshStore.statuses().length).toBe(DEFAULT_STATUSES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns the matching status', () => {
|
||||||
|
const result = store.getById('draft');
|
||||||
|
expect(result?.id).toBe('draft');
|
||||||
|
expect(result?.label).toBe('BROUILLON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unknown id', () => {
|
||||||
|
expect(store.getById('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('adds a new status to the list', () => {
|
||||||
|
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e' });
|
||||||
|
const found = store.getById('review');
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found?.label).toBe('EN REVUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns an order greater than existing max', () => {
|
||||||
|
const maxBefore = Math.max(...store.statuses().map((s) => s.order));
|
||||||
|
store.create({ id: 'new-one', label: 'NOUVEAU', bg: '#fff', color: '#000' });
|
||||||
|
const created = store.getById('new-one');
|
||||||
|
expect(created?.order).toBeGreaterThan(maxBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists to localStorage', () => {
|
||||||
|
store.create({ id: 'saved', label: 'SAUVEGARDÉ', bg: '#fff', color: '#000' });
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
const parsed = JSON.parse(raw!);
|
||||||
|
expect(parsed.some((s: { id: string }) => s.id === 'saved')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates the label of an existing status', () => {
|
||||||
|
store.update('draft', { label: 'NOUVEAU LABEL' });
|
||||||
|
expect(store.getById('draft')?.label).toBe('NOUVEAU LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the colors of an existing status', () => {
|
||||||
|
store.update('todo', { bg: '#fff000', color: '#123456' });
|
||||||
|
const updated = store.getById('todo');
|
||||||
|
expect(updated?.bg).toBe('#fff000');
|
||||||
|
expect(updated?.color).toBe('#123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect other statuses', () => {
|
||||||
|
store.update('draft', { label: 'MODIFIÉ' });
|
||||||
|
expect(store.getById('todo')?.label).toBe('À FAIRE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists changes to localStorage', () => {
|
||||||
|
store.update('draft', { label: 'UPDATED' });
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const parsed = JSON.parse(raw!);
|
||||||
|
const draft = parsed.find((s: { id: string }) => s.id === 'draft');
|
||||||
|
expect(draft?.label).toBe('UPDATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type StatusEntity = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_STATUSES: StatusEntity[] = [
|
||||||
|
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
|
||||||
|
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
|
||||||
|
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2 },
|
||||||
|
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'bonsai_statuses';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class StatusesStore {
|
||||||
|
private readonly data = signal<StatusEntity[]>([]);
|
||||||
|
|
||||||
|
readonly statuses = this.data.asReadonly();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFromStorage(): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as StatusEntity[];
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
this.data.set(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
this.data.set([...DEFAULT_STATUSES]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveToStorage(): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data()));
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): StatusEntity | undefined {
|
||||||
|
return this.data().find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(status: Omit<StatusEntity, 'order'>): void {
|
||||||
|
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
|
||||||
|
this.data.update((statuses) => [...statuses, { ...status, order: maxOrder + 1 }]);
|
||||||
|
this.saveToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
|
||||||
|
this.data.update((statuses) =>
|
||||||
|
statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)),
|
||||||
|
);
|
||||||
|
this.saveToStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { StatusEntity, StatusesStore } from './statuses.store';
|
||||||
|
|
||||||
|
type FormMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
type StatusForm = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-statuses',
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './statuses.html',
|
||||||
|
styleUrl: './statuses.css',
|
||||||
|
})
|
||||||
|
export class Statuses {
|
||||||
|
protected readonly statusesStore = inject(StatusesStore);
|
||||||
|
|
||||||
|
protected formMode = signal<FormMode | null>(null);
|
||||||
|
protected editingId = signal<string | null>(null);
|
||||||
|
protected form: StatusForm = this.emptyForm();
|
||||||
|
protected idError = signal<string | null>(null);
|
||||||
|
|
||||||
|
protected get statuses(): StatusEntity[] {
|
||||||
|
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openCreate(): void {
|
||||||
|
this.form = this.emptyForm();
|
||||||
|
this.idError.set(null);
|
||||||
|
this.editingId.set(null);
|
||||||
|
this.formMode.set('create');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openEdit(status: StatusEntity): void {
|
||||||
|
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
|
||||||
|
this.idError.set(null);
|
||||||
|
this.editingId.set(status.id);
|
||||||
|
this.formMode.set('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancel(): void {
|
||||||
|
this.formMode.set(null);
|
||||||
|
this.editingId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected save(): void {
|
||||||
|
if (this.formMode() === 'create') {
|
||||||
|
const id = this.slugify(this.form.id || this.form.label);
|
||||||
|
if (!id) return;
|
||||||
|
if (this.statusesStore.getById(id)) {
|
||||||
|
this.idError.set('Un statut avec cet identifiant existe déjà.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
|
||||||
|
} else {
|
||||||
|
const id = this.editingId();
|
||||||
|
if (!id) return;
|
||||||
|
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
|
||||||
|
}
|
||||||
|
this.formMode.set(null);
|
||||||
|
this.editingId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isFormValid(): boolean {
|
||||||
|
return this.form.label.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyForm(): StatusForm {
|
||||||
|
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user