Files
kanbante/static/js/board.js
T
Spark 9bf24da04e feat(frontend): panneau détail de carte, badges d'âge, limites WIP
- drawer de détail: corps markdown rendu (marked + DOMPurify
  vendorés), commentaires, formulaire de commentaire vers Gitea;
  clic distingué du drag par la distance du pointeur
- badges ⏳ N j au-delà du seuil d'âge (rouge au double)
- en-têtes de colonnes: n/limite, classe .over en rouge, toast
  d'avertissement au dépôt dans une colonne saturée
- page activity.html, liens Activité dans dashboard et board
2026-10-11 10:05:51 -04:00

265 lines
12 KiB
JavaScript

/**
* Kanbante — Kanban board: drag & drop with persisted order,
* filters, and issue creation.
*
* Failure contract: if the server rejects a move (HTTP error, network
* error, or ok:false), the card is put back where it came from and an
* error (red) toast is shown — the UI never claims a move that the
* backend did not record.
*/
document.addEventListener('DOMContentLoaded', () => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const repoFullName = document.querySelector('.kanban-card')?.dataset.repo
|| document.querySelector('footer span')?.textContent || '';
function orderOf(columnEl) {
return Array.from(columnEl.querySelectorAll('.kanban-card'))
.map(card => card.dataset.issue).join(',');
}
function revert(evt) {
const from = evt.from;
const ref = from.children[evt.oldIndex] || null;
from.insertBefore(evt.item, ref);
updateCounts();
}
document.querySelectorAll('.column-cards').forEach(col => {
new Sortable(col, {
group: 'kanban',
animation: 200,
ghostClass: 'dragging',
dragClass: 'dragging',
onEnd: function (evt) {
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
const card = evt.item;
const formData = new FormData();
formData.append('repo_full_name', card.dataset.repo);
formData.append('issue_number', card.dataset.issue);
formData.append('to_column', evt.to.dataset.column);
formData.append('from_column', evt.from.dataset.column);
formData.append('to_order', orderOf(evt.to));
formData.append('from_order', orderOf(evt.from));
formData.append('csrf_token', csrfToken);
fetch('/api/move', { method: 'POST', body: formData })
.then(async r => {
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
})
.then(data => {
updateCounts();
const targetCol = evt.to.closest('.kanban-column');
const badge = targetCol?.querySelector('.column-count');
if (badge?.classList.contains('over')) {
showToast('⚠️ Limite WIP dépassée dans ' + evt.to.dataset.column.replace('_', ' ') + ' (' + badge.textContent + ')', true);
}
let msg = '✅ Déplacé vers ' + evt.to.dataset.column.replace('_', ' ');
if (data.gitea_sync === 'closed') msg += ' · issue fermée dans Gitea';
if (data.gitea_sync === 'reopened') msg += ' · issue rouverte dans Gitea';
if (data.gitea_sync === 'failed') {
showToast('⚠️ Déplacé localement, mais la synchro Gitea a échoué', true);
return;
}
showToast(msg);
})
.catch(err => {
console.error('Move failed:', err);
revert(evt);
showToast('❌ Échec du déplacement — carte replacée', true);
});
}
});
});
// ── Filters (text + label), client-side ────────────────
const textFilter = document.getElementById('card-filter');
const labelFilter = document.getElementById('label-filter');
function applyFilters() {
const q = (textFilter?.value || '').toLowerCase();
const label = (labelFilter?.value || '').toLowerCase();
document.querySelectorAll('.kanban-card').forEach(card => {
const matchesText = !q || (card.dataset.title || '').includes(q);
const matchesLabel = !label || (card.dataset.labels || '').split(',').includes(label);
card.style.display = (matchesText && matchesLabel) ? '' : 'none';
});
updateCounts();
}
textFilter?.addEventListener('input', applyFilters);
labelFilter?.addEventListener('change', applyFilters);
// ── Create issue (goes to Gitea, card lands in Backlog) ─
const createForm = document.getElementById('create-issue-form');
createForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const titleInput = document.getElementById('new-issue-title');
const title = titleInput.value.trim();
if (!title || !repoFullName) return;
const formData = new FormData();
formData.append('repo_full_name', repoFullName);
formData.append('title', title);
formData.append('column', 'backlog');
formData.append('csrf_token', csrfToken);
try {
const r = await fetch('/api/issues', { method: 'POST', body: formData });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
showToast('✅ Issue #' + data.issue.number + ' créée');
setTimeout(() => location.reload(), 600);
} catch (err) {
console.error('Create failed:', err);
showToast('❌ Échec de la création : ' + err.message, true);
}
});
function updateCounts() {
document.querySelectorAll('.kanban-column').forEach(col => {
const count = col.querySelectorAll('.kanban-card').length;
const badge = col.querySelector('.column-count');
if (!badge) return;
const limit = parseInt(badge.dataset.limit || '0', 10);
if (limit > 0) {
badge.textContent = count + '/' + limit;
badge.classList.toggle('over', count > limit);
} else {
badge.textContent = count;
}
});
}
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => {
card.addEventListener('click', (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (e.target.closest('a')) return;
openDetail(card.dataset.repo, card.dataset.issue);
});
});
const drawer = document.getElementById('card-detail');
const overlay = document.getElementById('detail-overlay');
let currentIssue = null; // {repo, number}
function renderMarkdown(text) {
if (window.marked && window.DOMPurify) {
return DOMPurify.sanitize(marked.parse(text || ''));
}
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function closeDetail() {
drawer.hidden = true;
overlay.hidden = true;
currentIssue = null;
}
document.getElementById('detail-close')?.addEventListener('click', closeDetail);
overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number };
drawer.hidden = false;
overlay.hidden = false;
document.getElementById('detail-number').textContent = '#' + number;
document.getElementById('detail-title').textContent = 'Chargement...';
document.getElementById('detail-body').innerHTML = '';
document.getElementById('detail-comments').innerHTML = '';
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, data.comments);
} catch (err) {
document.getElementById('detail-title').textContent = 'Erreur de chargement';
showToast('❌ Détail indisponible : ' + err.message, true);
}
}
function fillDetail(issue, comments) {
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
const meta = [];
if (issue.milestone) meta.push('🎯 ' + issue.milestone);
if (issue.due_date) meta.push('📅 échéance ' + String(issue.due_date).slice(0, 10));
meta.push('créée le ' + String(issue.created_at).slice(0, 10));
for (const a of issue.assignees || []) {
meta.push('👤 ' + a.login);
}
document.getElementById('detail-meta').textContent = meta.join(' · ');
const labelsBox = document.getElementById('detail-labels');
labelsBox.innerHTML = '';
(issue.labels || []).forEach(l => {
const span = document.createElement('span');
span.className = 'label-tag';
const color = /^[0-9a-fA-F]{6}$/.test(l.color || '') ? l.color : '888888';
span.style.background = '#' + color + '30';
span.style.color = '#' + color;
span.textContent = l.name;
labelsBox.appendChild(span);
});
document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments');
box.innerHTML = '';
(comments || []).forEach(c => box.appendChild(commentNode(c)));
if (!comments || comments.length === 0) {
box.innerHTML = '<p class="detail-empty">Aucun commentaire.</p>';
}
}
function commentNode(c) {
const div = document.createElement('div');
div.className = 'comment';
const head = document.createElement('div');
head.className = 'comment-head';
head.textContent = '👤 ' + (c.user?.login || '?') + ' · ' + String(c.created_at).slice(0, 16).replace('T', ' ');
const bodyEl = document.createElement('div');
bodyEl.className = 'comment-body';
bodyEl.innerHTML = renderMarkdown(c.body || '');
div.appendChild(head);
div.appendChild(bodyEl);
return div;
}
document.getElementById('comment-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const textarea = document.getElementById('comment-body');
const body = textarea.value.trim();
if (!body) return;
const [owner, repo] = currentIssue.repo.split('/', 2);
const formData = new FormData();
formData.append('body', body);
formData.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/comments`, { method: 'POST', body: formData });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
const box = document.getElementById('detail-comments');
if (box.querySelector('.detail-empty')) box.innerHTML = '';
box.appendChild(commentNode(data.comment));
textarea.value = '';
showToast('✅ Commentaire publié dans Gitea');
} catch (err) {
showToast('❌ Échec du commentaire : ' + err.message, true);
}
});
function showToast(msg, isError = false) {
const toast = document.createElement('div');
toast.className = 'toast' + (isError ? ' error' : '');
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
});