feat: add Jira ticket creation modal
Release notes: create Jira tickets from the header, targeting the project and epic found in JQL, with title, dates, and assignee.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Jira Ticket Extractor - Mise à jour
|
# Jira Ticket Extractor - Mise à jour
|
||||||
|
|
||||||
|
## Notes de version
|
||||||
|
|
||||||
|
### Création de tickets Jira
|
||||||
|
- Nouveau bouton dans le header pour créer un ticket Jira depuis l'interface.
|
||||||
|
- Le projet et l'epic parent sont déduits de la JQL configurée.
|
||||||
|
- La modale permet de renseigner le titre, la Due Date, un champ personnalisé de date et l'assigné.
|
||||||
|
|
||||||
## Nouvelles fonctionnalités
|
## Nouvelles fonctionnalités
|
||||||
|
|
||||||
### 1. Labels des tickets
|
### 1. Labels des tickets
|
||||||
|
|||||||
+39
@@ -28,6 +28,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="badge-api"><i class="fa-solid fa-server"></i>API : localhost:3000</div>
|
<div class="badge-api"><i class="fa-solid fa-server"></i>API : localhost:3000</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreateIssueModal()" title="Create a Jira ticket in the project or epic from the JQL">
|
||||||
|
<i class="fa-solid fa-plus"></i> New ticket
|
||||||
|
</button>
|
||||||
<button class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" aria-label="Toggle theme" title="Switch between light and dark mode">
|
<button class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" aria-label="Toggle theme" title="Switch between light and dark mode">
|
||||||
<i class="fa-solid fa-sun" id="themeIcon"></i>
|
<i class="fa-solid fa-sun" id="themeIcon"></i>
|
||||||
<span id="themeLabel">Light mode</span>
|
<span id="themeLabel">Light mode</span>
|
||||||
@@ -174,6 +177,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="createIssueModal">
|
||||||
|
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="createIssueTitle">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="createIssueTitle"><i class="fa-solid fa-square-plus"></i> New Jira Ticket</div>
|
||||||
|
<button class="modal-close" onclick="closeCreateIssueModal()" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-target" id="createIssueTarget"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createIssueSummary">Title</label>
|
||||||
|
<input type="text" id="createIssueSummary" maxlength="255" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="modal-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createIssueDueDate">Due Date</label>
|
||||||
|
<input type="date" id="createIssueDueDate" class="date-input modal-date-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createIssueGoLiveDate" id="createIssueGoLiveLabel">Go Live Date</label>
|
||||||
|
<input type="date" id="createIssueGoLiveDate" class="date-input modal-date-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createIssueAssignee">Assignee</label>
|
||||||
|
<input type="text" id="createIssueAssignee" autocomplete="off" placeholder="Jira username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeCreateIssueModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="createIssueSaveBtn" onclick="createIssue()">
|
||||||
|
<i class="fa-solid fa-plus"></i> Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/js/script.js"></script>
|
<script src="/js/script.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1469,6 +1469,23 @@ GANTT
|
|||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-target {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-date-input {
|
.modal-date-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1481,4 +1498,15 @@ GANTT
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+105
-1
@@ -408,6 +408,102 @@ function enableUpdateBtn(ticketKey) {
|
|||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TICKET CREATION ---
|
||||||
|
function getCreateIssueContext() {
|
||||||
|
const jql = document.getElementById('jqlQuery').value.trim();
|
||||||
|
const fallbackProject = document.getElementById('projectKey').value.trim().toUpperCase();
|
||||||
|
const projectMatch = jql.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i);
|
||||||
|
const epicMatch = jql.match(/(?:["']?Epic Link["']?|\bparent|cf\[\d+\])\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
||||||
|
const configuredFields = parseCustomFields(document.getElementById('customFields').value.trim());
|
||||||
|
const dateField = configuredFields.find(field => /date|go live|production/i.test(field.label)) || configuredFields[0] || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
jql,
|
||||||
|
projectKey: (projectMatch?.[1] || fallbackProject).toUpperCase(),
|
||||||
|
epicKey: epicMatch?.[1]?.toUpperCase() || '',
|
||||||
|
dateField
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateIssueModal() {
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const apiToken = document.getElementById('apiToken').value.trim();
|
||||||
|
const context = getCreateIssueContext();
|
||||||
|
|
||||||
|
if (!username || !apiToken) {
|
||||||
|
showToast('Enter your Jira credentials first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.projectKey) {
|
||||||
|
showToast('Add a project to the JQL query first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings();
|
||||||
|
document.getElementById('createIssueTarget').textContent = context.epicKey
|
||||||
|
? `Project ${context.projectKey} · Epic ${context.epicKey}`
|
||||||
|
: `Project ${context.projectKey}`;
|
||||||
|
document.getElementById('createIssueSummary').value = '';
|
||||||
|
document.getElementById('createIssueDueDate').value = '';
|
||||||
|
document.getElementById('createIssueGoLiveDate').value = '';
|
||||||
|
document.getElementById('createIssueAssignee').value = '';
|
||||||
|
|
||||||
|
const customDateInput = document.getElementById('createIssueGoLiveDate');
|
||||||
|
document.getElementById('createIssueGoLiveLabel').textContent = context.dateField?.label || 'Custom Date';
|
||||||
|
customDateInput.disabled = !context.dateField;
|
||||||
|
customDateInput.title = context.dateField ? '' : 'Configure a custom date field in Extraction Settings';
|
||||||
|
|
||||||
|
document.getElementById('createIssueModal').classList.add('visible');
|
||||||
|
document.getElementById('createIssueSummary').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateIssueModal() {
|
||||||
|
document.getElementById('createIssueModal').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIssue() {
|
||||||
|
const summary = document.getElementById('createIssueSummary').value.trim();
|
||||||
|
if (!summary) {
|
||||||
|
showToast('The ticket title is required.', 'error');
|
||||||
|
document.getElementById('createIssueSummary').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getCreateIssueContext();
|
||||||
|
const btn = document.getElementById('createIssueSaveBtn');
|
||||||
|
const originalContent = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Creating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/issues`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value.trim(),
|
||||||
|
apiToken: document.getElementById('apiToken').value.trim(),
|
||||||
|
projectKey: context.projectKey,
|
||||||
|
jql: context.jql,
|
||||||
|
summary,
|
||||||
|
dueDate: document.getElementById('createIssueDueDate').value || null,
|
||||||
|
goLiveDate: document.getElementById('createIssueGoLiveDate').value || null,
|
||||||
|
goLiveFieldId: context.dateField?.id || null,
|
||||||
|
assignee: document.getElementById('createIssueAssignee').value.trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || `HTTP Error ${response.status}`);
|
||||||
|
|
||||||
|
closeCreateIssueModal();
|
||||||
|
showToast(`Ticket ${data.key} created!`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalContent;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpdateClick(ticketKey, goLiveColName) {
|
function handleUpdateClick(ticketKey, goLiveColName) {
|
||||||
const dueInput = document.getElementById(`due-${ticketKey}`);
|
const dueInput = document.getElementById(`due-${ticketKey}`);
|
||||||
const dueDate = dueInput && dueInput.value ? dueInput.value : null;
|
const dueDate = dueInput && dueInput.value ? dueInput.value : null;
|
||||||
@@ -964,7 +1060,10 @@ function clearResults() { allTickets=[]; filteredTickets=[]; document.getElement
|
|||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()}
|
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()}
|
||||||
if(e.key === 'Escape') closeDateEditModal();
|
if(e.key === 'Escape') {
|
||||||
|
closeDateEditModal();
|
||||||
|
closeCreateIssueModal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
@@ -975,5 +1074,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeDateEditModal(); });
|
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeDateEditModal(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createIssueOverlay = document.getElementById('createIssueModal');
|
||||||
|
if (createIssueOverlay) {
|
||||||
|
createIssueOverlay.addEventListener('click', e => { if (e.target === createIssueOverlay) closeCreateIssueModal(); });
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Settings loaded from local storage.");
|
console.log("Settings loaded from local storage.");
|
||||||
});
|
});
|
||||||
@@ -57,6 +57,19 @@ const jiraClient = axios.create({
|
|||||||
timeout: 60000
|
timeout: 60000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseJqlContext(jql, fallbackProjectKey) {
|
||||||
|
const projectMatch = jql?.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i);
|
||||||
|
const parentMatch = jql?.match(/\bparent\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
||||||
|
const epicLinkMatch = jql?.match(/["']?Epic Link["']?\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
||||||
|
const customEpicMatch = jql?.match(/cf\[(\d+)\]\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectKey: (projectMatch?.[1] || fallbackProjectKey || '').toUpperCase(),
|
||||||
|
epicKey: (parentMatch?.[1] || epicLinkMatch?.[1] || customEpicMatch?.[2] || '').toUpperCase(),
|
||||||
|
epicRelation: parentMatch ? 'parent' : (customEpicMatch ? `customfield_${customEpicMatch[1]}` : (epicLinkMatch ? 'epic-link' : null))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DEBUG : Intercepteurs de requêtes
|
// DEBUG : Intercepteurs de requêtes
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -230,6 +243,68 @@ app.post('/api/extract', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Endpoint de création de ticket dans le projet / epic ciblé par la JQL
|
||||||
|
app.post('/api/issues', async (req, res) => {
|
||||||
|
const { username, apiToken, projectKey, jql, summary, dueDate, goLiveDate, goLiveFieldId, assignee } = req.body;
|
||||||
|
|
||||||
|
if (!username || !apiToken || !summary) {
|
||||||
|
return res.status(400).json({ error: "Les champs username, apiToken et summary sont requis." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = parseJqlContext(jql, projectKey);
|
||||||
|
if (!context.projectKey) {
|
||||||
|
return res.status(400).json({ error: "Aucun projet n'a été trouvé dans la JQL." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${apiToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fields = {
|
||||||
|
project: { key: context.projectKey },
|
||||||
|
summary: summary.trim(),
|
||||||
|
issuetype: { name: 'Task' }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dueDate) fields.duedate = dueDate;
|
||||||
|
if (goLiveDate && goLiveFieldId?.startsWith('customfield_')) fields[goLiveFieldId] = goLiveDate;
|
||||||
|
if (assignee?.trim()) fields.assignee = { name: assignee.trim() };
|
||||||
|
|
||||||
|
if (context.epicKey) {
|
||||||
|
if (context.epicRelation === 'parent') {
|
||||||
|
fields.parent = { key: context.epicKey };
|
||||||
|
} else {
|
||||||
|
let epicFieldId = context.epicRelation;
|
||||||
|
if (epicFieldId === 'epic-link') {
|
||||||
|
const fieldResponse = await jiraClient.get('/rest/api/2/field', { headers });
|
||||||
|
epicFieldId = fieldResponse.data.find(field => field.name === 'Epic Link')?.id;
|
||||||
|
}
|
||||||
|
if (!epicFieldId) {
|
||||||
|
return res.status(400).json({ error: "Le champ Jira 'Epic Link' est introuvable." });
|
||||||
|
}
|
||||||
|
fields[epicFieldId] = context.epicKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await jiraClient.post('/rest/api/2/issue', { fields }, { headers });
|
||||||
|
console.log(`[CREATE] Ticket ${response.data.key} créé dans ${context.projectKey}.`);
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
key: response.data.key,
|
||||||
|
url: `${config.jira.url}/browse/${response.data.key}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CREATE ERROR]', error.response?.data || error.message);
|
||||||
|
res.status(error.response?.status || 500).json({
|
||||||
|
error: error.response?.data?.errorMessages?.[0] || error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Endpoint de mise à jour de ticket
|
// Endpoint de mise à jour de ticket
|
||||||
app.put('/api/update', async (req, res) => {
|
app.put('/api/update', async (req, res) => {
|
||||||
const { username, apiToken, ticketKey, updates } = req.body;
|
const { username, apiToken, ticketKey, updates } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user