mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-25 12:08:40 +02:00
implement seach on domains and requests
This commit is contained in:
parent
97340e92c0
commit
5e6f405b95
6 changed files with 197 additions and 49 deletions
|
@ -918,8 +918,9 @@ function ScrollToElement(attributeName, attributeValue) {
|
|||
* @param {boolean} hasPrevious - Whether there is a page before the current page.
|
||||
* @param {boolean} hasNext - Whether there is a page after the current page.
|
||||
* @param {number} totalItems - The total number of items.
|
||||
* @param {string} searchTerm - The search term
|
||||
*/
|
||||
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems) {
|
||||
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
|
||||
const paginationContainer = document.querySelector(paginationSelector);
|
||||
const paginationCounter = document.querySelector(counterSelector);
|
||||
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
|
||||
|
@ -932,7 +933,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
|||
// Counter should only be displayed if there is more than 1 item
|
||||
paginationContainer.classList.toggle('display-none', totalItems < 1);
|
||||
|
||||
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}`;
|
||||
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + searchTerm : ''}`;
|
||||
|
||||
if (hasPrevious) {
|
||||
const prevPageItem = document.createElement('li');
|
||||
|
@ -1018,6 +1019,28 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
|||
}
|
||||
}
|
||||
|
||||
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
|
||||
const { unfiltered_total, total } = data;
|
||||
|
||||
const showElement = (element) => element.classList.remove('display-none');
|
||||
const hideElement = (element) => element.classList.add('display-none');
|
||||
|
||||
if (unfiltered_total) {
|
||||
if (total) {
|
||||
showElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
} else {
|
||||
hideElement(dataWrapper);
|
||||
showElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
}
|
||||
} else {
|
||||
hideElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
showElement(noDataWrapper);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
||||
|
@ -1025,13 +1048,19 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
|||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domainsWrapper = document.querySelector('.domains-wrapper');
|
||||
let domainsWrapper = document.querySelector('.domains__table-wrapper');
|
||||
|
||||
if (domainsWrapper) {
|
||||
let currentSortBy = 'id';
|
||||
let currentOrder = 'asc';
|
||||
let noDomainsWrapper = document.querySelector('.no-domains-wrapper');
|
||||
let noDomainsWrapper = document.querySelector('.domains__no-data');
|
||||
let noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
|
||||
let hasLoaded = false;
|
||||
let currentSearchTerm = ''
|
||||
let domainsSearchInput = document.getElementById('domains__search-field');
|
||||
let domainsSearchSubmit = document.getElementById('domains__search-field-submit');
|
||||
let tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
|
||||
let tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region')
|
||||
|
||||
/**
|
||||
* Loads rows in the domains list, as well as updates pagination around the domains list
|
||||
|
@ -1040,10 +1069,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @param {*} loaded - control for the scrollToElement functionality
|
||||
* @param {*} searchTerm - the search term
|
||||
*/
|
||||
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
|
||||
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
|
||||
//fetch json of page of domains, given page # and sort
|
||||
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
|
||||
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
|
@ -1051,17 +1081,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domains exist in the list
|
||||
if (data.domains.length) {
|
||||
domainsWrapper.classList.remove('display-none');
|
||||
noDomainsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainsWrapper.classList.add('display-none');
|
||||
noDomainsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results
|
||||
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper);
|
||||
|
||||
// identify the DOM element where the domain list will be inserted into the DOM
|
||||
const domainList = document.querySelector('.dotgov-table__registered-domains tbody');
|
||||
const domainList = document.querySelector('.domains__table tbody');
|
||||
domainList.innerHTML = '';
|
||||
|
||||
data.domains.forEach(domain => {
|
||||
|
@ -1122,7 +1146,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
|
@ -1133,7 +1158,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
|
||||
// Add event listeners to table headers for sorting
|
||||
document.querySelectorAll('.dotgov-table__registered-domains th[data-sortable]').forEach(header => {
|
||||
tableHeaders.forEach(header => {
|
||||
header.addEventListener('click', function() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
let order = 'asc';
|
||||
|
@ -1147,6 +1172,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
});
|
||||
|
||||
domainsSearchSubmit.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
currentSearchTerm = domainsSearchInput.value;
|
||||
loadDomains(1, 'id', 'asc');
|
||||
resetheaders();
|
||||
})
|
||||
|
||||
// Reset UI and accessibility
|
||||
function resetheaders() {
|
||||
tableHeaders.forEach(header => {
|
||||
// unset sort UI in headers
|
||||
window.table.unsetHeader(header);
|
||||
});
|
||||
// Reset the announcement region
|
||||
tableAnnouncementRegion.innerHTML = '';
|
||||
}
|
||||
|
||||
// Load the first page initially
|
||||
loadDomains(1);
|
||||
}
|
||||
|
@ -1157,10 +1199,13 @@ const utcDateString = (dateString) => {
|
|||
const utcYear = date.getUTCFullYear();
|
||||
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
||||
const utcDay = date.getUTCDate().toString().padStart(2, '0');
|
||||
const utcHours = date.getUTCHours().toString().padStart(2, '0');
|
||||
let utcHours = date.getUTCHours();
|
||||
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} UTC`;
|
||||
|
||||
const ampm = utcHours >= 12 ? 'PM' : 'AM';
|
||||
utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
|
||||
|
||||
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1169,13 +1214,19 @@ const utcDateString = (dateString) => {
|
|||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domainRequestsWrapper = document.querySelector('.domain-requests-wrapper');
|
||||
let domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
|
||||
|
||||
if (domainRequestsWrapper) {
|
||||
let currentSortBy = 'id';
|
||||
let currentOrder = 'asc';
|
||||
let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper');
|
||||
let noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
|
||||
let noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
|
||||
let hasLoaded = false;
|
||||
let currentSearchTerm = ''
|
||||
let domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
|
||||
let domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
|
||||
let tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
|
||||
let tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region')
|
||||
|
||||
/**
|
||||
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list
|
||||
|
@ -1184,10 +1235,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @param {*} loaded - control for the scrollToElement functionality
|
||||
* @param {*} searchTerm - the search term
|
||||
*/
|
||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
|
||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
|
||||
//fetch json of page of domain requests, given page # and sort
|
||||
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
|
||||
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
|
@ -1195,17 +1247,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domain requests exist in the list
|
||||
if (data.domain_requests.length) {
|
||||
domainRequestsWrapper.classList.remove('display-none');
|
||||
noDomainRequestsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainRequestsWrapper.classList.add('display-none');
|
||||
noDomainRequestsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
|
||||
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper);
|
||||
|
||||
// identify the DOM element where the domain request list will be inserted into the DOM
|
||||
const tbody = document.querySelector('.dotgov-table__domain-requests tbody');
|
||||
const tbody = document.querySelector('.domain-requests__table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// remove any existing modal elements from the DOM so they can be properly re-initialized
|
||||
|
@ -1272,7 +1318,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
|
@ -1281,7 +1328,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
// Add event listeners to table headers for sorting
|
||||
document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => {
|
||||
tableHeaders.forEach(header => {
|
||||
header.addEventListener('click', function() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
let order = 'asc';
|
||||
|
@ -1294,6 +1341,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
});
|
||||
|
||||
domainRequestsSearchSubmit.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
currentSearchTerm = domainRequestsSearchInput.value;
|
||||
loadDomainRequests(1, 'id', 'asc');
|
||||
resetheaders();
|
||||
})
|
||||
|
||||
// Reset UI and accessibility
|
||||
function resetheaders() {
|
||||
tableHeaders.forEach(header => {
|
||||
// unset sort UI in headers
|
||||
window.table.unsetHeader(header);
|
||||
});
|
||||
// Reset the announcement region
|
||||
tableAnnouncementRegion.innerHTML = '';
|
||||
}
|
||||
|
||||
// Load the first page initially
|
||||
loadDomainRequests(1);
|
||||
}
|
||||
|
|
|
@ -5709,9 +5709,15 @@ const table = behavior({
|
|||
},
|
||||
TABLE,
|
||||
SORTABLE_HEADER,
|
||||
SORT_BUTTON
|
||||
SORT_BUTTON,
|
||||
// DOTGOV: Export unsetSort
|
||||
unsetHeader(header) {
|
||||
unsetSort(header);
|
||||
}
|
||||
});
|
||||
module.exports = table;
|
||||
// DOTGOV: modified uswds.js to add table module to window so that it is accessible to other js
|
||||
window.table = table;
|
||||
|
||||
},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/sanitizer":50,"../../uswds-core/src/js/utils/select":53}],32:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
|
|
@ -98,7 +98,7 @@
|
|||
}
|
||||
}
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__domain-requests {
|
||||
.domain-requests__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
@ -122,7 +122,7 @@
|
|||
}
|
||||
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__registered-domains {
|
||||
.domains__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
|
|
@ -23,10 +23,35 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domains-header">Domains</h2>
|
||||
<div class="domains-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
|
||||
<section class="section--outlined domains">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domains-header flex-6">Domains</h2>
|
||||
</div>
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domains search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" role="search">
|
||||
<label class="usa-sr-only" for="domains__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domains__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search domains by name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domains__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -50,7 +75,7 @@
|
|||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="no-domains-wrapper display-none">
|
||||
<div class="domains__no-data display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<p class="maxw-none clearfix">
|
||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
||||
|
@ -61,6 +86,10 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>Nothing, nada, zilch.</p>
|
||||
<p>Reset your search or try a different search term.</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
|
@ -71,10 +100,35 @@
|
|||
</ul>
|
||||
</nav>
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domain-requests-header">Domain requests</h2>
|
||||
<div class="domain-requests-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
|
||||
<section class="section--outlined domain-requests">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header flex-6">Domain requests</h2>
|
||||
</div>
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" role="search">
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domain-requests__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search requests by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domain-requests__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -129,9 +183,13 @@
|
|||
{% endfor %}
|
||||
|
||||
</div>
|
||||
<div class="no-domain-requests-wrapper display-none">
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domain-requests__no-search-results display-none">
|
||||
<p>Nothing, nada, zilch.</p>
|
||||
<p>Reset your search or try a different search term.</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
|
|
|
@ -4,6 +4,7 @@ from registrar.models import DomainRequest
|
|||
from django.utils.dateformat import format
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,9 +15,18 @@ def get_domain_requests_json(request):
|
|||
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED
|
||||
)
|
||||
unfiltered_total = domain_requests.count()
|
||||
|
||||
# Handle sorting
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
search_term = request.GET.get("search_term")
|
||||
|
||||
if search_term:
|
||||
domain_requests = domain_requests.filter(
|
||||
Q(requested_domain__name__icontains=search_term)
|
||||
)
|
||||
|
||||
if order == "desc":
|
||||
sort_by = f"-{sort_by}"
|
||||
domain_requests = domain_requests.order_by(sort_by)
|
||||
|
@ -75,5 +85,6 @@ def get_domain_requests_json(request):
|
|||
"page": page_obj.number,
|
||||
"num_pages": paginator.num_pages,
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.core.paginator import Paginator
|
|||
from registrar.models import UserDomainRole, Domain
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,10 +15,17 @@ def get_domains_json(request):
|
|||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||
|
||||
objects = Domain.objects.filter(id__in=domain_ids)
|
||||
unfiltered_total = objects.count()
|
||||
|
||||
# Handle sorting
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
search_term = request.GET.get("search_term")
|
||||
|
||||
if search_term:
|
||||
objects = objects.filter(
|
||||
Q(name__icontains=search_term)
|
||||
)
|
||||
|
||||
if sort_by == "state_display":
|
||||
# Fetch the objects and sort them in Python
|
||||
|
@ -56,5 +64,6 @@ def get_domains_json(request):
|
|||
"has_previous": page_obj.has_previous(),
|
||||
"has_next": page_obj.has_next(),
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue