Files
flowdeck/app/templates/board.html
T
bruno a67f42bfe6
CI / test (push) Failing after 4s
CI / lint (push) Failing after 3s
fix: resolve 3 critical UI bugs
1. Refresh button no longer doubles header
   - Added ?fragment=1 param to filter/refresh HTMX requests
   - Board route returns board_fragment.html (columns only) for fragment requests
   - Refresh button uses window.location.reload() for full page reload

2. No more raw JSON in header
   - base.html: user info rendered server-side via template variable
   - board.py: decodes session cookie, passes user to template
   - Dashboard/board templates show Login/Deconnexion based on auth state

3. New issue form works
   - Replaced HTMX post with JS fetch (handleNewIssue)
   - Adds CSRF token to form submission
   - Reloads page on successful creation
   - Cleaner error handling
2026-07-08 09:45:24 -04:00

276 lines
13 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 }}',
showNewIssue: false
}">
<!-- 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" @click="showNewIssue = !showNewIssue">+ Issue</button>
</div>
</div>
<!-- New Issue Form -->
<div class="new-issue-form" x-show="showNewIssue" x-transition>
<h4>Nouvelle issue dans {{ owner }}/{{ repo }}</h4>
<form id="new-issue-form" onsubmit="handleNewIssue(event, '{{ owner }}', '{{ repo }}')">
<input type="hidden" name="csrf_token" class="csrf-token-field">
<input type="text" name="title" placeholder="Titre de l'issue..." required class="form-input">
<textarea name="body" placeholder="Description (markdown)..." rows="3" class="form-input"></textarea>
<div class="form-row">
<select name="labels" class="form-select">
<option value="">Label</option>
{% for lbl in labels %}
<option value="{{ lbl.id }}">🏷 {{ lbl.name }}</option>
{% endfor %}
</select>
<select name="milestone" class="form-select">
<option value="">Milestone</option>
{% for m in milestones %}
<option value="{{ m.id }}">📅 {{ m.title }}</option>
{% endfor %}
</select>
<input type="text" name="assignee" placeholder="Assigné à (login)" class="form-input" style="flex:1">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Créer</button>
<button type="button" class="btn" @click="showNewIssue = false">Annuler</button>
</div>
</form>
</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>
{% endblock %}
{% block scripts %}
<script>
// CSRF helper — set CSRF on all forms after load
function refreshCsrfFields() {
const csrf = getCSRFToken();
document.querySelectorAll('.csrf-token-field').forEach(f => f.value = csrf);
}
document.addEventListener('DOMContentLoaded', refreshCsrfFields);
function getCSRFToken() {
const match = document.cookie.match(/csrf_token=([^;]+)/);
return match ? match[1] : '';
}
async function handleNewIssue(evt, owner, repo) {
evt.preventDefault();
const form = evt.target;
const title = form.title.value.trim();
if (!title) return;
const body = form.body.value.trim();
const labels = form.labels.value;
const milestone = form.milestone.value;
const assignee = form.assignee.value.trim();
const csrf = getCSRFToken();
const params = new URLSearchParams({ title, body, labels, milestone, assignee });
try {
const resp = await fetch(`/api/issues/${owner}/${repo}?${params}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf }
});
if (resp.ok) {
window.location.reload();
} else {
alert('Erreur lors de la création');
}
} catch(e) {
alert('Erreur: ' + e);
}
}
// Initialize 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;
const issueId = card.dataset.issueId;
const newColumn = evt.to.dataset.column;
const owner = evt.to.dataset.owner;
const repo = evt.to.dataset.repo;
const csrfToken = getCSRFToken();
try {
const resp = await fetch(
`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`,
{ method: 'POST', headers: { 'X-CSRF-Token': csrfToken } }
);
if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => {
const cardCount = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count');
if (badge) badge.textContent = cardCount;
});
} catch (e) {
console.error('Move failed:', e);
window.location.reload();
}
}
});
});
}
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSettle', initSortable);
function openCardDetail(owner, repo, issueId) {
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 || {};
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 += `<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 tache..." class="form-input-sm" onkeydown="if(event.key==='Enter')addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})">`;
h += `<button onclick="addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})" class="btn btn-sm">+</button></div></div>`;
});
if (!checklists.length) h += `<button onclick="createChecklist('${owner}','${repo}',${issue.number})" class="btn btn-sm">+ Nouvelle checklist</button>`;
h += `</div>`;
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="editIssueInline('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>`;
h += `<button onclick="closeModal()" class="btn btn-sm">Fermer</button></div>`;
return h;
}
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function updateCardMeta(o,r,i,priority,dueDate) { await fetch(`/api/card/${o}/${r}/${i}?priority=${priority}&due_date=${dueDate}`,{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 parts=document.getElementById('card-modal-title').textContent.split(' — ');
const [ow,rp]=parts[1]?parts[1].split('/'):['',''];
if(ow&&rp) openCardDetail(ow,rp,parseInt(parts[0].replace('#','')));
}
async function addCheckItem(cid,o,r,issueId) {
const inp=document.getElementById(`new-item-${cid}`);
if(!inp||!inp.value.trim())return;
await fetch(`/api/checklist-items/${o}/${r}/${issueId}/${cid}?content=${encodeURIComponent(inp.value)}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
openCardDetail(o,r,issueId);
}
async function createChecklist(o,r,issueId) { await fetch(`/api/checklists/${o}/${r}/${issueId}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); openCardDetail(o,r,issueId); }
async function editIssueInline(o,r,issueId) {
const t=prompt('Nouveau titre:',''); if(!t)return;
await fetch(`/api/issues/${o}/${r}/${issueId}?title=${encodeURIComponent(t)}`,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}});
closeModal(); window.location.reload();
}
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 deja enregistre':'Webhook enregistre');
}catch(e){alert('Erreur: '+e);}
}
</script>
{% endblock %}