Files
flowdeck/app/templates/board.html
T
bruno ae41937b03
CI / test (push) Failing after 4s
CI / lint (push) Failing after 4s
feat: multi-label selection + double-click column to create issue
- Labels now rendered as checkboxes (not single-select dropdown)
  in both create modal and edit form
- ALL_LABELS and ALL_MILESTONES passed as JSON to JS
- Double-click on empty column area opens create modal
  (pre-fills with that column name in title placeholder context)
- '+ Issue' button also opens the same modal
- Removed old inline new-issue-form (Alpine x-show), replaced with modal
- Create modal: title, body, multi-label checkboxes, milestone, assignee
- Edit form: title, body, multi-label checkboxes, assignee
- CSS: .label-checkboxes + .label-cb styles
2026-07-08 10:47:54 -04:00

366 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<div class="board"
x-data="{
owner: '{{ owner }}',
repo: '{{ repo }}',
milestone: '{{ current_milestone }}',
label: '{{ current_label }}',
assignee: '{{ current_assignee }}'
}">
<!-- Header -->
<div class="board-header">
<div class="board-breadcrumb">
<a href="/" class="back-link">← Projets</a>
<span class="separator">/</span>
<span class="repo-owner">{{ owner }}</span>
<span class="separator">/</span>
<strong>{{ repo }}</strong>
</div>
<div class="board-actions">
<button class="btn btn-sm" onclick="registerWebhook('{{ owner }}', '{{ repo }}')" title="Enregistrer webhook">🔔 Webhook</button>
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
<button class="btn btn-sm btn-primary" onclick="openCreateModal('{{ owner }}','{{ repo }}','Backlog')">+ Issue</button>
</div>
</div>
<!-- Filters -->
<div class="board-filters">
<select class="filter-select" name="milestone_filter"
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target="#board-columns"
hx-trigger="change"
hx-include="[name='label_filter'],[name='assignee_filter']"
hx-swap="outerHTML">
<option value="">Tous les milestones</option>
{% for m in milestones %}
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
{% endfor %}
</select>
<select class="filter-select" name="label_filter"
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target="#board-columns"
hx-trigger="change"
hx-include="[name='milestone_filter'],[name='assignee_filter']"
hx-swap="outerHTML">
<option value="">Tous les labels</option>
{% for lbl in labels %}
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
{% endfor %}
</select>
<input type="text" name="assignee_filter" placeholder="Assigné à..."
class="filter-input"
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target="#board-columns"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='milestone_filter'],[name='label_filter']"
hx-swap="outerHTML"
value="{{ current_assignee }}">
<button class="btn btn-sm" onclick="window.location.reload()">🔄 Rafraîchir</button>
</div>
<!-- Columns (fragment) -->
{% include "board_fragment.html" %}
<!-- PRs section -->
{% if prs %}
<div class="prs-section">
<h3>🔄 Pull Requests ({{ prs|length }})</h3>
<div class="prs-list">
{% for pr in prs %}
<div class="pr-card">
<a href="{{ pr.html_url }}" target="_blank" class="pr-title">#{{ pr.number }} {{ pr.title[:80] }}</a>
<div class="pr-meta">
{% if pr.user %}<span>{{ pr.user.login }}</span>{% endif %}
<span>{{ pr.state }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Create Issue Modal (reuses card-modal) -->
<div id="create-modal" class="modal-overlay" style="display:none">
<div class="modal-container">
<div class="modal-header">
<h3 id="create-modal-title">Nouvelle issue</h3>
<button onclick="closeCreateModal()" class="modal-close">&times;</button>
</div>
<div id="create-modal-content" class="modal-body"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Labels data (passed from server) ──
const ALL_LABELS = {{ labels | tojson }};
const ALL_MILESTONES = {{ milestones | tojson }};
function getCSRFToken() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
}
// ── Create Modal ──
function openCreateModal(owner, repo, defaultColumn) {
document.getElementById('card-modal').style.display = 'none';
const modal = document.getElementById('create-modal');
const content = document.getElementById('create-modal-content');
document.getElementById('create-modal-title').textContent = `Nouvelle issue — ${owner}/${repo}`;
let h = '<form onsubmit="saveNewIssue(event,\''+owner+'\',\''+repo+'\',\''+defaultColumn+'\')">';
h += '<label class="form-label">Titre *</label>';
h += '<input type="text" id="create-title" class="form-input" placeholder="Titre de l\'issue..." required>';
h += '<label class="form-label">Description</label>';
h += '<textarea id="create-body" rows="4" class="form-input" placeholder="Description (markdown)..."></textarea>';
// Multi-label checkboxes
h += '<label class="form-label">Labels</label>';
h += '<div class="label-checkboxes">';
if (ALL_LABELS.length === 0) {
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label dans ce repo. <a href="'+window._giteaUrl+'/'+owner+'/'+repo+'/labels" target="_blank">En créer dans Gitea</a></span>';
} else {
ALL_LABELS.forEach(l => {
h += '<label class="label-cb"><input type="checkbox" name="labels" value="'+l.id+'"> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
});
}
h += '</div>';
// Milestone
h += '<label class="form-label">Milestone</label>';
h += '<select id="create-milestone" class="form-select">';
h += '<option value="">Aucun</option>';
ALL_MILESTONES.forEach(m => { h += '<option value="'+m.id+'">'+escHtml(m.title)+'</option>'; });
h += '</select>';
// Assignee
h += '<label class="form-label">Assigné à</label>';
h += '<input type="text" id="create-assignee" class="form-input" placeholder="Login Gitea...">';
h += '<div class="detail-actions" style="margin-top:12px">';
h += '<button type="submit" class="btn btn-primary">💾 Créer</button>';
h += '<button type="button" class="btn" onclick="closeCreateModal()">Annuler</button>';
h += '</div></form>';
content.innerHTML = h;
modal.style.display = 'flex';
}
function closeCreateModal() {
document.getElementById('create-modal').style.display = 'none';
}
async function saveNewIssue(evt, owner, repo, defaultColumn) {
evt.preventDefault();
const title = document.getElementById('create-title').value.trim();
const body = document.getElementById('create-body').value.trim();
const milestone = document.getElementById('create-milestone').value;
const assignee = document.getElementById('create-assignee').value.trim();
if (!title) return;
// Gather checked labels
const checkedLabels = [];
document.querySelectorAll('#create-modal-content input[name="labels"]:checked').forEach(cb => {
checkedLabels.push(cb.value);
});
const params = new URLSearchParams({title, body, milestone, assignee});
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
const resp = await fetch(`/api/issues/${owner}/${repo}?${params}`, {
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
});
if (resp.ok) {
closeCreateModal();
window.location.reload();
} else {
alert('Erreur lors de la création');
}
}
// ── Column double-click → create with column preselected ──
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.board-column').forEach(col => {
col.addEventListener('dblclick', function(evt) {
// Only if NOT clicking on a card
if (evt.target.closest('.card')) return;
const owner = this.querySelector('.column-cards')?.dataset.owner;
const repo = this.querySelector('.column-cards')?.dataset.repo;
const column = this.dataset.column || 'Backlog';
if (owner && repo) openCreateModal(owner, repo, column);
});
});
});
document.addEventListener('htmx:afterSettle', () => {
document.querySelectorAll('.board-column').forEach(col => {
if (col._hasDblClick) return;
col._hasDblClick = true;
col.addEventListener('dblclick', function(evt) {
if (evt.target.closest('.card')) return;
const owner = this.querySelector('.column-cards')?.dataset.owner;
const repo = this.querySelector('.column-cards')?.dataset.repo;
const column = this.dataset.column || 'Backlog';
if (owner && repo) openCreateModal(owner, repo, column);
});
});
});
// ── SortableJS ──
function initSortable() {
document.querySelectorAll('.sortable').forEach(el => {
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban', animation: 150, ghostClass: 'card-ghost',
onEnd: async function(evt) {
const card = evt.item, issueId = card.dataset.issueId;
const newCol = evt.to.dataset.column, owner = evt.to.dataset.owner, repo = evt.to.dataset.repo;
try {
const resp = await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newCol)}`, {
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
});
if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => {
const c = col.querySelectorAll('.card').length;
const b = col.querySelector('.column-count');
if (b) b.textContent = c;
});
} catch(e) { console.error('Move failed:', e); window.location.reload(); }
}
});
});
}
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSettle', initSortable);
// ── Card Detail ──
function openCardDetail(owner, repo, issueId) {
document.getElementById('create-modal').style.display = 'none';
const modal = document.getElementById('card-modal');
const content = document.getElementById('card-modal-content');
content.innerHTML = '<div class="loading">Chargement...</div>';
document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`;
modal.style.display = 'flex';
fetch(`/api/issues/${owner}/${repo}/${issueId}`)
.then(r => r.json()).then(data => { content.innerHTML = renderCardDetail(owner, repo, data); })
.catch(e => { content.innerHTML = '<p class="error">Erreur: '+e+'</p>'; });
}
function closeModal() { document.getElementById('card-modal').style.display = 'none'; }
function renderCardDetail(owner, repo, data) {
const issue = data.issue || {}, comments = data.comments || [], checklists = data.checklists || [], card = data.card || {};
window._issueData = {owner, repo, issue, comments, checklists, card};
let h = '';
h += '<div class="detail-section"><span class="detail-state badge '+issue.state+'">'+issue.state+'</span> <strong>'+escHtml(issue.title||'')+'</strong></div>';
h += '<div class="detail-meta">';
(issue.labels||[]).forEach(l => h += '<span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span>');
if (issue.milestone) h += '<span>📅 '+escHtml(issue.milestone.title)+'</span>';
if (issue.assignee) h += '<span>👤 '+escHtml(issue.assignee.login)+'</span>';
h += '</div>';
h += '<div class="detail-editable"><label>Priorité: <select onchange="updateCardMeta(\''+owner+'\',\''+repo+'\','+issue.number+',this.value,\'\')" class="form-select-sm">';
['Low','Medium','High','Urgent'].forEach(p => h += '<option value="'+p+'" '+(card.priority||'Medium')===p?'selected':'')+'>'+p+'</option>');
h += '</select></label> <label>Échéance: <input type="date" value="'+(card.due_date||'')+'" onchange="updateCardMeta(\''+owner+'\',\''+repo+'\','+issue.number+',\'\',this.value)" class="form-input-sm"></label></div>';
h += '<div class="detail-section"><h4>Description</h4><div class="markdown-body">'+escHtml(issue.body||'')+'</div></div>';
h += renderChecklists(owner, repo, issue.number, checklists);
h += '<div class="detail-section"><h4>Commentaires ('+comments.length+')</h4>';
comments.forEach(c => h += '<div class="comment"><img src="'+(c.user?.avatar_url||'')+'" width="24" height="24" class="avatar"><strong>'+escHtml(c.user?.login||'')+'</strong><span class="comment-date">'+(c.created_at||'').substr(0,10)+'</span><div class="comment-body">'+escHtml(c.body||'')+'</div></div>');
h += '</div>';
h += '<div class="detail-actions"><a href="'+(issue.html_url||'')+'" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>';
h += '<button onclick="showEditForm(\''+owner+'\',\''+repo+'\','+issue.number+')" class="btn btn-sm">✏️ Éditer</button>';
h += '<button onclick="closeModal()" class="btn btn-sm">Fermer</button></div>';
return h;
}
function renderChecklists(owner, repo, issueId, checklists) {
let h = '<div class="detail-section"><h4>Checklists</h4>';
checklists.forEach(cl => {
h += '<div class="checklist"><strong>'+escHtml(cl.title)+'</strong><ul class="checklist-items">';
(cl.items||[]).forEach(item => {
h += '<li><input type="checkbox" '+(item.checked?'checked':'')+' onchange="toggleCheckItem('+item.id+',this.checked)" class="checklist-check">';
h += '<span class="'+(item.checked?'checked':'')+'">'+escHtml(item.content)+'</span>';
h += '<button onclick="deleteCheckItem('+item.id+')" class="btn-del">×</button></li>';
});
h += '</ul><div class="add-item"><input type="text" id="new-item-'+cl.id+'" placeholder="Nouvelle tâche..." class="form-input-sm" onkeydown="if(event.key===\'Enter\')addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')">';
h += '<button onclick="addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+</button></div></div>';
});
if (!checklists.length) h += '<button onclick="createChecklist(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+ Nouvelle checklist</button>';
h += '</div>';
return h;
}
// ── Edit Form ──
function showEditForm(owner, repo, issueId) {
const content = document.getElementById('card-modal-content');
const data = window._issueData || {}, issue = data.issue || {};
let h = '<form onsubmit="saveIssueEdit(event,\''+owner+'\',\''+repo+'\','+issueId+')">';
h += '<label class="form-label">Titre</label>';
h += '<input type="text" id="edit-title" value="'+escHtml(issue.title||'')+'" class="form-input" required>';
h += '<label class="form-label">Description</label>';
h += '<textarea id="edit-body" rows="5" class="form-input">'+escHtml(issue.body||'')+'</textarea>';
h += '<label class="form-label">Labels</label>';
h += '<div class="label-checkboxes">';
const issueLabelIds = (issue.labels||[]).map(l => l.id);
if (!ALL_LABELS.length) {
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label</span>';
} else {
ALL_LABELS.forEach(l => {
h += '<label class="label-cb"><input type="checkbox" name="edit-labels" value="'+l.id+'" '+(issueLabelIds.includes(l.id)?'checked':'')+'> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
});
}
h += '</div>';
h += '<label class="form-label">Assigné à</label>';
h += '<input type="text" id="edit-assignee" value="'+(issue.assignee?issue.assignee.login:'')+'" placeholder="Login Gitea..." class="form-input">';
h += '<div class="detail-actions" style="margin-top:8px">';
h += '<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>';
h += '<button type="button" onclick="openCardDetail(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">Annuler</button>';
h += '</div></form>';
content.innerHTML = h;
}
async function saveIssueEdit(evt, owner, repo, issueId) {
evt.preventDefault();
const title = document.getElementById('edit-title').value.trim();
const body = document.getElementById('edit-body').value.trim();
const assignee = document.getElementById('edit-assignee').value.trim();
if (!title) return;
const checkedLabels = [];
document.querySelectorAll('#card-modal-content input[name="edit-labels"]:checked').forEach(cb => checkedLabels.push(cb.value));
const params = new URLSearchParams({title, body, assignee});
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
await fetch(`/api/issues/${owner}/${repo}/${issueId}?${params}`, {
method: 'PATCH', headers: {'X-CSRF-Token': getCSRFToken()}
});
openCardDetail(owner, repo, issueId);
}
// ── Helpers ──
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function updateCardMeta(o,r,i,p,d) { await fetch('/api/card/'+o+'/'+r+'/'+i+'?priority='+p+'&due_date='+d,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); }
async function toggleCheckItem(id,checked) { await fetch('/api/checklist-items/'+id+'?checked='+checked,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}}); }
async function deleteCheckItem(id) {
await fetch('/api/checklist-items/'+id,{method:'DELETE',headers:{'X-CSRF-Token':getCSRFToken()}});
const t = document.getElementById('card-modal-title').textContent;
const parts = t.split(' — '), n = parts[0].replace('#','');
const or = parts[1]?parts[1].split('/'):['',''];
if(or[0]&&or[1]) openCardDetail(or[0],or[1],parseInt(n));
}
async function addCheckItem(cid,o,r,iid) {
const inp = document.getElementById('new-item-'+cid);
if (!inp||!inp.value.trim()) return;
await fetch('/api/checklist-items/'+o+'/'+r+'/'+iid+'/'+cid+'?content='+encodeURIComponent(inp.value),{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
openCardDetail(o,r,iid);
}
async function createChecklist(o,r,iid) { await fetch('/api/checklists/'+o+'/'+r+'/'+iid,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); openCardDetail(o,r,iid); }
async function registerWebhook(o,r) {
try {
const resp = await fetch('/api/webhook/register/'+o+'/'+r,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
const d = await resp.json();
alert(d.status==='already_registered'?'Webhook déjà enregistré':'Webhook enregistré');
} catch(e) { alert('Erreur: '+e); }
}
</script>
{% endblock %}