@@ -4,14 +4,16 @@
|
||||
Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend.
|
||||
|
||||
## Action requise
|
||||
Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé en kebab-case selon le besoin :
|
||||
Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé d'après le **numéro de ticket** extrait du nom de la branche courante, suivi du slug de la branche :
|
||||
|
||||
```
|
||||
api-issues/nom-du-besoin.md
|
||||
api-issues/<numéro>-<slug>.md
|
||||
```
|
||||
|
||||
> **Exemple** : branche `feat/30-ordre-statut` → fichier `api-issues/30-ordre-statut.md`
|
||||
|
||||
## Contenu du fichier
|
||||
Le fichier doit décrire :
|
||||
Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire :
|
||||
1. **Contexte** — quelle fonctionnalité frontend nécessite cette évolution
|
||||
2. **Problème** — ce qui manque ou bloque dans l'API actuelle
|
||||
3. **Besoin** — le ou les endpoints à créer / modifier, avec le corps de requête et la réponse attendus
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)"
|
||||
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)",
|
||||
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Issues } from './issues/issues';
|
||||
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
|
||||
import { Milestones } from './milestones/milestones';
|
||||
import { Projects } from './projects/projects';
|
||||
import { Settings } from './settings/settings';
|
||||
import { Statuses } from './statuses/statuses';
|
||||
import { authGuard } from './auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
@@ -21,6 +21,6 @@ export const routes: Routes = [
|
||||
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
|
||||
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
|
||||
{ path: 'milestones', component: Milestones, canActivate: [authGuard] },
|
||||
{ path: 'settings', component: Settings, canActivate: [authGuard] },
|
||||
{ path: 'statuses', component: Statuses, canActivate: [authGuard] },
|
||||
{ path: '**', redirectTo: 'home' },
|
||||
];
|
||||
|
||||
@@ -100,11 +100,11 @@ export class Dashboard {
|
||||
|
||||
protected formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
const d = new Date(iso);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
@if (statusMenuOpen) {
|
||||
<div class="status-backdrop" (click)="closeStatusMenu()"></div>
|
||||
<ul class="status-dropdown dropdown-menu show">
|
||||
@for (status of statusOptions; track status.id) {
|
||||
@for (status of statusOptions(); track status.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IssueEntity, IssuesStore } from '../issues.store';
|
||||
import { IssueComments } from '../issue-comments/issue-comments';
|
||||
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issue-detail',
|
||||
@@ -21,8 +21,8 @@ export class IssueDetail {
|
||||
private readonly router = inject(Router);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
protected readonly statusesStore = inject(StatusesStore);
|
||||
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||
|
||||
protected issue: IssueEntity = this.buildIssue();
|
||||
@@ -66,9 +66,7 @@ export class IssueDetail {
|
||||
protected showCreateInEpic = false;
|
||||
protected newIssueName = '';
|
||||
|
||||
protected get statusOptions(): StatusEntity[] {
|
||||
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
protected readonly statusOptions = this.statusesStore.statuses;
|
||||
|
||||
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||
'Epic',
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@for (status of statusOptions; track status.id) {
|
||||
@for (status of statusOptions(); track status.id) {
|
||||
<li>
|
||||
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status.id, $event)">
|
||||
<span class="filter-check">@if (selectedStatuses.has(status.id)) { ✓ }</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../settings/statuses/statuses.store';
|
||||
import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issues',
|
||||
@@ -15,7 +15,7 @@ export class Issues {
|
||||
private readonly router = inject(Router);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
protected readonly statusesStore = inject(StatusesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
|
||||
constructor() {
|
||||
this.issuesStore.load();
|
||||
@@ -35,9 +35,7 @@ export class Issues {
|
||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||
];
|
||||
|
||||
protected get statusOptions(): StatusEntity[] {
|
||||
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
protected readonly statusOptions = this.statusesStore.statuses;
|
||||
|
||||
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
||||
return this.milestones().find((m) => m.issueIds.includes(issueId));
|
||||
|
||||
@@ -19,7 +19,7 @@ export class Menu {
|
||||
{ label: 'Projet', path: '/project' },
|
||||
{ label: 'Issues', path: '/issues' },
|
||||
{ label: 'Milestones', path: '/milestones' },
|
||||
{ label: 'Paramètres', path: '/settings' },
|
||||
{ label: 'Statuts', path: '/statuses' },
|
||||
];
|
||||
|
||||
protected logout(): void {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.statuses-page {
|
||||
max-width: 560px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.statuses-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.statuses-title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.statuses-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.statuses-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.status-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.status-item--dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.status-item--drag-over {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.status-item__handle {
|
||||
font-size: 1.125rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-item__position {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-id {
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-item__actions {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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,108 @@
|
||||
<div class="statuses-page">
|
||||
<div class="statuses-header">
|
||||
<div>
|
||||
<h1 class="statuses-title">Statuts</h1>
|
||||
<p class="statuses-subtitle">Glissez-déposez pour réordonner. Cliquez sur Modifier pour changer les couleurs ou le libellé.</p>
|
||||
</div>
|
||||
@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; let i = $index) {
|
||||
<div
|
||||
class="status-item"
|
||||
[class.status-item--drag-over]="dragOverIndex === i && dragIndex !== i"
|
||||
[class.status-item--dragging]="dragIndex === i"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart(i)"
|
||||
(dragover)="onDragOver($event, i)"
|
||||
(drop)="onDrop($event, i)"
|
||||
(dragend)="onDragEnd()"
|
||||
>
|
||||
<span class="status-item__handle" aria-hidden="true">⠿</span>
|
||||
<span class="status-item__position">{{ i + 1 }}</span>
|
||||
<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-item__actions">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
[disabled]="formMode() !== null"
|
||||
(click)="openEdit(status)"
|
||||
>Modifier</button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="text-muted">Aucun statut configuré.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture } from '@angular/core/testing';
|
||||
import { Statuses } from './statuses';
|
||||
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
|
||||
|
||||
describe('Statuses', () => {
|
||||
let fixture: ComponentFixture<Statuses>;
|
||||
let component: Statuses;
|
||||
let store: StatusesStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Statuses],
|
||||
}).compileComponents();
|
||||
|
||||
store = TestBed.inject(StatusesStore);
|
||||
fixture = TestBed.createComponent(Statuses);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('sets dragIndex on dragstart', () => {
|
||||
(component as any).onDragStart(2);
|
||||
expect((component as any).dragIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('sets dragOverIndex on dragover', () => {
|
||||
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
|
||||
(component as any).onDragOver(event, 1);
|
||||
expect((component as any).dragOverIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('reorders statuses on drop', () => {
|
||||
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
|
||||
(component as any).onDragStart(0);
|
||||
(component as any).onDrop(event, 2);
|
||||
expect(store.statuses()[0].id).toBe('todo');
|
||||
expect(store.statuses()[2].id).toBe('draft');
|
||||
});
|
||||
|
||||
it('does nothing when dropped on same index', () => {
|
||||
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
|
||||
const originalIds = store.statuses().map((s) => s.id);
|
||||
(component as any).onDragStart(1);
|
||||
(component as any).onDrop(event, 1);
|
||||
expect(store.statuses().map((s) => s.id)).toEqual(originalIds);
|
||||
});
|
||||
|
||||
it('resets drag state after drop', () => {
|
||||
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
|
||||
(component as any).onDragStart(0);
|
||||
(component as any).onDrop(event, 1);
|
||||
expect((component as any).dragIndex).toBeNull();
|
||||
expect((component as any).dragOverIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('resets drag state on dragend', () => {
|
||||
(component as any).onDragStart(1);
|
||||
(component as any).onDragEnd();
|
||||
expect((component as any).dragIndex).toBeNull();
|
||||
expect((component as any).dragOverIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create form', () => {
|
||||
it('opens the create form', () => {
|
||||
(component as any).openCreate();
|
||||
expect((component as any).formMode()).toBe('create');
|
||||
});
|
||||
|
||||
it('cancels the form', () => {
|
||||
(component as any).openCreate();
|
||||
(component as any).cancel();
|
||||
expect((component as any).formMode()).toBeNull();
|
||||
});
|
||||
|
||||
it('saves a new status', () => {
|
||||
(component as any).openCreate();
|
||||
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000' };
|
||||
(component as any).save();
|
||||
expect(store.getById('test-id')?.label).toBe('TEST');
|
||||
});
|
||||
|
||||
it('reports duplicate id error', () => {
|
||||
(component as any).openCreate();
|
||||
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000' };
|
||||
(component as any).save();
|
||||
expect((component as any).idError()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit form', () => {
|
||||
it('opens the edit form with existing status values', () => {
|
||||
const status = DEFAULT_STATUSES[0];
|
||||
(component as any).openEdit(status);
|
||||
expect((component as any).formMode()).toBe('edit');
|
||||
expect((component as any).form.label).toBe(status.label);
|
||||
});
|
||||
|
||||
it('saves updated label', () => {
|
||||
const status = DEFAULT_STATUSES[0];
|
||||
(component as any).openEdit(status);
|
||||
(component as any).form = { ...status, label: 'MODIFIÉ' };
|
||||
(component as any).save();
|
||||
expect(store.getById(status.id)?.label).toBe('MODIFIÉ');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
|
||||
|
||||
const STORAGE_KEY = 'bonsai_statuses';
|
||||
|
||||
describe('StatusesStore', () => {
|
||||
let store: StatusesStore;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
TestBed.configureTestingModule({});
|
||||
store = TestBed.inject(StatusesStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('statuses', () => {
|
||||
it('returns default statuses when localStorage is empty', () => {
|
||||
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
|
||||
});
|
||||
|
||||
it('restores statuses saved in localStorage', () => {
|
||||
const saved = [
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 0 },
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 1 },
|
||||
];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
const freshStore = TestBed.inject(StatusesStore);
|
||||
expect(freshStore.statuses()).toEqual(saved);
|
||||
});
|
||||
|
||||
it('falls back to default when localStorage contains invalid JSON', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-json');
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
const freshStore = TestBed.inject(StatusesStore);
|
||||
expect(freshStore.statuses()).toEqual(DEFAULT_STATUSES);
|
||||
});
|
||||
|
||||
it('sorts statuses by order on load', () => {
|
||||
const unsorted = [DEFAULT_STATUSES[2], DEFAULT_STATUSES[0], DEFAULT_STATUSES[1], DEFAULT_STATUSES[3]];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(unsorted));
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
const freshStore = TestBed.inject(StatusesStore);
|
||||
expect(freshStore.statuses().map((s) => s.id)).toEqual(['draft', 'todo', 'in-progress', 'done']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOrder', () => {
|
||||
it('reassigns order values based on position', () => {
|
||||
const reordered = [DEFAULT_STATUSES[3], DEFAULT_STATUSES[2], DEFAULT_STATUSES[1], DEFAULT_STATUSES[0]];
|
||||
store.setOrder(reordered);
|
||||
expect(store.statuses()[0].id).toBe('done');
|
||||
expect(store.statuses()[0].order).toBe(0);
|
||||
expect(store.statuses()[3].id).toBe('draft');
|
||||
expect(store.statuses()[3].order).toBe(3);
|
||||
});
|
||||
|
||||
it('persists the new order to localStorage', () => {
|
||||
const reordered = [DEFAULT_STATUSES[1], DEFAULT_STATUSES[0], DEFAULT_STATUSES[2], DEFAULT_STATUSES[3]];
|
||||
store.setOrder(reordered);
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(stored[0].id).toBe('todo');
|
||||
expect(stored[1].id).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns the matching status entity', () => {
|
||||
expect(store.getById('draft')?.label).toBe('BROUILLON');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown id', () => {
|
||||
expect(store.getById('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('adds a new status with the next order value', () => {
|
||||
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b' });
|
||||
const added = store.getById('blocked');
|
||||
expect(added?.label).toBe('BLOQUÉ');
|
||||
expect(added?.order).toBe(DEFAULT_STATUSES.length);
|
||||
});
|
||||
|
||||
it('persists the new status to localStorage', () => {
|
||||
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000' });
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(stored.some((s: { id: string }) => s.id === 'custom')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates label, bg and color of an existing status', () => {
|
||||
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000' });
|
||||
const updated = store.getById('draft');
|
||||
expect(updated?.label).toBe('NOUVEAU');
|
||||
expect(updated?.bg).toBe('#fff');
|
||||
});
|
||||
|
||||
it('does not change unrelated statuses', () => {
|
||||
store.update('draft', { label: 'NOUVEAU' });
|
||||
expect(store.getById('todo')?.label).toBe('À FAIRE');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[]>(this.loadFromStorage());
|
||||
|
||||
readonly statuses = this.data.asReadonly();
|
||||
|
||||
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((list) => [...list, { ...status, order: maxOrder + 1 }]);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
|
||||
this.data.update((list) =>
|
||||
list.map((s) => (s.id === id ? { ...s, ...changes } : s)),
|
||||
);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
setOrder(ordered: StatusEntity[]): void {
|
||||
this.data.set(ordered.map((s, i) => ({ ...s, order: i })));
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
private saveToStorage(): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data()));
|
||||
}
|
||||
|
||||
private loadFromStorage(): StatusEntity[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as StatusEntity[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return [...parsed].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return [...DEFAULT_STATUSES];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 readonly statuses = this.statusesStore.statuses;
|
||||
|
||||
protected dragIndex: number | null = null;
|
||||
protected dragOverIndex: number | null = null;
|
||||
|
||||
protected formMode = signal<FormMode | null>(null);
|
||||
protected editingId = signal<string | null>(null);
|
||||
protected form: StatusForm = this.emptyForm();
|
||||
protected idError = signal<string | null>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected onDragStart(index: number): void {
|
||||
this.dragIndex = index;
|
||||
}
|
||||
|
||||
protected onDragOver(event: DragEvent, index: number): void {
|
||||
event.preventDefault();
|
||||
this.dragOverIndex = index;
|
||||
}
|
||||
|
||||
protected onDrop(event: DragEvent, dropIndex: number): void {
|
||||
event.preventDefault();
|
||||
if (this.dragIndex === null || this.dragIndex === dropIndex) {
|
||||
this.dragIndex = null;
|
||||
this.dragOverIndex = null;
|
||||
return;
|
||||
}
|
||||
const newOrder = [...this.statuses()];
|
||||
const [dragged] = newOrder.splice(this.dragIndex, 1);
|
||||
newOrder.splice(dropIndex, 0, dragged);
|
||||
this.statusesStore.setOrder(newOrder);
|
||||
this.dragIndex = null;
|
||||
this.dragOverIndex = null;
|
||||
}
|
||||
|
||||
protected onDragEnd(): void {
|
||||
this.dragIndex = null;
|
||||
this.dragOverIndex = null;
|
||||
}
|
||||
|
||||
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