Files
flowdeck/app/templates/board.html
T
bruno 3e78d4343c
CI / test (push) Failing after 4s
CI / lint (push) Failing after 4s
feat: full inline issue editing (title + body + assignee)
Replaced bare prompt() with proper edit form in card modal:
- showEditForm: renders title input + body textarea + assignee field
- saveIssueEdit: PATCH /api/issues/... with title, body, assignee
- 'Annuler' button returns to card detail view
- Old editIssueInline (prompt only) removed
- Added .form-label CSS style
2026-07-08 10:18:28 -04:00

309 lines
15 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 = '';
// Edit mode toggle: store data for later
window._issueData = {owner, repo, issue, comments, checklists, card};
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="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 showEditForm(owner, repo, issueId) {
const content = document.getElementById('card-modal-content');
const data = window._issueData || {};
const issue = data.issue || {};
fetch(`/api/collaborators/${owner}/${repo}`).then(r=>r.json()).then(cdata => {
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 += '<div class="form-row">';
h += '<input type="text" id="edit-assignee" value="'+(issue.assignee?issue.assignee.login:'')+'" placeholder="Assigné à (login)" class="form-input" style="flex:1">';
h += '</div>';
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 params = new URLSearchParams({title, body, assignee});
await fetch(`/api/issues/${owner}/${repo}/${issueId}?${params}`, {
method: 'PATCH', headers: {'X-CSRF-Token': getCSRFToken()}
});
openCardDetail(owner, repo, issueId);
}
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 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 %}