Add issue type dropdown and Save to Dashboard from lookup
- Replace issue type text input with dropdown of STEAM project types (Story default) - Add Save to Dashboard button on lookup results to link existing Jira tickets locally - Make cve_id and vendor optional on local POST /api/jira-tickets endpoint - Fix: use normalized values in local ticket INSERT query
This commit is contained in:
@@ -522,11 +522,21 @@ function createJiraTicketsRouter() {
|
|||||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
if (!cve_id || !isValidCveId(cve_id)) {
|
// CVE ID is optional — validate format only if provided and non-empty
|
||||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
let normalizedCveId = null;
|
||||||
|
if (cve_id && typeof cve_id === 'string' && cve_id.trim().length > 0) {
|
||||||
|
if (!isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||||
|
}
|
||||||
|
normalizedCveId = cve_id;
|
||||||
}
|
}
|
||||||
if (!vendor || !isValidVendor(vendor)) {
|
// Vendor is optional — validate length only if provided and non-empty
|
||||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
let normalizedVendor = null;
|
||||||
|
if (vendor && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||||
|
if (vendor.trim().length > 200) {
|
||||||
|
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||||
|
}
|
||||||
|
normalizedVendor = vendor.trim();
|
||||||
}
|
}
|
||||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||||
@@ -548,7 +558,7 @@ function createJiraTicketsRouter() {
|
|||||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -557,7 +567,7 @@ function createJiraTicketsRouter() {
|
|||||||
action: 'jira_ticket_create',
|
action: 'jira_ticket_create',
|
||||||
entityType: 'jira_ticket',
|
entityType: 'jira_ticket',
|
||||||
entityId: rows[0].id.toString(),
|
entityId: rows[0].id.toString(),
|
||||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ export default function JiraPage() {
|
|||||||
const [lookupResult, setLookupResult] = useState(null);
|
const [lookupResult, setLookupResult] = useState(null);
|
||||||
const [lookupLoading, setLookupLoading] = useState(false);
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
const [lookupError, setLookupError] = useState(null);
|
const [lookupError, setLookupError] = useState(null);
|
||||||
|
const [linkingSaving, setLinkingSaving] = useState(false);
|
||||||
|
const [linkingError, setLinkingError] = useState(null);
|
||||||
|
const [linkingSuccess, setLinkingSuccess] = useState(null);
|
||||||
|
|
||||||
// Add/Edit modal
|
// Add/Edit modal
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -287,6 +290,37 @@ export default function JiraPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Link existing Jira ticket — save to local DB without recreating in Jira
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const linkExistingTicket = async (issue) => {
|
||||||
|
setLinkingError(null);
|
||||||
|
setLinkingSuccess(null);
|
||||||
|
setLinkingSaving(true);
|
||||||
|
try {
|
||||||
|
const jiraUrl = `https://jira.charter.com/browse/${issue.key}`;
|
||||||
|
const res = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_key: issue.key,
|
||||||
|
url: jiraUrl,
|
||||||
|
summary: issue.summary || '',
|
||||||
|
status: issue.status === 'Open' || issue.status === 'In Progress' || issue.status === 'Closed' ? issue.status : 'Open',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
setLinkingSuccess(`${issue.key} saved to dashboard.`);
|
||||||
|
fetchTickets();
|
||||||
|
} catch (err) {
|
||||||
|
setLinkingError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLinkingSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CRUD — save (create or update)
|
// CRUD — save (create or update)
|
||||||
@@ -715,6 +749,18 @@ export default function JiraPage() {
|
|||||||
<div><strong>Priority:</strong> {lookupResult.priority}</div>
|
<div><strong>Priority:</strong> {lookupResult.priority}</div>
|
||||||
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
|
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
|
||||||
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
|
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
style={{ ...STYLES.btn, ...STYLES.btnSuccess, marginTop: '0.75rem' }}
|
||||||
|
onClick={() => linkExistingTicket(lookupResult)}
|
||||||
|
disabled={linkingSaving}
|
||||||
|
>
|
||||||
|
{linkingSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||||
|
Save to Dashboard
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{linkingError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingError}</div>}
|
||||||
|
{linkingSuccess && <div style={{ color: '#6EE7B7', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingSuccess}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -828,8 +874,16 @@ export default function JiraPage() {
|
|||||||
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
|
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
|
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
||||||
<input style={STYLES.input} placeholder="Task" value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))} />
|
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
||||||
|
<option value="">Story (default)</option>
|
||||||
|
<option value="Story">Story</option>
|
||||||
|
<option value="Epic">Epic</option>
|
||||||
|
<option value="Program">Program</option>
|
||||||
|
<option value="Project">Project</option>
|
||||||
|
<option value="Reservation">Reservation</option>
|
||||||
|
<option value="Automation Maintenance">Automation Maintenance</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>
|
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>
|
||||||
|
|||||||
Reference in New Issue
Block a user