mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-31 23:16:36 +02:00
fixed javascript and other issues on domain request admin
This commit is contained in:
parent
fe1e64828b
commit
343d8dc962
3 changed files with 149 additions and 92 deletions
|
@ -2976,6 +2976,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||||
"federally_recognized_tribe",
|
"federally_recognized_tribe",
|
||||||
"state_recognized_tribe",
|
"state_recognized_tribe",
|
||||||
"about_your_organization",
|
"about_your_organization",
|
||||||
|
"rejection_reason",
|
||||||
|
"rejection_reason_email",
|
||||||
|
"action_needed_reason",
|
||||||
|
"action_needed_reason_email",
|
||||||
]
|
]
|
||||||
|
|
||||||
autocomplete_fields = [
|
autocomplete_fields = [
|
||||||
|
|
|
@ -106,7 +106,9 @@ export function initApprovedDomain() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusToCheck = "approved";
|
const statusToCheck = "approved";
|
||||||
|
const readonlyStatusToCheck = "Approved";
|
||||||
const statusSelect = document.getElementById("id_status");
|
const statusSelect = document.getElementById("id_status");
|
||||||
|
const statusField = document.querySelector("field-status");
|
||||||
const sessionVariableName = "showApprovedDomain";
|
const sessionVariableName = "showApprovedDomain";
|
||||||
let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
|
let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
|
||||||
|
|
||||||
|
@ -120,18 +122,30 @@ export function initApprovedDomain() {
|
||||||
|
|
||||||
// Handle showing/hiding the related fields on page load.
|
// Handle showing/hiding the related fields on page load.
|
||||||
function initializeFormGroups() {
|
function initializeFormGroups() {
|
||||||
let isStatus = statusSelect.value == statusToCheck;
|
let isStatus = false;
|
||||||
|
if (statusSelect) {
|
||||||
|
isStatus = statusSelect.value == statusToCheck;
|
||||||
|
} else {
|
||||||
|
// statusSelect does not exist, indicating readonly
|
||||||
|
if (statusField) {
|
||||||
|
let readonlyDiv = statusField.querySelector("div.readonly");
|
||||||
|
let readonlyStatusText = readonlyDiv.textContent.trim();
|
||||||
|
isStatus = readonlyStatusText == readonlyStatusToCheck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial handling of these groups.
|
// Initial handling of these groups.
|
||||||
updateFormGroupVisibility(isStatus);
|
updateFormGroupVisibility(isStatus);
|
||||||
|
|
||||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
if (statusSelect) {
|
||||||
statusSelect.addEventListener('change', () => {
|
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||||
// Show the approved if the status is what we expect.
|
statusSelect.addEventListener('change', () => {
|
||||||
isStatus = statusSelect.value == statusToCheck;
|
// Show the approved if the status is what we expect.
|
||||||
updateFormGroupVisibility(isStatus);
|
isStatus = statusSelect.value == statusToCheck;
|
||||||
addOrRemoveSessionBoolean(sessionVariableName, isStatus);
|
updateFormGroupVisibility(isStatus);
|
||||||
});
|
addOrRemoveSessionBoolean(sessionVariableName, isStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage
|
// Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage
|
||||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||||
|
@ -322,6 +336,7 @@ class CustomizableEmailBase {
|
||||||
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
||||||
* @property {string} apiUrl - The API URL for fetching email content.
|
* @property {string} apiUrl - The API URL for fetching email content.
|
||||||
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||||
|
* @property {string} readonlyStatusToCheck - The status to check against when readonly. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||||
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||||
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
||||||
*/
|
*/
|
||||||
|
@ -338,6 +353,7 @@ class CustomizableEmailBase {
|
||||||
this.textAreaFormGroup = config.textAreaFormGroup;
|
this.textAreaFormGroup = config.textAreaFormGroup;
|
||||||
this.dropdownFormGroup = config.dropdownFormGroup;
|
this.dropdownFormGroup = config.dropdownFormGroup;
|
||||||
this.statusToCheck = config.statusToCheck;
|
this.statusToCheck = config.statusToCheck;
|
||||||
|
this.readonlyStatusToCheck = config.readonlyStatusToCheck;
|
||||||
this.sessionVariableName = config.sessionVariableName;
|
this.sessionVariableName = config.sessionVariableName;
|
||||||
|
|
||||||
// Non-configurable variables
|
// Non-configurable variables
|
||||||
|
@ -363,19 +379,31 @@ class CustomizableEmailBase {
|
||||||
|
|
||||||
// Handle showing/hiding the related fields on page load.
|
// Handle showing/hiding the related fields on page load.
|
||||||
initializeFormGroups() {
|
initializeFormGroups() {
|
||||||
let isStatus = this.statusSelect.value == this.statusToCheck;
|
let isStatus = false;
|
||||||
|
if (this.statusSelect) {
|
||||||
|
this.statusSelect.value == this.statusToCheck;
|
||||||
|
} else {
|
||||||
|
// statusSelect does not exist, indicating readonly
|
||||||
|
if (this.dropdownFormGroup) {
|
||||||
|
let readonlyDiv = this.dropdownFormGroup.querySelector("div.readonly");
|
||||||
|
let readonlyStatusText = readonlyDiv.textContent.trim();
|
||||||
|
isStatus = readonlyStatusText == this.readonlyStatusToCheck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial handling of these groups.
|
// Initial handling of these groups.
|
||||||
this.updateFormGroupVisibility(isStatus);
|
this.updateFormGroupVisibility(isStatus);
|
||||||
|
|
||||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
if (this.statusSelect) {
|
||||||
this.statusSelect.addEventListener('change', () => {
|
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||||
// Show the action needed field if the status is what we expect.
|
this.statusSelect.addEventListener('change', () => {
|
||||||
// Then track if its shown or hidden in our session cache.
|
// Show the action needed field if the status is what we expect.
|
||||||
isStatus = this.statusSelect.value == this.statusToCheck;
|
// Then track if its shown or hidden in our session cache.
|
||||||
this.updateFormGroupVisibility(isStatus);
|
isStatus = this.statusSelect.value == this.statusToCheck;
|
||||||
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
this.updateFormGroupVisibility(isStatus);
|
||||||
});
|
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||||
|
@ -403,58 +431,64 @@ class CustomizableEmailBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeDropdown() {
|
initializeDropdown() {
|
||||||
this.dropdown.addEventListener("change", () => {
|
if (this.dropdown) {
|
||||||
let reason = this.dropdown.value;
|
this.dropdown.addEventListener("change", () => {
|
||||||
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
let reason = this.dropdown.value;
|
||||||
let searchParams = new URLSearchParams(
|
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
||||||
{
|
let searchParams = new URLSearchParams(
|
||||||
"reason": reason,
|
{
|
||||||
"domain_request_id": this.domainRequestId,
|
"reason": reason,
|
||||||
}
|
"domain_request_id": this.domainRequestId,
|
||||||
);
|
}
|
||||||
// Replace the email content
|
);
|
||||||
fetch(`${this.apiUrl}?${searchParams.toString()}`)
|
// Replace the email content
|
||||||
.then(response => {
|
fetch(`${this.apiUrl}?${searchParams.toString()}`)
|
||||||
return response.json().then(data => data);
|
.then(response => {
|
||||||
})
|
return response.json().then(data => data);
|
||||||
.then(data => {
|
})
|
||||||
if (data.error) {
|
.then(data => {
|
||||||
console.error("Error in AJAX call: " + data.error);
|
if (data.error) {
|
||||||
}else {
|
console.error("Error in AJAX call: " + data.error);
|
||||||
this.textarea.value = data.email;
|
}else {
|
||||||
}
|
this.textarea.value = data.email;
|
||||||
this.updateUserInterface(reason);
|
}
|
||||||
})
|
this.updateUserInterface(reason);
|
||||||
.catch(error => {
|
})
|
||||||
console.error(this.apiErrorMessage, error)
|
.catch(error => {
|
||||||
});
|
console.error(this.apiErrorMessage, error)
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeModalConfirm() {
|
initializeModalConfirm() {
|
||||||
this.modalConfirm.addEventListener("click", () => {
|
if (this.modalConfirm) {
|
||||||
this.textarea.removeAttribute('readonly');
|
this.modalConfirm.addEventListener("click", () => {
|
||||||
this.textarea.focus();
|
this.textarea.removeAttribute('readonly');
|
||||||
hideElement(this.directEditButton);
|
this.textarea.focus();
|
||||||
hideElement(this.modalTrigger);
|
hideElement(this.directEditButton);
|
||||||
});
|
hideElement(this.modalTrigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeDirectEditButton() {
|
initializeDirectEditButton() {
|
||||||
this.directEditButton.addEventListener("click", () => {
|
if (this.directEditButton) {
|
||||||
this.textarea.removeAttribute('readonly');
|
this.directEditButton.addEventListener("click", () => {
|
||||||
this.textarea.focus();
|
this.textarea.removeAttribute('readonly');
|
||||||
hideElement(this.directEditButton);
|
this.textarea.focus();
|
||||||
hideElement(this.modalTrigger);
|
hideElement(this.directEditButton);
|
||||||
});
|
hideElement(this.modalTrigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isEmailAlreadySent() {
|
isEmailAlreadySent() {
|
||||||
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
|
updateUserInterface(reason, excluded_reasons=["other"]) {
|
||||||
if (!reason) {
|
if (!reason) {
|
||||||
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
||||||
this.showPlaceholderNoReason();
|
this.showPlaceholderNoReason();
|
||||||
|
@ -468,23 +502,25 @@ class CustomizableEmailBase {
|
||||||
|
|
||||||
// Helper function that makes overriding the readonly textarea easy
|
// Helper function that makes overriding the readonly textarea easy
|
||||||
showReadonlyTextarea() {
|
showReadonlyTextarea() {
|
||||||
// A triggering selection is selected, all hands on board:
|
if (this.textarea && this.textareaPlaceholder) {
|
||||||
this.textarea.setAttribute('readonly', true);
|
// A triggering selection is selected, all hands on board:
|
||||||
showElement(this.textarea);
|
this.textarea.setAttribute('readonly', true);
|
||||||
hideElement(this.textareaPlaceholder);
|
showElement(this.textarea);
|
||||||
|
hideElement(this.textareaPlaceholder);
|
||||||
|
|
||||||
if (this.isEmailAlreadySentConst) {
|
if (this.isEmailAlreadySentConst) {
|
||||||
hideElement(this.directEditButton);
|
hideElement(this.directEditButton);
|
||||||
showElement(this.modalTrigger);
|
showElement(this.modalTrigger);
|
||||||
|
} else {
|
||||||
|
showElement(this.directEditButton);
|
||||||
|
hideElement(this.modalTrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isEmailAlreadySent()) {
|
||||||
|
this.formLabel.innerHTML = "Email sent to creator:";
|
||||||
} else {
|
} else {
|
||||||
showElement(this.directEditButton);
|
this.formLabel.innerHTML = "Email:";
|
||||||
hideElement(this.modalTrigger);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isEmailAlreadySent()) {
|
|
||||||
this.formLabel.innerHTML = "Email sent to creator:";
|
|
||||||
} else {
|
|
||||||
this.formLabel.innerHTML = "Email:";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -516,9 +552,10 @@ class customActionNeededEmail extends CustomizableEmailBase {
|
||||||
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
|
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
|
||||||
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
|
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
|
||||||
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
|
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
|
||||||
textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
|
textAreaFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
||||||
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
dropdownFormGroup: document.querySelector('.field-action_needed_reason'),
|
||||||
statusToCheck: "action needed",
|
statusToCheck: "action needed",
|
||||||
|
readonlyStatusToCheck: "Action needed",
|
||||||
sessionVariableName: "showActionNeededReason",
|
sessionVariableName: "showActionNeededReason",
|
||||||
apiErrorMessage: "Error when attempting to grab action needed email: "
|
apiErrorMessage: "Error when attempting to grab action needed email: "
|
||||||
}
|
}
|
||||||
|
@ -529,7 +566,15 @@ class customActionNeededEmail extends CustomizableEmailBase {
|
||||||
// Hide/show the email fields depending on the current status
|
// Hide/show the email fields depending on the current status
|
||||||
this.initializeFormGroups();
|
this.initializeFormGroups();
|
||||||
// Setup the textarea, edit button, helper text
|
// Setup the textarea, edit button, helper text
|
||||||
this.updateUserInterface();
|
let reason = null;
|
||||||
|
if (this.dropdown) {
|
||||||
|
reason = this.dropdown.value;
|
||||||
|
} else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) {
|
||||||
|
if (this.dropdownFormGroup.querySelector("div.readonly").textContent) {
|
||||||
|
reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateUserInterface(reason);
|
||||||
this.initializeDropdown();
|
this.initializeDropdown();
|
||||||
this.initializeModalConfirm();
|
this.initializeModalConfirm();
|
||||||
this.initializeDirectEditButton();
|
this.initializeDirectEditButton();
|
||||||
|
@ -560,12 +605,12 @@ export function initActionNeededEmail() {
|
||||||
// Initialize UI
|
// Initialize UI
|
||||||
const customEmail = new customActionNeededEmail();
|
const customEmail = new customActionNeededEmail();
|
||||||
|
|
||||||
// Check that every variable was setup correctly
|
// // Check that every variable was setup correctly
|
||||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
// const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||||
if (nullItems.length > 0) {
|
// if (nullItems.length > 0) {
|
||||||
console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
// console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
customEmail.loadActionNeededEmail()
|
customEmail.loadActionNeededEmail()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -581,6 +626,7 @@ class customRejectedEmail extends CustomizableEmailBase {
|
||||||
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
|
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
|
||||||
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
|
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
|
||||||
statusToCheck: "rejected",
|
statusToCheck: "rejected",
|
||||||
|
readonlyStatusToCheck: "Rejected",
|
||||||
sessionVariableName: "showRejectionReason",
|
sessionVariableName: "showRejectionReason",
|
||||||
errorMessage: "Error when attempting to grab rejected email: "
|
errorMessage: "Error when attempting to grab rejected email: "
|
||||||
};
|
};
|
||||||
|
@ -589,7 +635,15 @@ class customRejectedEmail extends CustomizableEmailBase {
|
||||||
|
|
||||||
loadRejectedEmail() {
|
loadRejectedEmail() {
|
||||||
this.initializeFormGroups();
|
this.initializeFormGroups();
|
||||||
this.updateUserInterface();
|
let reason = null;
|
||||||
|
if (this.dropdown) {
|
||||||
|
reason = this.dropdown.value;
|
||||||
|
} else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) {
|
||||||
|
if (this.dropdownFormGroup.querySelector("div.readonly").textContent) {
|
||||||
|
reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateUserInterface(reason);
|
||||||
this.initializeDropdown();
|
this.initializeDropdown();
|
||||||
this.initializeModalConfirm();
|
this.initializeModalConfirm();
|
||||||
this.initializeDirectEditButton();
|
this.initializeDirectEditButton();
|
||||||
|
@ -600,7 +654,7 @@ class customRejectedEmail extends CustomizableEmailBase {
|
||||||
this.showPlaceholder("Email:", "Select a rejection reason to see email");
|
this.showPlaceholder("Email:", "Select a rejection reason to see email");
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
|
updateUserInterface(reason, excluded_reasons=[]) {
|
||||||
super.updateUserInterface(reason, excluded_reasons);
|
super.updateUserInterface(reason, excluded_reasons);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -619,12 +673,12 @@ export function initRejectedEmail() {
|
||||||
|
|
||||||
// Initialize UI
|
// Initialize UI
|
||||||
const customEmail = new customRejectedEmail();
|
const customEmail = new customRejectedEmail();
|
||||||
// Check that every variable was setup correctly
|
// // Check that every variable was setup correctly
|
||||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
// const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||||
if (nullItems.length > 0) {
|
// if (nullItems.length > 0) {
|
||||||
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
// console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
customEmail.loadRejectedEmail()
|
customEmail.loadRejectedEmail()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -648,7 +702,6 @@ function handleSuborgFieldsAndButtons() {
|
||||||
|
|
||||||
// Ensure that every variable is present before proceeding
|
// Ensure that every variable is present before proceeding
|
||||||
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
|
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
|
||||||
console.warn("handleSuborganizationSelection() => Could not find required fields.")
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -404,7 +404,7 @@ export function handlePortfolioSelection(
|
||||||
updateSubOrganizationDropdown(portfolio_id);
|
updateSubOrganizationDropdown(portfolio_id);
|
||||||
|
|
||||||
// Show fields relevant to a selected portfolio
|
// Show fields relevant to a selected portfolio
|
||||||
showElement(suborganizationField);
|
if (suborganizationField) showElement(suborganizationField);
|
||||||
hideElement(seniorOfficialField);
|
hideElement(seniorOfficialField);
|
||||||
showElement(portfolioSeniorOfficialField);
|
showElement(portfolioSeniorOfficialField);
|
||||||
|
|
||||||
|
@ -427,7 +427,7 @@ export function handlePortfolioSelection(
|
||||||
// No portfolio is selected - reverse visibility of fields
|
// No portfolio is selected - reverse visibility of fields
|
||||||
|
|
||||||
// Hide suborganization field as no portfolio is selected
|
// Hide suborganization field as no portfolio is selected
|
||||||
hideElement(suborganizationField);
|
if (suborganizationField) hideElement(suborganizationField);
|
||||||
|
|
||||||
// Show fields that are relevant when no portfolio is selected
|
// Show fields that are relevant when no portfolio is selected
|
||||||
showElement(seniorOfficialField);
|
showElement(seniorOfficialField);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue