Sep 4 - Add link relavant issues function
This commit is contained in:
@@ -227,6 +227,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Linked issues ──────────────────────────────────────────────────────
|
||||
Duplicates and related issues, so whoever picks this one up can reach the
|
||||
others. `issue_links` arrives already filtered to links whose far end this
|
||||
viewer may open (_readable_links) — do NOT add links from the model
|
||||
directly here, or a customer sees an issue at a facility they have no
|
||||
assignment to. Links are navigational only: nothing here changes status,
|
||||
SLA, assignee or followers on either issue. #}
|
||||
<div class="card shadow-sm mb-4" id="linked-issues-section">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-link-45deg me-1"></i>Linked Issues
|
||||
<span class="badge bg-secondary rounded-pill ms-1">{{ issue_links|length }}</span>
|
||||
</h6>
|
||||
{% if can_manage_links %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#linkIssueModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Link an Issue
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-body py-2">
|
||||
{% if issue_links %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for link, other, label in issue_links %}
|
||||
<div class="list-group-item px-0 py-2 d-flex align-items-start gap-2 flex-wrap">
|
||||
<span class="badge {{ 'bg-warning text-dark' if link.link_type == 'duplicate' else 'bg-info text-dark' }} mt-1"
|
||||
style="min-width:7.5rem;">{{ label }}</span>
|
||||
|
||||
<div class="flex-grow-1" style="min-width:14rem;">
|
||||
<a href="{{ url_for('issues.view', issue_id=other.id, next=back_url) }}"
|
||||
class="fw-semibold text-decoration-none">#{{ other.id }}</a>
|
||||
<span class="text-muted small ms-1">
|
||||
{{ other.area.name if other.area
|
||||
else other.resolved_facility.name if other.resolved_facility else '—' }}
|
||||
</span>
|
||||
<div class="small text-muted text-truncate" style="max-width:38rem;">
|
||||
{{ other.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-1 mt-1">
|
||||
<span class="badge bg-{{ 'danger' if other.severity in ['critical','high']
|
||||
else 'warning text-dark' if other.severity == 'medium'
|
||||
else 'secondary' }}">{{ other.severity|title }}</span>
|
||||
<span class="badge bg-{{ 'success' if other.status == 'resolved'
|
||||
else 'info text-dark' if other.status == 'pending_verification'
|
||||
else 'light text-dark' }}">
|
||||
{{ other.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
{% if can_manage_links %}
|
||||
<form method="POST" class="mb-0 ms-1"
|
||||
action="{{ url_for('issues.remove_link', issue_id=issue.id, link_id=link.id) }}"
|
||||
onsubmit="return confirm('Remove the link between #{{ issue.id }} and #{{ other.id }}? Neither issue is changed or deleted.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0 px-1"
|
||||
title="Remove this link">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0 py-1">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
No linked issues.
|
||||
{% if can_manage_links %}
|
||||
Use <strong>Link an Issue</strong> to point at a duplicate or a related issue.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Comments ───────────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-4" id="comments-section">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
@@ -564,6 +642,77 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Link an issue ────────────────────────────────────────────────────────────
|
||||
Search is scoped server-side to issues this viewer could already open, so the
|
||||
picker can never be used to enumerate another contract's issues. The POST
|
||||
re-checks access — the search is only a convenience. #}
|
||||
{% if can_manage_links %}
|
||||
<div class="modal fade" id="linkIssueModal" tabindex="-1"
|
||||
aria-labelledby="linkIssueModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('issues.add_link', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="linkIssueModalLabel">
|
||||
<i class="bi bi-link-45deg me-1"></i>Link an issue to #{{ issue.id }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold" for="linkTypeSelect">
|
||||
How are they related?
|
||||
</label>
|
||||
<select name="link_type" id="linkTypeSelect" class="form-select form-select-sm">
|
||||
{% for value, label in link_types %}
|
||||
<option value="{{ value }}">
|
||||
#{{ issue.id }} is a <strong>{{ label|lower }}</strong> …
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Linking is for navigation only — neither issue's status, SLA or
|
||||
assignee changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold" for="linkIssueSearch">
|
||||
Which issue?
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" id="linkIssueSearch"
|
||||
autocomplete="off" placeholder="Issue number, or words from the description…">
|
||||
<input type="hidden" name="linked_issue_id" id="linkIssueId">
|
||||
</div>
|
||||
|
||||
{# Chosen issue, shown once picked so nobody submits a mistyped number #}
|
||||
<div id="linkIssueChosen" class="alert alert-primary py-2 small d-none mb-2">
|
||||
<span id="linkIssueChosenText"></span>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2" id="linkIssueClear">change</button>
|
||||
</div>
|
||||
|
||||
<div id="linkIssueResults" class="list-group small" style="max-height:16rem; overflow-y:auto;"></div>
|
||||
<div id="linkIssueEmpty" class="text-muted small d-none py-2">
|
||||
No matching issue you can access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm" id="linkIssueSubmit" disabled>
|
||||
<i class="bi bi-link-45deg me-1"></i>Link Issue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@@ -619,6 +768,122 @@
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
// ── Link-an-issue picker ────────────────────────────────────────────────
|
||||
// Type a number or some words, pick from the scoped results, submit. The
|
||||
// hidden linked_issue_id is only ever set by CHOOSING a result, so the
|
||||
// number posted is always one the server just confirmed this user can see.
|
||||
//
|
||||
// Every result field is written with textContent / createTextNode, never
|
||||
// innerHTML: `description` is text a person typed and would otherwise be
|
||||
// an XSS hole straight into the page of whoever opens the picker.
|
||||
var linkSearch = document.getElementById('linkIssueSearch');
|
||||
if (linkSearch) {
|
||||
var linkResults = document.getElementById('linkIssueResults');
|
||||
var linkEmpty = document.getElementById('linkIssueEmpty');
|
||||
var linkIdField = document.getElementById('linkIssueId');
|
||||
var linkChosen = document.getElementById('linkIssueChosen');
|
||||
var linkChosenText = document.getElementById('linkIssueChosenText');
|
||||
var linkClear = document.getElementById('linkIssueClear');
|
||||
var linkSubmit = document.getElementById('linkIssueSubmit');
|
||||
var searchTimer = null;
|
||||
var searchSeq = 0;
|
||||
|
||||
function clearChoice() {
|
||||
linkIdField.value = '';
|
||||
linkSubmit.disabled = true;
|
||||
linkChosen.classList.add('d-none');
|
||||
linkSearch.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function choose(item) {
|
||||
linkIdField.value = item.id;
|
||||
linkSubmit.disabled = false;
|
||||
linkChosenText.textContent =
|
||||
'#' + item.id + ' — ' + item.location + ' — ' + item.description;
|
||||
linkChosen.classList.remove('d-none');
|
||||
linkSearch.classList.add('d-none');
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.add('d-none');
|
||||
}
|
||||
|
||||
function renderResults(items) {
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.toggle('d-none', items.length > 0);
|
||||
|
||||
items.forEach(function (item) {
|
||||
var row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'list-group-item list-group-item-action py-2';
|
||||
|
||||
var head = document.createElement('div');
|
||||
head.className = 'd-flex justify-content-between gap-2';
|
||||
|
||||
var num = document.createElement('span');
|
||||
num.className = 'fw-semibold';
|
||||
num.textContent = '#' + item.id + ' · ' + item.location;
|
||||
|
||||
var meta = document.createElement('span');
|
||||
meta.className = 'text-muted';
|
||||
meta.textContent = item.severity + ' · ' + item.status +
|
||||
(item.reported_at ? ' · ' + item.reported_at : '');
|
||||
|
||||
head.appendChild(num);
|
||||
head.appendChild(meta);
|
||||
|
||||
var desc = document.createElement('div');
|
||||
desc.className = 'text-muted text-truncate';
|
||||
desc.textContent = item.description;
|
||||
|
||||
row.appendChild(head);
|
||||
row.appendChild(desc);
|
||||
row.addEventListener('click', function () { choose(item); });
|
||||
linkResults.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
var term = linkSearch.value.trim();
|
||||
if (!term) {
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
// Responses can arrive out of order; only the newest one may render.
|
||||
var seq = ++searchSeq;
|
||||
fetch('{{ url_for("issues.link_search", issue_id=issue.id) }}?q=' +
|
||||
encodeURIComponent(term), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (res) { return res.ok ? res.json() : { results: [] }; })
|
||||
.then(function (data) {
|
||||
if (seq !== searchSeq) { return; }
|
||||
renderResults(data.results || []);
|
||||
})
|
||||
.catch(function () {
|
||||
if (seq !== searchSeq) { return; }
|
||||
renderResults([]);
|
||||
});
|
||||
}
|
||||
|
||||
linkSearch.addEventListener('input', function () {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runSearch, 250);
|
||||
});
|
||||
// The picker lives inside a form — Enter would submit it with no issue
|
||||
// chosen instead of searching.
|
||||
linkSearch.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
clearTimeout(searchTimer);
|
||||
runSearch();
|
||||
}
|
||||
});
|
||||
linkClear.addEventListener('click', function () {
|
||||
clearChoice();
|
||||
linkSearch.value = '';
|
||||
linkSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user