Ordre des status

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-28 20:58:40 +02:00
parent 3450066c6f
commit b9ee016119
15 changed files with 667 additions and 24 deletions
+5 -3
View File
@@ -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
+2 -1
View File
@@ -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",
+2 -2
View File
@@ -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' },
];
+5 -5
View File
@@ -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"
+3 -5
View File
@@ -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',
+1 -1
View File
@@ -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 -5
View File
@@ -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));
+1 -1
View File
@@ -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 {
+137
View File
@@ -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;
}
+108
View File
@@ -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>
+113
View File
@@ -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É');
});
});
});
+111
View File
@@ -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');
});
});
});
+66
View File
@@ -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];
}
}
+109
View File
@@ -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' };
}
}