Merge branch 'main' into za/3103-prevent-errors-member-management

This commit is contained in:
zandercymatics 2024-11-29 08:07:40 -07:00
commit e43b91b2c6
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
66 changed files with 8100 additions and 5142 deletions

View file

@ -7,8 +7,8 @@ name: Clone Stable Database
on:
schedule:
# Run daily at 2:00 PM EST
- cron: '0 * * * *'
# Run daily at 12:00 AM EST
- cron: '0 0 * * *'
# Allow manual triggering
workflow_dispatch:

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
docs/research/data/**
**/assets/*
!**/assets/src/
!**/assets/sass/
!**/assets/img/registrar/
public/

View file

@ -1,37 +1,123 @@
/* gulpfile.js */
// We need a hook into gulp for the JS jobs definitions
const gulp = require('gulp');
// For bundling
const webpack = require('webpack-stream');
// Out-of-the-box uswds compiler
const uswds = require('@uswds/compile');
// For minimizing and optimizing
const TerserPlugin = require('terser-webpack-plugin');
const ASSETS_DIR = './registrar/assets/';
const JS_BUNDLE_DEST = ASSETS_DIR + 'js';
const JS_SOURCES = [
{ src: ASSETS_DIR + 'src/js/getgov/*.js', output: 'getgov.min.js' },
{ src: ASSETS_DIR + 'src/js/getgov-admin/*.js', output: 'getgov-admin.min.js' },
];
/**
* USWDS version
* Set the version of USWDS you're using (2 or 3)
*/
uswds.settings.version = 3;
/**
* Path settings
* Set as many as you need
*/
const ASSETS_DIR = './registrar/assets/';
uswds.paths.dist.css = ASSETS_DIR + 'css';
uswds.paths.dist.sass = ASSETS_DIR + 'sass';
uswds.paths.dist.theme = ASSETS_DIR + 'sass/_theme';
uswds.paths.dist.theme = ASSETS_DIR + 'src/sass/_theme';
uswds.paths.dist.fonts = ASSETS_DIR + 'fonts';
uswds.paths.dist.js = ASSETS_DIR + 'js';
uswds.paths.dist.img = ASSETS_DIR + 'img';
/**
* Function: Create Bundling Task
*
* This function generates a Gulp task for bundling JavaScript files. It accepts a source file pattern
* and an output filename, then processes the files using Webpack for tasks like transpilation, bundling,
* and optimization. The resulting task performs the following:
*
* 1. Reads the JavaScript source files specified by the `source` parameter.
* 2. Transforms the JavaScript using Webpack:
* - Runs in "production" mode by default for optimizations (use "development" mode for easier debugging).
* - Generates a source map for better debugging experience, linking the output to the original source.
* - Minifies the code using TerserPlugin while suppressing the generation of `.LICENSE.txt` files.
* - Processes the JavaScript with Babel to ensure compatibility with older browsers by using the `@babel/preset-env`.
* 3. Outputs the bundled and optimized JavaScript to the specified destination folder.
*
* Parameters:
* - `source`: A glob pattern or file path specifying the input JavaScript files.
* - `output`: The filename for the generated JavaScript bundle.
*
* Returns:
* - A function that can be executed as a Gulp task.
*/
function createBundleTask(source, output) {
return () =>
gulp
.src(source)
.pipe(
webpack({
mode: 'production', // Use 'development' if you want less minification during debugging
devtool: 'source-map', // Enable source map generation
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
extractComments: false, // Prevents generating .LICENSE.txt
}),
],
},
output: { filename: output },
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: { presets: ['@babel/preset-env'] },
},
},
],
},
})
)
.pipe(gulp.dest(JS_BUNDLE_DEST));
}
// Create tasks for JavaScript sources
JS_SOURCES.forEach(({ src, output }, index) => {
const taskName = `bundle-js-${index}`;
gulp.task(taskName, createBundleTask(src, output));
});
/**
* Watch for changes in JavaScript modules
*/
gulp.task('watch-js', () => {
JS_SOURCES.forEach(({ src }, index) => {
gulp.watch(src, gulp.series(`bundle-js-${index}`));
});
});
/**
* Combine all watch tasks
*/
gulp.task('watch', gulp.parallel('watch-js', uswds.watch));
/**
* Exports
* Add as many as you need
* Some tasks combine USWDS compilation and JavaScript precompilation.
*/
exports.default = uswds.compile;
exports.default = gulp.series(uswds.compile, ...JS_SOURCES.map((_, i) => `bundle-js-${i}`));
exports.init = uswds.init;
exports.compile = uswds.compile;
exports.compile = gulp.series(uswds.compile, ...JS_SOURCES.map((_, i) => `bundle-js-${i}`));
exports.watch = uswds.watch;
exports.watchAll = gulp.parallel('watch');
exports.copyAssets = uswds.copyAssets
exports.updateUswds = uswds.updateUswds

3700
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,11 +10,17 @@
"author": "",
"license": "ISC",
"dependencies": {
"@uswds/uswds": "^3.8.1",
"@uswds/uswds": "3.8.1",
"pa11y-ci": "^3.0.1",
"sass": "^1.54.8"
},
"devDependencies": {
"@uswds/compile": "^1.0.0-beta.3"
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@uswds/compile": "1.1.0",
"babel-loader": "^9.2.1",
"sass-loader": "^12.6.0",
"webpack": "^5.96.1",
"webpack-stream": "^7.0.0"
}
}

View file

@ -1570,7 +1570,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"is_policy_acknowledged",
]
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
# For each filter_horizontal, init in admin js initFilterHorizontalWidget
# to activate the edit/delete/view buttons
filter_horizontal = ("other_contacts",)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
// Copy input value to clipboard
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
// Change the icon to a checkmark on successful copy
let buttonIcon = button.querySelector('.copy-to-clipboard use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
let nearestSpan = button.querySelector("span")
let original_text = nearestSpan.innerText
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
nearestSpan.innerText = original_text;
}, 2000);
}
}).catch(function(error) {
console.error('Clipboard copy failed', error);
});
}
}
/**
* A function for pages in DjangoAdmin that use a clipboard button
*/
export function initCopyToClipboard() {
let clipboardButtons = document.querySelectorAll(".copy-to-clipboard")
clipboardButtons.forEach((button) => {
// Handle copying the text to your clipboard,
// and changing the icon.
button.addEventListener("click", ()=>{
copyToClipboardAndChangeIcon(button);
});
// Add a class that adds the outline style on click
button.addEventListener("mousedown", function() {
this.classList.add("no-outline-on-click");
});
// But add it back in after the user clicked,
// for accessibility reasons (so we can still tab, etc)
button.addEventListener("blur", function() {
this.classList.remove("no-outline-on-click");
});
});
}

View file

@ -0,0 +1,30 @@
/**
* A function that appends target="_blank" to the domain_form buttons
*/
/** Either sets attribute target="_blank" to a given element, or removes it */
function openInNewTab(el, removeAttribute = false){
if(removeAttribute){
el.setAttribute("target", "_blank");
}else{
el.removeAttribute("target", "_blank");
}
};
/**
On mouseover, appends target="_blank" on domain_form under the Domain page.
The reason for this is that the template has a form that contains multiple buttons.
The structure of that template complicates seperating those buttons
out of the form (while maintaining the same position on the page).
However, if we want to open one of those submit actions to a new tab -
such as the manage domain button - we need to dynamically append target.
As there is no built-in django method which handles this, we do it here.
*/
export function initDomainFormTargetBlankButtons() {
let domainFormElement = document.getElementById("domain_form");
let domainSubmitButton = document.getElementById("manageDomainSubmitButton");
if(domainSubmitButton && domainFormElement){
domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
}
}

View file

@ -0,0 +1,17 @@
import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.js';
/**
* A function for dynamic DomainInformation fields
*/
export function initDynamicDomainInformationFields(){
const domainInformationPage = document.getElementById("domaininformation_form");
if (domainInformationPage) {
handleSuborganizationFields();
}
// DomainInformation is embedded inside domain so this should fire there too
const domainPage = document.getElementById("domain_form");
if (domainPage) {
handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization");
}
}

View file

@ -0,0 +1,640 @@
import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js';
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
// If these exist all at the same time, we're on the right page
if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){
// Set the previous value in the event the user cancels.
let previousValue = statusDropdown.value;
if (actionButton){
// Otherwise, if the confirmation buttion is pressed, set it to that
actionButton.addEventListener('click', function() {
// Revert the dropdown to its previous value
statusDropdown.value = valueToCheck;
});
}else {
console.log("displayModalOnDropdownClick() -> Cancel button was null");
}
// Add a change event listener to the dropdown.
statusDropdown.addEventListener('change', function() {
// Check if "Ineligible" is selected
if (this.value && this.value.toLowerCase() === valueToCheck) {
// Set the old value in the event the user cancels,
// or otherwise exists the dropdown.
statusDropdown.value = previousValue;
// Display the modal.
linkClickedDisplaysModal.click();
}
});
}
}
/**
* A function for DomainRequest to hook a modal to a dropdown option.
* This intentionally does not interact with createPhantomModalFormButtons()
* When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown.
*/
export function initIneligibleModal(){
// Grab the invisible element that will hook to the modal.
// This doesn't technically need to be done with one, but this is simpler to manage.
let modalButton = document.getElementById("invisible-ineligible-modal-toggler");
let statusDropdown = document.getElementById("id_status");
// Because the modal button does not have the class "dja-form-placeholder",
// it will not be affected by the createPhantomModalFormButtons() function.
let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]');
let valueToCheck = "ineligible";
displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck);
}
/**
* A function for the "Assign to me" button under the investigator field in DomainRequests.
* This field uses the "select2" selector, rather than the default.
* To perform data operations on this - we need to use jQuery rather than vanilla js.
*/
export function initAssignToMe() {
if (document.getElementById("id_investigator") && django && django.jQuery) {
let selector = django.jQuery("#id_investigator");
let assignSelfButton = document.querySelector("#investigator__assign_self");
if (!selector || !assignSelfButton) {
return;
}
let currentUserId = assignSelfButton.getAttribute("data-user-id");
let currentUserName = assignSelfButton.getAttribute("data-user-name");
if (!currentUserId || !currentUserName){
console.error("Could not assign current user: no values found.");
return;
}
// Hook a click listener to the "Assign to me" button.
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
assignSelfButton.addEventListener("click", function() {
if (selector.find(`option[value='${currentUserId}']`).length) {
// Select the value that is associated with the current user.
selector.val(currentUserId).trigger("change");
} else {
// Create a DOM Option that matches the desired user. Then append it and select it.
let userOption = new Option(currentUserName, currentUserId, true, true);
selector.append(userOption).trigger("change");
}
});
// Listen to any change events, and hide the parent container if investigator has a value.
selector.on('change', function() {
// The parent container has display type flex.
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
});
}
}
/**
* A function that hides and shows approved domain select2 row in domain request
* conditionally based on the Status field selection. If Approved, show. If not Approved,
* don't show.
*/
export function initApprovedDomain() {
document.addEventListener('DOMContentLoaded', function() {
const domainRequestForm = document.getElementById("domainrequest_form");
if (!domainRequestForm) {
return;
}
const statusToCheck = "approved";
const statusSelect = document.getElementById("id_status");
const sessionVariableName = "showApprovedDomain";
let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
function updateFormGroupVisibility(showFormGroups) {
if (showFormGroups) {
showElement(approvedDomainFormGroup);
} else {
hideElement(approvedDomainFormGroup);
}
}
// Handle showing/hiding the related fields on page load.
function initializeFormGroups() {
let isStatus = statusSelect.value == statusToCheck;
// Initial handling of these groups.
updateFormGroupVisibility(isStatus);
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', () => {
// Show the approved if the status is what we expect.
isStatus = statusSelect.value == statusToCheck;
updateFormGroupVisibility(isStatus);
addOrRemoveSessionBoolean(sessionVariableName, isStatus);
});
// 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
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
// accurately for this edge case, we use cache and test for the back/forward navigation.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null;
updateFormGroupVisibility(showTextAreaFormGroup);
}
});
});
observer.observe({ type: "navigation" });
}
initializeFormGroups();
});
}
/**
* A function for copy summary button
*/
export function initCopyRequestSummary() {
const copyButton = document.getElementById('id-copy-to-clipboard-summary');
if (copyButton) {
copyButton.addEventListener('click', function() {
/// Generate a rich HTML summary text and copy to clipboard
//------ Organization Type
const organizationTypeElement = document.getElementById('id_organization_type');
const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text;
//------ Alternative Domains
const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly');
const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a');
const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent);
//------ Existing Websites
const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly');
const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a');
const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent);
//------ Additional Contacts
// 1 - Create a hyperlinks map so we can display contact details and also link to the contact
const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly');
let otherContactLinks = [];
const nameToUrlMap = {};
if (otherContactsDiv) {
otherContactLinks = otherContactsDiv.querySelectorAll('a');
otherContactLinks.forEach(link => {
const name = link.textContent.trim();
const url = link.href;
nameToUrlMap[name] = url;
});
}
// 2 - Iterate through contact details and assemble html for summary
let otherContactsSummary = ""
const bulletList = document.createElement('ul');
// CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts)
const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd');
if (contacts) {
contacts.forEach(contact => {
// Check if the <dl> element is not empty
const name = contact.querySelector('a.contact_info_name')?.innerText;
const title = contact.querySelector('span.contact_info_title')?.innerText;
const email = contact.querySelector('span.contact_info_email')?.innerText;
const phone = contact.querySelector('span.contact_info_phone')?.innerText;
const url = nameToUrlMap[name] || '#';
// Format the contact information
const listItem = document.createElement('li');
listItem.innerHTML = `<a href="${url}">${name}</a>, ${title}, ${email}, ${phone}`;
bulletList.appendChild(listItem);
});
}
// CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts)
const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody');
if (otherContactsTable) {
const otherContactsRows = otherContactsTable.querySelectorAll('tr');
otherContactsRows.forEach(contactRow => {
// Extract the contact details
const name = contactRow.querySelector('th').textContent.trim();
const title = contactRow.querySelectorAll('td')[0].textContent.trim();
const email = contactRow.querySelectorAll('td')[1].textContent.trim();
const phone = contactRow.querySelectorAll('td')[2].textContent.trim();
const url = nameToUrlMap[name] || '#';
// Format the contact information
const listItem = document.createElement('li');
listItem.innerHTML = `<a href="${url}">${name}</a>, ${title}, ${email}, ${phone}`;
bulletList.appendChild(listItem);
});
}
otherContactsSummary += bulletList.outerHTML;
//------ Requested Domains
const requestedDomainElement = document.getElementById('id_requested_domain');
// We have to account for different superuser and analyst markups
const requestedDomain = requestedDomainElement.options
? requestedDomainElement.options[requestedDomainElement.selectedIndex].text
: requestedDomainElement.text;
//------ Submitter
// Function to extract text by ID and handle missing elements
function extractTextById(id, divElement) {
if (divElement) {
const element = divElement.querySelector(`#${id}`);
return element ? ", " + element.textContent.trim() : '';
}
return '';
}
//------ Senior Official
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
const seniorOfficialElement = document.getElementById('id_senior_official');
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title');
const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email');
const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone');
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
const html_summary = `<strong>Recommendation:</strong></br>` +
`<strong>Organization Type:</strong> ${organizationType}</br>` +
`<strong>Requested Domain:</strong> ${requestedDomain}</br>` +
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
`<strong>Rationale:</strong></br>` +
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;
//Replace </br> with \n, then strip out all remaining html tags (replace <...> with '')
const plain_summary = html_summary.replace(/<\/br>|<br>/g, '\n').replace(/<\/?[^>]+(>|$)/g, '');
// Create Blobs with the summary content
const html_blob = new Blob([html_summary], { type: 'text/html' });
const plain_blob = new Blob([plain_summary], { type: 'text/plain' });
// Create a ClipboardItem with the Blobs
const clipboardItem = new ClipboardItem({
'text/html': html_blob,
'text/plain': plain_blob
});
// Write the ClipboardItem to the clipboard
navigator.clipboard.write([clipboardItem]).then(() => {
// Change the icon to a checkmark on successful copy
let buttonIcon = copyButton.querySelector('use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
let nearestSpan = copyButton.querySelector("span");
let original_text = nearestSpan.innerText;
nearestSpan.innerText = "Copied to clipboard";
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
nearestSpan.innerText = original_text;
}, 2000);
}
console.log('Summary copied to clipboard successfully!');
}).catch(err => {
console.error('Failed to copy text: ', err);
});
});
}
}
class CustomizableEmailBase {
/**
* @param {Object} config - must contain the following:
* @property {HTMLElement} dropdown - The dropdown element.
* @property {HTMLElement} textarea - The textarea element.
* @property {HTMLElement} lastSentEmailContent - The last sent email content element.
* @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
* @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
* @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} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
* @property {string} apiErrorMessage - The error message that the ajax call returns.
*/
constructor(config) {
this.config = config;
this.dropdown = config.dropdown;
this.textarea = config.textarea;
this.lastSentEmailContent = config.lastSentEmailContent;
this.apiUrl = config.apiUrl;
this.apiErrorMessage = config.apiErrorMessage;
this.modalConfirm = config.modalConfirm;
// These fields are hidden/shown on pageload depending on the current status
this.textAreaFormGroup = config.textAreaFormGroup;
this.dropdownFormGroup = config.dropdownFormGroup;
this.statusToCheck = config.statusToCheck;
this.sessionVariableName = config.sessionVariableName;
// Non-configurable variables
this.statusSelect = document.getElementById("id_status");
this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null
this.initialDropdownValue = this.dropdown ? this.dropdown.value : null;
this.initialEmailValue = this.textarea ? this.textarea.value : null;
// Find other fields near the textarea
const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null;
this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null;
this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null;
this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null;
this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null;
this.isEmailAlreadySentConst;
if (this.lastSentEmailContent && this.textarea) {
this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
}
}
// Handle showing/hiding the related fields on page load.
initializeFormGroups() {
let isStatus = this.statusSelect.value == this.statusToCheck;
// Initial handling of these groups.
this.updateFormGroupVisibility(isStatus);
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
this.statusSelect.addEventListener('change', () => {
// Show the action needed field if the status is what we expect.
// Then track if its shown or hidden in our session cache.
isStatus = this.statusSelect.value == this.statusToCheck;
this.updateFormGroupVisibility(isStatus);
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
});
// 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
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
// accurately for this edge case, we use cache and test for the back/forward navigation.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null;
this.updateFormGroupVisibility(showTextAreaFormGroup);
}
});
});
observer.observe({ type: "navigation" });
}
updateFormGroupVisibility(showFormGroups) {
if (showFormGroups) {
showElement(this.textAreaFormGroup);
showElement(this.dropdownFormGroup);
}else {
hideElement(this.textAreaFormGroup);
hideElement(this.dropdownFormGroup);
}
}
initializeDropdown() {
this.dropdown.addEventListener("change", () => {
let reason = this.dropdown.value;
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
let searchParams = new URLSearchParams(
{
"reason": reason,
"domain_request_id": this.domainRequestId,
}
);
// Replace the email content
fetch(`${this.apiUrl}?${searchParams.toString()}`)
.then(response => {
return response.json().then(data => data);
})
.then(data => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
}else {
this.textarea.value = data.email;
}
this.updateUserInterface(reason);
})
.catch(error => {
console.error(this.apiErrorMessage, error)
});
}
});
}
initializeModalConfirm() {
this.modalConfirm.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton);
hideElement(this.modalTrigger);
});
}
initializeDirectEditButton() {
this.directEditButton.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton);
hideElement(this.modalTrigger);
});
}
isEmailAlreadySent() {
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
}
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
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
this.showPlaceholderNoReason();
} else if (excluded_reasons.includes(reason)) {
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
this.showPlaceholderOtherReason();
} else {
this.showReadonlyTextarea();
}
}
// Helper function that makes overriding the readonly textarea easy
showReadonlyTextarea() {
// A triggering selection is selected, all hands on board:
this.textarea.setAttribute('readonly', true);
showElement(this.textarea);
hideElement(this.textareaPlaceholder);
if (this.isEmailAlreadySentConst) {
hideElement(this.directEditButton);
showElement(this.modalTrigger);
} else {
showElement(this.directEditButton);
hideElement(this.modalTrigger);
}
if (this.isEmailAlreadySent()) {
this.formLabel.innerHTML = "Email sent to creator:";
} else {
this.formLabel.innerHTML = "Email:";
}
}
// Helper function that makes overriding the placeholder reason easy
showPlaceholderNoReason() {
this.showPlaceholder("Email:", "Select a reason to see email");
}
// Helper function that makes overriding the placeholder reason easy
showPlaceholderOtherReason() {
this.showPlaceholder("Email:", "No email will be sent");
}
showPlaceholder(formLabelText, placeholderText) {
this.formLabel.innerHTML = formLabelText;
this.textareaPlaceholder.innerHTML = placeholderText;
showElement(this.textareaPlaceholder);
hideElement(this.directEditButton);
hideElement(this.modalTrigger);
hideElement(this.textarea);
}
}
class customActionNeededEmail extends CustomizableEmailBase {
constructor() {
const emailConfig = {
dropdown: document.getElementById("id_action_needed_reason"),
textarea: document.getElementById("id_action_needed_reason_email"),
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
statusToCheck: "action needed",
sessionVariableName: "showActionNeededReason",
apiErrorMessage: "Error when attempting to grab action needed email: "
}
super(emailConfig);
}
loadActionNeededEmail() {
// Hide/show the email fields depending on the current status
this.initializeFormGroups();
// Setup the textarea, edit button, helper text
this.updateUserInterface();
this.initializeDropdown();
this.initializeModalConfirm();
this.initializeDirectEditButton();
}
// Overrides the placeholder text when no reason is selected
showPlaceholderNoReason() {
this.showPlaceholder("Email:", "Select an action needed reason to see email");
}
// Overrides the placeholder text when the reason other is selected
showPlaceholderOtherReason() {
this.showPlaceholder("Email:", "No email will be sent");
}
}
/**
* A function that hooks to the show/hide button underneath action needed reason.
* This shows the auto generated email on action needed reason.
*/
export function initActionNeededEmail() {
document.addEventListener('DOMContentLoaded', function() {
const domainRequestForm = document.getElementById("domainrequest_form");
if (!domainRequestForm) {
return;
}
// Initialize UI
const customEmail = new customActionNeededEmail();
// Check that every variable was setup correctly
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
if (nullItems.length > 0) {
console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
return;
}
customEmail.loadActionNeededEmail()
});
}
class customRejectedEmail extends CustomizableEmailBase {
constructor() {
const emailConfig = {
dropdown: document.getElementById("id_rejection_reason"),
textarea: document.getElementById("id_rejection_reason_email"),
lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"),
modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"),
apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null,
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
statusToCheck: "rejected",
sessionVariableName: "showRejectionReason",
errorMessage: "Error when attempting to grab rejected email: "
};
super(emailConfig);
}
loadRejectedEmail() {
this.initializeFormGroups();
this.updateUserInterface();
this.initializeDropdown();
this.initializeModalConfirm();
this.initializeDirectEditButton();
}
// Overrides the placeholder text when no reason is selected
showPlaceholderNoReason() {
this.showPlaceholder("Email:", "Select a rejection reason to see email");
}
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
super.updateUserInterface(reason, excluded_reasons);
}
}
/**
* A function that hooks to the show/hide button underneath rejected reason.
* This shows the auto generated email on action needed reason.
*/
export function initRejectedEmail() {
document.addEventListener('DOMContentLoaded', function() {
const domainRequestForm = document.getElementById("domainrequest_form");
if (!domainRequestForm) {
return;
}
// Initialize UI
const customEmail = new customRejectedEmail();
// Check that every variable was setup correctly
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
if (nullItems.length > 0) {
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
return;
}
customEmail.loadRejectedEmail()
});
}
/**
* A function for dynamic DomainRequest fields
*/
export function initDynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) {
handlePortfolioSelection();
}
}

View file

@ -0,0 +1,72 @@
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
// initialize the associated widget
function checkToListThenInitWidget(toListId, attempts) {
let toList = document.getElementById(toListId);
attempts++;
if (attempts < 12) {
if (toList) {
// toList found, handle it
// Then get fromList and handle it
initializeWidgetOnList(toList, ".selector-chosen");
let fromList = toList.closest('.selector').querySelector(".selector-available select");
initializeWidgetOnList(fromList, ".selector-available");
} else {
// Element not found, check again after a delay
setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds
}
}
}
// Initialize the widget:
// Replace h2 with more semantic h3
function initializeWidgetOnList(list, parentId) {
if (list) {
// Get h2 and its container
const parentElement = list.closest(parentId);
const h2Element = parentElement.querySelector('h2');
// One last check
if (parentElement && h2Element) {
// Create a new <h3> element
const h3Element = document.createElement('h3');
// Copy the text content from the <h2> element to the <h3> element
h3Element.textContent = h2Element.textContent;
// Find the nested <span> element inside the <h2>
const nestedSpan = h2Element.querySelector('span[class][title]');
// If the nested <span> element exists
if (nestedSpan) {
// Create a new <span> element
const newSpan = document.createElement('span');
// Copy the class and title attributes from the nested <span> element
newSpan.className = nestedSpan.className;
newSpan.title = nestedSpan.title;
// Append the new <span> element to the <h3> element
h3Element.appendChild(newSpan);
}
// Replace the <h2> element with the new <h3> element
parentElement.replaceChild(h3Element, h2Element);
}
}
}
/**
*
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*
*/
export function initFilterHorizontalWidget() {
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
// and a "to" select list; initialization is based off of the presence of the
// "to" select list
checkToListThenInitWidget('id_groups_to', 0);
checkToListThenInitWidget('id_user_permissions_to', 0);
checkToListThenInitWidget('id_portfolio_roles_to', 0);
checkToListThenInitWidget('id_portfolio_additional_permissions_to', 0);
}

View file

@ -0,0 +1,24 @@
export function hideElement(element) {
if (element) {
element.classList.add('display-none');
} else {
console.warn('Called hideElement on a null or undefined element');
}
};
export function showElement(element) {
if (element) {
element.classList.remove('display-none');
} else {
console.warn('Called showElement on a null or undefined element');
}
};
// Adds or removes a boolean from our session
export function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
} else {
sessionStorage.removeItem(name);
}
}

View file

@ -0,0 +1,542 @@
import { hideElement, showElement } from './helpers-admin.js';
/**
* Helper function that handles business logic for the suborganization field.
* Can be used anywhere the suborganization dropdown exists
*/
export function handleSuborganizationFields(
portfolioDropdownSelector="#id_portfolio",
suborgDropdownSelector="#id_sub_organization",
requestedSuborgFieldSelector=".field-requested_suborganization",
suborgCitySelector=".field-suborganization_city",
suborgStateTerritorySelector=".field-suborganization_state_territory"
) {
// These dropdown are select2 fields so they must be interacted with via jquery
const portfolioDropdown = django.jQuery(portfolioDropdownSelector)
const suborganizationDropdown = django.jQuery(suborgDropdownSelector)
const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector);
const suborgCity = document.querySelector(suborgCitySelector);
const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector);
if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) {
console.error("Requested suborg fields not found.");
return;
}
function toggleSuborganizationFields() {
if (portfolioDropdown.val() && !suborganizationDropdown.val()) {
showElement(requestedSuborgField);
showElement(suborgCity);
showElement(suborgStateTerritory);
}else {
hideElement(requestedSuborgField);
hideElement(suborgCity);
hideElement(suborgStateTerritory);
}
}
// Run the function once on page startup, then attach an event listener
toggleSuborganizationFields();
suborganizationDropdown.on("change", toggleSuborganizationFields);
portfolioDropdown.on("change", toggleSuborganizationFields);
}
/**
*
* This function handles the portfolio selection as well as display of
* portfolio-related fields in the DomainRequest Form.
*
* IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields
*/
export function handlePortfolioSelection() {
// These dropdown are select2 fields so they must be interacted with via jquery
const portfolioDropdown = django.jQuery("#id_portfolio");
const suborganizationDropdown = django.jQuery("#id_sub_organization");
const suborganizationField = document.querySelector(".field-sub_organization");
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
const suborganizationCity = document.querySelector(".field-suborganization_city");
const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory");
const seniorOfficialField = document.querySelector(".field-senior_official");
const otherEmployeesField = document.querySelector(".field-other_contacts");
const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale");
const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name");
const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name");
const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email");
const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement;
const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling;
const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement;
const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling;
const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official");
const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly");
const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list");
const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement;
const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly");
const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type");
const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly");
const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name")
const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly");
const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement;
const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling;
const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency");
const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly");
const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly");
const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly");
const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly");
const portfolioCity = document.querySelector(".field-portfolio_city .readonly");
const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly");
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
let isPageLoading = true;
/**
* Fetches portfolio data by ID using an AJAX call.
*
* @param {number|string} portfolio_id - The ID of the portfolio to retrieve.
* @returns {Promise<Object|null>} - A promise that resolves to the portfolio data object if successful,
* or null if there was an error.
*
* This function performs an asynchronous fetch request to retrieve portfolio data.
* If the request is successful, it returns the portfolio data as an object.
* If an error occurs during the request or the data contains an error, it logs the error
* to the console and returns null.
*/
function getPortfolio(portfolio_id) {
return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
return null;
} else {
return data;
}
})
.catch(error => {
console.error("Error retrieving portfolio", error);
return null;
});
}
/**
* Updates various UI elements with the data from a given portfolio object.
*
* @param {Object} portfolio - The portfolio data object containing values to populate in the UI.
*
* This function updates multiple fields in the UI to reflect data in the `portfolio` object:
* - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`.
* - Calls `updatePortfolioSeniorOfficial` to set the senior official information.
* - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields.
*
* The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc.,
* are already defined and accessible in the global scope.
*/
function updatePortfolioFieldsData(portfolio) {
// replace selections in suborganizationDropdown with
// values in portfolio.suborganizations
suborganizationDropdown.empty();
// update portfolio senior official
updatePortfolioSeniorOfficial(portfolio.senior_official);
// update portfolio organization type
portfolioOrgType.innerText = portfolio.organization_type;
// update portfolio federal type
portfolioFederalType.innerText = portfolio.federal_type
// update portfolio organization name
portfolioOrgName.innerText = portfolio.organization_name;
// update portfolio federal agency
portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : '';
// update portfolio state
portfolioStateTerritory.innerText = portfolio.state_territory;
// update portfolio address line 1
portfolioAddressLine1.innerText = portfolio.address_line1;
// update portfolio address line 2
portfolioAddressLine2.innerText = portfolio.address_line2;
// update portfolio city
portfolioCity.innerText = portfolio.city;
// update portfolio zip code
portfolioZipcode.innerText = portfolio.zipcode
// update portfolio urbanization
portfolioUrbanization.innerText = portfolio.urbanization;
}
/**
* Updates the UI to display the senior official information from a given object.
*
* @param {Object} senior_official - The senior official's data object, containing details like
* first name, last name, and ID. If `senior_official` is null, displays a default message.
*
* This function:
* - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element.
* - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info
* and displays it by calling `updateSeniorOfficialContactInfo`.
* - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message.
*
* Dependencies:
* - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally.
* - Uses `showElement` and `hideElement` for visibility control.
*/
function updatePortfolioSeniorOfficial(senior_official) {
if (senior_official) {
let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' ');
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${senior_official.id}/change/ class='test'>${seniorOfficialName}</a>`
portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official);
showElement(portfolioSeniorOfficialAddress);
} else {
portfolioSeniorOfficial.innerText = "No senior official found.";
hideElement(portfolioSeniorOfficialAddress);
}
}
/**
* Populates and displays contact information for a senior official within a specified address field element.
*
* @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official.
* @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone.
*
* This function:
* - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data.
* - Updates the `titleSpan` with the official's title, or "None" if unavailable.
* - Updates the `emailSpan` with the official's email, or "None" if unavailable.
* - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`.
* - If no email is provided, hides the `copyButton`.
* - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable.
*
* Dependencies:
* - Uses `showElement` and `hideElement` to control visibility of the `copyButton`.
* - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work.
*/
function updateSeniorOfficialContactInfo(addressField, senior_official) {
const titleSpan = addressField.querySelector(".contact_info_title");
const emailSpan = addressField.querySelector(".contact_info_email");
const phoneSpan = addressField.querySelector(".contact_info_phone");
const hiddenInput = addressField.querySelector("input");
const copyButton = addressField.querySelector(".admin-icon-group");
if (titleSpan) {
titleSpan.textContent = senior_official.title || "None";
};
if (emailSpan) {
emailSpan.textContent = senior_official.email || "None";
if (senior_official.email) {
hiddenInput.value = senior_official.email;
showElement(copyButton);
}else {
hideElement(copyButton);
}
}
if (phoneSpan) {
phoneSpan.textContent = senior_official.phone || "None";
};
}
/**
* Dynamically updates the visibility of certain portfolio fields based on specific conditions.
*
* This function adjusts the display of fields within the portfolio UI based on:
* - The presence of a senior official's contact information.
* - The selected state or territory, affecting the visibility of the urbanization field.
* - The organization type (Federal vs. non-Federal), toggling the visibility of related fields.
*
* Functionality:
* 1. **Senior Official Contact Info Display**:
* - If `portfolioSeniorOfficial` contains "No additional contact information found",
* hides `portfolioSeniorOfficialAddress`; otherwise, shows it.
*
* 2. **Urbanization Field Display**:
* - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico).
*
* 3. **Federal Organization Type Display**:
* - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField`
* and `portfolioFederalTypeField`.
* - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`.
* - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links
* to edit the portfolio
*
* Dependencies:
* - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.).
* - Uses `showElement` and `hideElement` functions to control element visibility.
*/
function updatePortfolioFieldsDataDynamicDisplay() {
// Handle visibility of senior official's contact information
if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) {
hideElement(portfolioSeniorOfficialAddress);
} else {
showElement(portfolioSeniorOfficialAddress);
}
// Handle visibility of urbanization field based on state/territory value
let portfolioStateTerritoryValue = portfolioStateTerritory.innerText;
if (portfolioStateTerritoryValue === "PR") {
showElement(portfolioUrbanizationField);
} else {
hideElement(portfolioUrbanizationField);
}
// Handle visibility of fields based on organization type (Federal vs. others)
if (portfolioOrgType.innerText === "Federal") {
hideElement(portfolioOrgNameField);
showElement(portfolioFederalAgencyField);
showElement(portfolioFederalTypeField);
} else {
showElement(portfolioOrgNameField);
hideElement(portfolioFederalAgencyField);
hideElement(portfolioFederalTypeField);
}
// Modify the display of certain fields to convert them from text to links
// to edit the portfolio
let portfolio_id = portfolioDropdown.val();
let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`;
let portfolioOrgTypeValue = portfolioOrgType.innerText;
portfolioOrgType.innerHTML = `<a href=${portfolioEditUrl}>${portfolioOrgTypeValue}</a>`;
let portfolioOrgNameValue = portfolioOrgName.innerText;
portfolioOrgName.innerHTML = `<a href=${portfolioEditUrl}>${portfolioOrgNameValue}</a>`;
let portfolioFederalAgencyValue = portfolioFederalAgency.innerText;
portfolioFederalAgency.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalAgencyValue}</a>`;
let portfolioFederalTypeValue = portfolioFederalType.innerText;
if (portfolioFederalTypeValue !== '-')
portfolioFederalType.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalTypeValue}</a>`;
}
/**
* Asynchronously updates portfolio fields in the UI based on the selected portfolio.
*
* This function first checks if the page is loading or if a portfolio selection is available
* in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data,
* then updates the UI fields to display relevant data. If no portfolio is selected, it simply
* refreshes the UI field display without new data. The `isPageLoading` flag prevents
* updates during page load.
*
* Workflow:
* 1. **Check Page Loading**:
* - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates.
* - If `isPageLoading` is `false`, proceed with portfolio field updates.
*
* 2. **Portfolio Selection**:
* - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data.
* - Once data is fetched, run three update functions:
* - `updatePortfolioFieldsData`: Populates specific portfolio-related fields.
* - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields.
* - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data.
* - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`.
*
* Dependencies:
* - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined.
* - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions.
*/
async function updatePortfolioFields() {
if (!isPageLoading) {
if (portfolioDropdown.val()) {
getPortfolio(portfolioDropdown.val()).then((portfolio) => {
updatePortfolioFieldsData(portfolio);
updatePortfolioFieldsDisplay();
updatePortfolioFieldsDataDynamicDisplay();
});
} else {
updatePortfolioFieldsDisplay();
}
} else {
isPageLoading = false;
}
}
/**
* Updates the Suborganization Dropdown with new data based on the provided portfolio ID.
*
* This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization
* data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized
* on `suborganizationDropdown` and destroys the existing instance to avoid duplication.
* It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search
* and select suborganizations dynamically, with results filtered based on `portfolio_id`.
*
* Key workflow:
* 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded.
* 2. **Check and Reinitialize Select2**:
* - If Select2 is already initialized, its destroyed to refresh with new options.
* - Select2 is reinitialized with AJAX settings for dynamic data fetching.
* 3. **AJAX Options**:
* - **Data Function**: Prepares the query by capturing the user's search term (`params.term`)
* and the provided `portfolio_id` to filter relevant suborganizations.
* - **Data Type**: Ensures responses are returned as JSON.
* - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing.
* - **Cache**: Enables caching to improve performance.
* 4. **Theme and Placeholder**:
* - Sets the dropdown theme to admin-autocomplete for consistent styling.
* - Allows clearing of the dropdown and displays a placeholder as defined in the HTML.
*
* Dependencies:
* - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin.
* - `portfolio_id` is passed to filter results relevant to a specific portfolio.
*/
function updateSubOrganizationDropdown(portfolio_id) {
django.jQuery(document).ready(function() {
if (suborganizationDropdown.data('select2')) {
suborganizationDropdown.select2('destroy');
}
// Reinitialize Select2 with the updated URL
suborganizationDropdown.select2({
ajax: {
data: function (params) {
var query = {
search: params.term,
portfolio_id: portfolio_id
}
return query;
},
dataType: 'json',
delay: 250,
cache: true
},
theme: 'admin-autocomplete',
allowClear: true,
placeholder: suborganizationDropdown.attr('data-placeholder')
});
});
}
/**
* Updates the display of portfolio-related fields based on whether a portfolio is selected.
*
* This function controls the visibility of specific fields by showing or hiding them
* depending on the presence of a selected portfolio ID in the dropdown. When a portfolio
* is selected, certain fields are shown (like suborganizations and portfolio-related fields),
* while others are hidden (like senior official and other employee-related fields).
*
* Workflow:
* 1. **Retrieve Portfolio ID**:
* - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected.
*
* 2. **Display Fields for Selected Portfolio**:
* - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio.
* - Shows or hides various fields to display only relevant portfolio information:
* - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization.
* - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`.
*
* 3. **Display Fields for No Portfolio Selected**:
* - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility:
* - Hides `suborganizationField` and other portfolio-specific fields.
* - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`.
*
* Dependencies:
* - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs.
* - `showElement` and `hideElement` utility functions are used to control element visibility.
* - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used.
*/
function updatePortfolioFieldsDisplay() {
// Retrieve the selected portfolio ID
let portfolio_id = portfolioDropdown.val();
if (portfolio_id) {
// A portfolio is selected - update suborganization dropdown and show/hide relevant fields
// Update suborganization dropdown for the selected portfolio
updateSubOrganizationDropdown(portfolio_id);
// Show fields relevant to a selected portfolio
showElement(suborganizationField);
hideElement(seniorOfficialField);
showElement(portfolioSeniorOfficialField);
// Hide fields not applicable when a portfolio is selected
hideElement(otherEmployeesField);
hideElement(noOtherContactsRationaleField);
hideElement(cisaRepresentativeFirstNameField);
hideElement(cisaRepresentativeLastNameField);
hideElement(cisaRepresentativeEmailField);
hideElement(orgTypeFieldSet);
hideElement(orgTypeFieldSetDetails);
hideElement(orgNameFieldSet);
hideElement(orgNameFieldSetDetails);
// Show portfolio-specific fields
showElement(portfolioOrgTypeFieldSet);
showElement(portfolioOrgNameFieldSet);
showElement(portfolioOrgNameFieldSetDetails);
} else {
// No portfolio is selected - reverse visibility of fields
// Hide suborganization field as no portfolio is selected
hideElement(suborganizationField);
// Show fields that are relevant when no portfolio is selected
showElement(seniorOfficialField);
hideElement(portfolioSeniorOfficialField);
showElement(otherEmployeesField);
showElement(noOtherContactsRationaleField);
showElement(cisaRepresentativeFirstNameField);
showElement(cisaRepresentativeLastNameField);
showElement(cisaRepresentativeEmailField);
// Show organization type and name fields
showElement(orgTypeFieldSet);
showElement(orgTypeFieldSetDetails);
showElement(orgNameFieldSet);
showElement(orgNameFieldSetDetails);
// Hide portfolio-specific fields that arent applicable
hideElement(portfolioOrgTypeFieldSet);
hideElement(portfolioOrgNameFieldSet);
hideElement(portfolioOrgNameFieldSetDetails);
}
updateSuborganizationFieldsDisplay();
}
/**
* Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown.
*
* If a suborganization is selected:
* - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`).
* - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization.
*
* If no suborganization is selected:
* - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`).
* - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields.
*
* This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested.
*/
function updateSuborganizationFieldsDisplay() {
let portfolio_id = portfolioDropdown.val();
let suborganization_id = suborganizationDropdown.val();
if (portfolio_id && !suborganization_id) {
// Show suborganization request fields
showElement(requestedSuborganizationField);
showElement(suborganizationCity);
showElement(suborganizationStateTerritory);
} else {
// Hide suborganization request fields if suborganization is selected
hideElement(requestedSuborganizationField);
hideElement(suborganizationCity);
hideElement(suborganizationStateTerritory);
}
}
/**
* Initializes necessary data and display configurations for the portfolio fields.
*/
function initializePortfolioSettings() {
// Update the visibility of portfolio-related fields based on current dropdown selection.
updatePortfolioFieldsDisplay();
// Dynamically adjust the display of certain fields based on the selected portfolio's characteristics.
updatePortfolioFieldsDataDynamicDisplay();
}
/**
* Sets event listeners for key UI elements.
*/
function setEventListeners() {
// When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields.
portfolioDropdown.on("change", updatePortfolioFields);
// When the 'suborganizationDropdown' selection changes
suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay);
}
// Run initial setup functions
initializePortfolioSettings();
setEventListeners();
}

View file

@ -0,0 +1,41 @@
import { initModals } from './modals.js';
import { initCopyToClipboard } from './copy-to-clipboard.js';
import { initFilterHorizontalWidget } from './filter-horizontal.js';
import { initDescriptions } from './show-more-description.js';
import { initSubmitBar } from './submit-bar.js';
import {
initIneligibleModal,
initAssignToMe,
initActionNeededEmail,
initRejectedEmail,
initApprovedDomain,
initCopyRequestSummary,
initDynamicDomainRequestFields } from './domain-request-form.js';
import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js';
// General
initModals();
initCopyToClipboard();
initFilterHorizontalWidget();
initDescriptions();
initSubmitBar();
// Domain request
initIneligibleModal();
initAssignToMe();
initActionNeededEmail();
initRejectedEmail();
initApprovedDomain();
initCopyRequestSummary();
initDynamicDomainRequestFields();
// Domain
initDomainFormTargetBlankButtons();
// Portfolio
initDynamicPortfolioFields();
// Domain information
initDynamicDomainInformationFields();

View file

@ -0,0 +1,30 @@
/**
* A function for pages in DjangoAdmin that use modals.
* Dja strips out form elements, and modals generate their content outside
* of the current form scope, so we need to "inject" these inputs.
*/
export function initModals(){
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder');
let form = document.querySelector("form")
submitButtons.forEach((button) => {
let input = document.createElement("input");
input.type = "submit";
if(button.name){
input.name = button.name;
}
if(button.value){
input.value = button.value;
}
input.style.display = "none"
// Add the hidden input to the form
form.appendChild(input);
button.addEventListener("click", () => {
input.click();
})
})
}

View file

@ -0,0 +1,259 @@
import { hideElement, showElement } from './helpers-admin.js';
/**
* A function for dynamically changing some fields on the portfolio admin model
* IMPORTANT NOTE: The logic in this function is paired handlePortfolioSelection and should be refactored once we solidify our requirements.
*/
export function initDynamicPortfolioFields(){
// the federal agency change listener fires on page load, which we don't want.
var isInitialPageLoad = true
// This is the additional information that exists beneath the SO element.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
const federalAgencyContainer = document.querySelector(".field-federal_agency");
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
if (!isPortfolioPage) {
return;
}
// $ symbolically denotes that this is using jQuery
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly");
let organizationNameContainer = document.querySelector(".field-organization_name");
let federalType = document.querySelector(".field-federal_type");
if ($federalAgency && (organizationType || readonlyOrganizationType)) {
// Attach the change event listener
$federalAgency.on("change", function() {
handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
});
}
// Handle dynamically hiding the urbanization field
let urbanizationField = document.querySelector(".field-urbanization");
let stateTerritory = document.getElementById("id_state_territory");
if (urbanizationField && stateTerritory) {
// Execute this function once on load
handleStateTerritoryChange(stateTerritory, urbanizationField);
// Attach the change event listener for state/territory
stateTerritory.addEventListener("change", function() {
handleStateTerritoryChange(stateTerritory, urbanizationField);
});
}
// Handle hiding the organization name field when the organization_type is federal.
// Run this first one page load, then secondly on a change event.
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
organizationType.addEventListener("change", function() {
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
});
});
function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) {
if (organizationType && organizationNameContainer) {
let selectedValue = organizationType.value;
if (selectedValue === "federal") {
hideElement(organizationNameContainer);
showElement(federalAgencyContainer);
if (federalType) {
showElement(federalType);
}
} else {
showElement(organizationNameContainer);
hideElement(federalAgencyContainer);
if (federalType) {
hideElement(federalType);
}
}
}
}
function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
return;
}
// Set the org type to federal if an agency is selected
let selectedText = federalAgency.find("option:selected").text();
// There isn't a federal senior official associated with null records
if (!selectedText) {
return;
}
let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase();
if (selectedText !== "Non-Federal Agency") {
if (organizationTypeValue !== "federal") {
if (organizationType){
organizationType.value = "federal";
}else {
readonlyOrganizationType.innerText = "Federal"
}
}
}else {
if (organizationTypeValue === "federal") {
if (organizationType){
organizationType.value = "";
}else {
readonlyOrganizationType.innerText = "-"
}
}
}
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
// Determine if any changes are necessary to the display of portfolio type or federal type
// based on changes to the Federal Agency
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
return;
}
updateReadOnly(data.federal_type, '.field-federal_type');
})
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
// Hide the contactList initially.
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
let $seniorOfficial = django.jQuery("#id_senior_official");
let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly");
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
// Clear the field if the SO doesn't exist.
if (statusCode === 404) {
if ($seniorOfficial && $seniorOfficial.length > 0) {
$seniorOfficial.val("").trigger("change");
}else {
// Show the "create one now" text if this field is none in readonly mode.
readonlySeniorOfficial.innerHTML = `<a href="${seniorOfficialAddUrl}">No senior official found. Create one now.</a>`;
}
console.warn("Record not found: " + data.error);
}else {
console.error("Error in AJAX call: " + data.error);
}
return;
}
// Update the "contact details" blurb beneath senior official
updateContactInfo(data);
showElement(contactList.parentElement);
// Get the associated senior official with this federal agency
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
if ($seniorOfficial && $seniorOfficial.length > 0) {
// If the senior official is a dropdown field, edit that
updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName);
}else {
if (readonlySeniorOfficial) {
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
}
}
})
.catch(error => console.error("Error fetching senior official: ", error));
}
function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) {
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
// Clear the field if the SO doesn't exist
dropdown.val("").trigger("change");
return;
}
// Add the senior official to the dropdown.
// This format supports select2 - if we decide to convert this field in the future.
if (dropdown.find(`option[value='${seniorOfficialId}']`).length) {
// Select the value that is associated with the current Senior Official.
dropdown.val(seniorOfficialId).trigger("change");
} else {
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
dropdown.append(userOption).trigger("change");
}
}
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
let selectedValue = stateTerritory.value;
if (selectedValue === "PR") {
showElement(urbanizationField)
} else {
hideElement(urbanizationField)
}
}
/**
* Utility that selects a div from the DOM using selectorString,
* and updates a div within that div which has class of 'readonly'
* so that the text of the div is updated to updateText
* @param {*} updateText
* @param {*} selectorString
*/
function updateReadOnly(updateText, selectorString) {
// find the div by selectorString
const selectedDiv = document.querySelector(selectorString);
if (selectedDiv) {
// find the nested div with class 'readonly' inside the selectorString div
const readonlyDiv = selectedDiv.querySelector('.readonly');
if (readonlyDiv) {
// Update the text content of the readonly div
readonlyDiv.textContent = updateText !== null ? updateText : '-';
}
}
}
function updateContactInfo(data) {
if (!contactList) return;
const titleSpan = contactList.querySelector(".contact_info_title");
const emailSpan = contactList.querySelector(".contact_info_email");
const phoneSpan = contactList.querySelector(".contact_info_phone");
if (titleSpan) {
titleSpan.textContent = data.title || "None";
};
// Update the email field and the content for the clipboard
if (emailSpan) {
let copyButton = contactList.querySelector(".admin-icon-group");
emailSpan.textContent = data.email || "None";
if (data.email) {
const clipboardInput = contactList.querySelector(".admin-icon-group input");
if (clipboardInput) {
clipboardInput.value = data.email;
};
showElement(copyButton);
}else {
hideElement(copyButton);
}
}
if (phoneSpan) {
phoneSpan.textContent = data.phone || "None";
};
}
}

View file

@ -0,0 +1,37 @@
import { hideElement } from './helpers-admin.js';
/** An IIFE for toggling the overflow styles on django-admin__model-description (the show more / show less button) */
export function initDescriptions() {
function handleShowMoreButton(toggleButton, descriptionDiv){
// Check the length of the text content in the description div
if (descriptionDiv.textContent.length < 200) {
// Hide the toggle button if text content is less than 200 characters
// This is a little over 160 characters to give us some wiggle room if we
// change the font size marginally.
if (toggleButton)
hideElement(toggleButton);
} else {
toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, descriptionDiv, 'dja__model-description--no-overflow');
});
}
}
function toggleShowMoreButton(toggleButton, descriptionDiv, showMoreClassName){
// Toggle the class on the description div
descriptionDiv.classList.toggle(showMoreClassName);
// Change the button text based on the presence of the class
if (descriptionDiv.classList.contains(showMoreClassName)) {
toggleButton.textContent = 'Show less';
} else {
toggleButton.textContent = 'Show more';
}
}
let toggleButton = document.getElementById('dja-show-more-model-description');
let descriptionDiv = document.querySelector('.dja__model-description');
if (toggleButton && descriptionDiv) {
handleShowMoreButton(toggleButton, descriptionDiv);
}
}

View file

@ -0,0 +1,57 @@
/**
* A function for toggling the submit bar on domain request forms
*/
export function initSubmitBar(){
// Get a reference to the button element
const toggleButton = document.getElementById('submitRowToggle');
const submitRowWrapper = document.querySelector('.submit-row-wrapper');
if (toggleButton) {
// Add event listener to toggle the class and update content on click
toggleButton.addEventListener('click', function() {
// Toggle the 'collapsed' class on the bar
submitRowWrapper.classList.toggle('submit-row-wrapper--collapsed');
// Get a reference to the span element inside the button
const spanElement = this.querySelector('span');
// Get a reference to the use element inside the button
const useElement = this.querySelector('use');
// Check if the span element text is 'Hide'
if (spanElement.textContent.trim() === 'Hide') {
// Update the span element text to 'Show'
spanElement.textContent = 'Show';
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the span element text to 'Hide'
spanElement.textContent = 'Hide';
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
});
// We have a scroll indicator at the end of the page.
// Observe it. Once it gets on screen, test to see if the row is collapsed.
// If it is, expand it.
const targetElement = document.querySelector(".scroll-indicator");
const options = {
threshold: 1
};
// Create a new Intersection Observer
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Refresh reference to submit row wrapper and check if it's collapsed
if (document.querySelector('.submit-row-wrapper').classList.contains('submit-row-wrapper--collapsed')) {
toggleButton.click();
}
}
});
}, options);
observer.observe(targetElement);
}
}

View file

@ -0,0 +1,113 @@
import { hideElement, showElement } from './helpers.js';
export function loadInitialValuesForComboBoxes() {
var overrideDefaultClearButton = true;
var isTyping = false;
document.addEventListener('DOMContentLoaded', (event) => {
handleAllComboBoxElements();
});
function handleAllComboBoxElements() {
const comboBoxElements = document.querySelectorAll(".usa-combo-box");
comboBoxElements.forEach(comboBox => {
const input = comboBox.querySelector("input");
const select = comboBox.querySelector("select");
if (!input || !select) {
console.warn("No combobox element found");
return;
}
// Set the initial value of the combobox
let initialValue = select.getAttribute("data-default-value");
let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
if (!clearInputButton) {
console.warn("No clear element found");
return;
}
// Override the default clear button behavior such that it no longer clears the input,
// it just resets to the data-initial-value.
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
// Configure the observer to watch for changes in the dropdown list
const config = { childList: true, subtree: true };
observer.observe(dropdownList, config);
// Input event listener to detect typing
input.addEventListener("input", () => {
isTyping = true;
});
// Blur event listener to reset typing state
input.addEventListener("blur", () => {
isTyping = false;
});
// Hide the reset button when there is nothing to reset.
// Do this once on init, then everytime a change occurs.
updateClearButtonVisibility(select, initialValue, clearInputButton)
select.addEventListener("change", () => {
updateClearButtonVisibility(select, initialValue, clearInputButton)
});
// Change the default input behaviour - have it reset to the data default instead
clearInputButton.addEventListener("click", (e) => {
if (overrideDefaultClearButton && initialValue) {
e.preventDefault();
e.stopPropagation();
input.click();
// Find the dropdown option with the desired value
const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
if (dropdownOptions) {
dropdownOptions.forEach(option => {
if (option.getAttribute("data-value") === initialValue) {
// Simulate a click event on the dropdown option
option.click();
}
});
}
}
});
});
}
function updateClearButtonVisibility(select, initialValue, clearInputButton) {
if (select.value === initialValue) {
hideElement(clearInputButton);
}else {
showElement(clearInputButton)
}
}
function addBlankOption(clearInputButton, dropdownList, initialValue) {
if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
const blankOption = document.createElement("li");
blankOption.setAttribute("role", "option");
blankOption.setAttribute("data-value", "");
blankOption.classList.add("usa-combo-box__list-option");
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "⎯";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
overrideDefaultClearButton = false;
// Trigger the default clear behavior
clearInputButton.click();
overrideDefaultClearButton = true;
});
}
}
}

View file

@ -0,0 +1,253 @@
var DEFAULT_ERROR = "Please check this field for errors.";
var ERROR = "error";
var SUCCESS = "success";
/** Makes an element invisible. */
function makeHidden(el) {
el.style.position = "absolute";
el.style.left = "-100vw";
// The choice of `visiblity: hidden`
// over `display: none` is due to
// UX: the former will allow CSS
// transitions when the elements appear.
el.style.visibility = "hidden";
}
/** Makes visible a perviously hidden element. */
function makeVisible(el) {
el.style.position = "relative";
el.style.left = "unset";
el.style.visibility = "visible";
}
/** Creates and returns a live region element. */
function createLiveRegion(id) {
const liveRegion = document.createElement("div");
liveRegion.setAttribute("role", "region");
liveRegion.setAttribute("aria-live", "polite");
liveRegion.setAttribute("id", id + "-live-region");
liveRegion.classList.add("usa-sr-only");
document.body.appendChild(liveRegion);
return liveRegion;
}
/** Announces changes to assistive technology users. */
function announce(id, text) {
let liveRegion = document.getElementById(id + "-live-region");
if (!liveRegion) liveRegion = createLiveRegion(id);
liveRegion.innerHTML = text;
}
/** Asyncronously fetches JSON. No error handling. */
function fetchJSON(endpoint, callback, url="/api/v1/") {
const xhr = new XMLHttpRequest();
xhr.open('GET', url + endpoint);
xhr.send();
xhr.onload = function() {
if (xhr.status != 200) return;
callback(JSON.parse(xhr.response));
};
}
/** Modifies CSS and HTML when an input is valid/invalid. */
function toggleInputValidity(el, valid, msg=DEFAULT_ERROR) {
if (valid) {
el.setCustomValidity("");
el.removeAttribute("aria-invalid");
el.classList.remove('usa-input--error');
} else {
el.classList.remove('usa-input--success');
el.setAttribute("aria-invalid", "true");
el.setCustomValidity(msg);
el.classList.add('usa-input--error');
}
}
/** Display (or hide) a message beneath an element. */
function inlineToast(el, id, style, msg) {
if (!el.id && !id) {
console.error("Elements must have an `id` to show an inline toast.");
return;
}
let toast = document.getElementById((el.id || id) + "--toast");
if (style) {
if (!toast) {
// create and insert the message div
toast = document.createElement("div");
const toastBody = document.createElement("div");
const p = document.createElement("p");
toast.setAttribute("id", (el.id || id) + "--toast");
toast.className = `usa-alert usa-alert--${style} usa-alert--slim`;
toastBody.classList.add("usa-alert__body");
p.classList.add("usa-alert__text");
p.innerHTML = msg;
toastBody.appendChild(p);
toast.appendChild(toastBody);
el.parentNode.insertBefore(toast, el.nextSibling);
} else {
// update and show the existing message div
toast.className = `usa-alert usa-alert--${style} usa-alert--slim`;
toast.querySelector("div p").innerHTML = msg;
makeVisible(toast);
}
} else {
if (toast) makeHidden(toast);
}
}
function checkDomainAvailability(el) {
const callback = (response) => {
toggleInputValidity(el, (response && response.available), response.message);
announce(el.id, response.message);
// Determines if we ignore the field if it is just blank
let ignore_blank = el.classList.contains("blank-ok")
if (el.validity.valid) {
el.classList.add('usa-input--success');
// use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration
inlineToast(el.parentElement, el.id, SUCCESS, response.message);
} else if (ignore_blank && response.code == "required"){
// Visually remove the error
error = "usa-input--error"
if (el.classList.contains(error)){
el.classList.remove(error)
}
} else {
inlineToast(el.parentElement, el.id, ERROR, response.message);
}
}
fetchJSON(`available/?domain=${el.value}`, callback);
}
/** Hides the toast message and clears the aira live region. */
function clearDomainAvailability(el) {
el.classList.remove('usa-input--success');
announce(el.id, "");
// use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration
inlineToast(el.parentElement, el.id);
}
/** Runs all the validators associated with this element. */
function runValidators(el) {
const attribute = el.getAttribute("validate") || "";
if (!attribute.length) return;
const validators = attribute.split(" ");
let isInvalid = false;
for (const validator of validators) {
switch (validator) {
case "domain":
checkDomainAvailability(el);
break;
}
}
toggleInputValidity(el, !isInvalid);
}
/** Clears all the validators associated with this element. */
function clearValidators(el) {
const attribute = el.getAttribute("validate") || "";
if (!attribute.length) return;
const validators = attribute.split(" ");
for (const validator of validators) {
switch (validator) {
case "domain":
clearDomainAvailability(el);
break;
}
}
toggleInputValidity(el, true);
}
/** On input change, handles running any associated validators. */
function handleInputValidation(e) {
clearValidators(e.target);
if (e.target.hasAttribute("auto-validate")) runValidators(e.target);
}
/** On button click, handles running any associated validators. */
function validateFieldInput(e) {
const attribute = e.target.getAttribute("validate-for") || "";
if (!attribute.length) return;
const input = document.getElementById(attribute);
removeFormErrors(input, true);
runValidators(input);
}
function validateFormsetInputs(e, availabilityButton) {
// Collect input IDs from the repeatable forms
let inputs = Array.from(document.querySelectorAll('.repeatable-form input'));
// Run validators for each input
inputs.forEach(input => {
removeFormErrors(input, true);
runValidators(input);
});
// Set the validate-for attribute on the button with the collected input IDs
// Not needed for functionality but nice for accessibility
inputs = inputs.map(input => input.id).join(', ');
availabilityButton.setAttribute('validate-for', inputs);
}
/**
* Removes form errors surrounding a form input
*/
function removeFormErrors(input, removeStaleAlerts=false){
// Remove error message
let errorMessage = document.getElementById(`${input.id}__error-message`);
if (errorMessage) {
errorMessage.remove();
} else{
return;
}
// Remove error classes
if (input.classList.contains('usa-input--error')) {
input.classList.remove('usa-input--error');
}
// Get the form label
let label = document.querySelector(`label[for="${input.id}"]`);
if (label) {
label.classList.remove('usa-label--error');
// Remove error classes from parent div
let parentDiv = label.parentElement;
if (parentDiv) {
parentDiv.classList.remove('usa-form-group--error');
}
}
if (removeStaleAlerts){
let staleAlerts = document.querySelectorAll(".usa-alert--error");
for (let alert of staleAlerts) {
// Don't remove the error associated with the input
if (alert.id !== `${input.id}--toast`) {
alert.remove();
}
}
}
}
export function initDomainValidators() {
"use strict";
const needsValidation = document.querySelectorAll('[validate]');
for (const input of needsValidation) {
input.addEventListener('input', handleInputValidation);
}
const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability');
const activatesValidation = document.querySelectorAll('[validate-for]');
for (const button of activatesValidation) {
if (button === alternativeDomainsAvailability) {
button.addEventListener('click', (e) => {
validateFormsetInputs(e, alternativeDomainsAvailability);
});
} else {
button.addEventListener('click', validateFieldInput);
}
}
}

View file

@ -0,0 +1,418 @@
/**
* Prepare the namerservers and DS data forms delete buttons
* We will call this on the forms init, and also every time we add a form
*
*/
function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
let formToRemove = e.target.closest(".repeatable-form");
formToRemove.remove();
let forms = document.querySelectorAll(".repeatable-form");
totalForms.setAttribute('value', `${forms.length}`);
let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
// For the example on Nameservers
let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g');
forms.forEach((form, index) => {
// Iterate over child nodes of the current element
Array.from(form.querySelectorAll('label, input, select')).forEach((node) => {
// Iterate through the attributes of the current node
Array.from(node.attributes).forEach((attr) => {
// Check if the attribute value matches the regex
if (formNumberRegex.test(attr.value)) {
// Replace the attribute value with the updated value
attr.value = attr.value.replace(formNumberRegex, `form-${index}-`);
}
});
});
// h2 and legend for DS form, label for nameservers
Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
let innerSpan = node.querySelector('span')
if (innerSpan) {
innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
} else {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
// If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
// inject the USWDS required markup and make sure the INPUT is required
if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
// Remove the word optional
innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, '');
// Create a new element
const newElement = document.createElement('abbr');
newElement.textContent = '*';
newElement.setAttribute("title", "required");
newElement.classList.add("usa-hint", "usa-hint--required");
// Append the new element to the label
node.appendChild(newElement);
// Find the next sibling that is an input element
let nextInputElement = node.nextElementSibling;
while (nextInputElement) {
if (nextInputElement.tagName === 'INPUT') {
// Found the next input element
nextInputElement.setAttribute("required", "")
break;
}
nextInputElement = nextInputElement.nextElementSibling;
}
nextInputElement.required = true;
}
});
// Display the add more button if we have less than 13 forms
if (isNameserversForm && forms.length <= 13) {
addButton.removeAttribute("disabled");
}
if (isNameserversForm && forms.length < 3) {
// Hide the delete buttons on the remaining nameservers
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
}
});
}
/**
* Delete method for formsets using the DJANGO DELETE widget (Other Contacts)
*
*/
function markForm(e, formLabel){
// Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget
let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
if (totalShownForms == 1) {
// toggle the radio buttons
let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]');
radioButton.checked = true;
// Trigger the change event
let event = new Event('change');
radioButton.dispatchEvent(event);
} else {
// Grab the hidden delete input and assign a value DJANGO will look for
let formToRemove = e.target.closest(".repeatable-form");
if (formToRemove) {
let deleteInput = formToRemove.querySelector('input[class="deletion"]');
if (deleteInput) {
deleteInput.value = 'on';
}
}
// Set display to 'none'
formToRemove.style.display = 'none';
}
// Update h2s on the visible forms only. We won't worry about the forms' identifiers
let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`);
let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
shownForms.forEach((form, index) => {
// Iterate over child nodes of the current element
Array.from(form.querySelectorAll('h2')).forEach((node) => {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
});
});
}
/**
* Prepare the namerservers, DS data and Other Contacts formsets' delete button
* for the last added form. We call this from the Add function
*
*/
function prepareNewDeleteButton(btn, formLabel) {
let formIdentifier = "form"
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let addButton = document.querySelector("#add-form");
if (isOtherContactsForm) {
formIdentifier = "other_contacts";
// We will mark the forms for deletion
btn.addEventListener('click', function(e) {
markForm(e, formLabel);
});
} else {
// We will remove the forms and re-order the formset
btn.addEventListener('click', function(e) {
removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
});
}
}
/**
* Prepare the namerservers, DS data and Other Contacts formsets' delete buttons
* We will call this on the forms init
*
*/
function prepareDeleteButtons(formLabel) {
let formIdentifier = "form"
let deleteButtons = document.querySelectorAll(".delete-record");
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let addButton = document.querySelector("#add-form");
if (isOtherContactsForm) {
formIdentifier = "other_contacts";
}
// Loop through each delete button and attach the click event listener
deleteButtons.forEach((deleteButton) => {
if (isOtherContactsForm) {
// We will mark the forms for deletion
deleteButton.addEventListener('click', function(e) {
markForm(e, formLabel);
});
} else {
// We will remove the forms and re-order the formset
deleteButton.addEventListener('click', function(e) {
removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
});
}
});
}
/**
* DJANGO formset's DELETE widget
* On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion'
* with value='on'
*/
function hideDeletedForms() {
let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]');
// Iterating over the NodeList of hidden inputs
hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) {
// Finding the closest parent element with class "repeatable-form" for each hidden input
var repeatableFormToHide = hiddenInput.closest('.repeatable-form');
// Checking if a matching parent element is found for each hidden input
if (repeatableFormToHide) {
// Setting the display property to "none" for each matching parent element
repeatableFormToHide.style.display = 'none';
}
});
}
/**
* A function that attaches a click handler for our dynamic formsets
*
* Only does something on a few pages, but it should be fast enough to run
* it everywhere.
*/
export function initFormsetsForms() {
let formIdentifier = "form"
let repeatableForm = document.querySelectorAll(".repeatable-form");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-form");
let cloneIndex = 0;
let formLabel = '';
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let isDsDataForm = document.querySelector(".ds-data-form");
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
// The Nameservers formset features 2 required and 11 optionals
if (isNameserversForm) {
// cloneIndex = 2;
formLabel = "Name server";
// DNSSEC: DS Data
} else if (isDsDataForm) {
formLabel = "DS data record";
// The Other Contacts form
} else if (isOtherContactsForm) {
formLabel = "Organization contact";
container = document.querySelector("#other-employees");
formIdentifier = "other_contacts"
} else if (isDotgovDomain) {
formIdentifier = "dotgov_domain"
}
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
// On load: Disable the add more button if we have 13 forms
if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) {
addButton.setAttribute("disabled", "true");
}
// Hide forms which have previously been deleted
hideDeletedForms()
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons(formLabel);
if (addButton)
addButton.addEventListener('click', addForm);
function addForm(e){
let forms = document.querySelectorAll(".repeatable-form");
let formNum = forms.length;
let newForm = repeatableForm[cloneIndex].cloneNode(true);
let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g');
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
// Some Nameserver form checks since the delete can mess up the source object we're copying
// in regards to required fields and hidden delete buttons
if (isNameserversForm) {
// If the source element we're copying has required on an input,
// reset that input
let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*');
if (formRequiredNeedsCleanUp) {
newForm.querySelector('label abbr').remove();
// Get all input elements within the container
const inputElements = newForm.querySelectorAll("input");
// Loop through each input element and remove the 'required' attribute
inputElements.forEach((input) => {
if (input.hasAttribute("required")) {
input.removeAttribute("required");
}
});
}
// If the source element we're copying has an disabled delete button,
// enable that button
let deleteButton= newForm.querySelector('.delete-record');
if (deleteButton.hasAttribute("disabled")) {
deleteButton.removeAttribute("disabled");
}
}
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`);
if (isOtherContactsForm) {
// For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
// since the form on the backend employs Django's DELETE widget.
let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`);
} else {
// Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional
// if indices 0 or 1 have been deleted
let containsOptional = newForm.innerHTML.includes('(optional)');
if (isNameserversForm && !containsOptional) {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`);
} else {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
}
}
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`);
newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters
newForm.innerHTML = newForm.innerHTML.replace(/>\s*</g, '><'); // Remove spaces between tags
container.insertBefore(newForm, addButton);
newForm.style.display = 'block';
let inputs = newForm.querySelectorAll("input");
// Reset the values of each input to blank
inputs.forEach((input) => {
input.classList.remove("usa-input--error");
input.classList.remove("usa-input--success");
if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") {
input.value = ""; // Set the value to an empty string
} else if (input.type === "checkbox" || input.type === "radio") {
input.checked = false; // Uncheck checkboxes and radios
}
});
// Reset any existing validation classes
let selects = newForm.querySelectorAll("select");
selects.forEach((select) => {
select.classList.remove("usa-input--error");
select.classList.remove("usa-input--success");
select.selectedIndex = 0; // Set the value to an empty string
});
let labels = newForm.querySelectorAll("label");
labels.forEach((label) => {
label.classList.remove("usa-label--error");
label.classList.remove("usa-label--success");
});
let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
usaFormGroups.forEach((usaFormGroup) => {
usaFormGroup.classList.remove("usa-form-group--error");
usaFormGroup.classList.remove("usa-form-group--success");
});
// Remove any existing error and success messages
let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert");
usaMessages.forEach((usaErrorMessage) => {
let parentDiv = usaErrorMessage.closest('div');
if (parentDiv) {
parentDiv.remove(); // Remove the parent div if it exists
}
});
totalForms.setAttribute('value', `${formNum}`);
// Attach click event listener on the delete buttons of the new form
let newDeleteButton = newForm.querySelector(".delete-record");
if (newDeleteButton)
prepareNewDeleteButton(newDeleteButton, formLabel);
// Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
addButton.setAttribute("disabled", "true");
}
if (isNameserversForm && forms.length >= 2) {
// Enable the delete buttons on the nameservers
forms.forEach((form, index) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.removeAttribute("disabled");
});
});
}
}
}
export function triggerModalOnDsDataForm() {
let saveButon = document.querySelector("#save-ds-data");
// The view context will cause a hitherto hidden modal trigger to
// show up. On save, we'll test for that modal trigger appearing. We'll
// run that test once every 100 ms for 5 secs, which should balance performance
// while accounting for network or lag issues.
if (saveButon) {
let i = 0;
var tryToTriggerModal = setInterval(function() {
i++;
if (i > 100) {
clearInterval(tryToTriggerModal);
}
let modalTrigger = document.querySelector("#ds-toggle-dnssec-alert");
if (modalTrigger) {
modalTrigger.click()
clearInterval(tryToTriggerModal);
}
}, 50);
}
}
/**
* Disable the delete buttons on nameserver forms on page load if < 3 forms
*
*/
export function nameserversFormListener() {
let isNameserversForm = document.querySelector(".nameservers-form");
if (isNameserversForm) {
let forms = document.querySelectorAll(".repeatable-form");
if (forms.length < 3) {
// Hide the delete buttons on the 2 nameservers
forms.forEach((form) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
});
}
}
}

View file

@ -0,0 +1,43 @@
/**
* Initialize USWDS tooltips by calling initialization method. Requires that uswds-edited.js
* be loaded before getgov.min.js. uswds-edited.js adds the tooltip module to the window to be
* accessible directly in getgov.min.js
*
*/
export function initializeTooltips() {
function checkTooltip() {
// Check that the tooltip library is loaded, and if not, wait and retry
if (window.tooltip && typeof window.tooltip.init === 'function') {
window.tooltip.init();
} else {
// Retry after a short delay
setTimeout(checkTooltip, 100);
}
}
checkTooltip();
}
/**
* Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded
* before getgov.min.js. uswds-edited.js adds the modal module to the window to be accessible
* directly in getgov.min.js.
* uswdsInitializeModals adds modal-related DOM elements, based on other DOM elements existing in
* the page. It needs to be called only once for any particular DOM element; otherwise, it
* will initialize improperly. Therefore, if DOM elements change dynamically and include
* DOM elements with modal classes, uswdsUnloadModals needs to be called before uswdsInitializeModals.
*
*/
export function uswdsInitializeModals() {
window.modal.on();
}
/**
* Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be
* loaded before getgov.min.js. uswds-edited.js adds the modal module to the window to be
* accessible directly in getgov.min.js.
* See note above with regards to calling this method relative to uswdsInitializeModals.
*
*/
export function uswdsUnloadModals() {
window.modal.off();
}

View file

@ -0,0 +1,77 @@
export function hideElement(element) {
element.classList.add('display-none');
};
export function showElement(element) {
element.classList.remove('display-none');
};
/**
* Helper function that scrolls to an element identified by a class or an id.
* @param {string} attributeName - The string "class" or "id"
* @param {string} attributeValue - The class or id name
*/
export function scrollToElement(attributeName, attributeValue) {
let targetEl = null;
if (attributeName === 'class') {
targetEl = document.getElementsByClassName(attributeValue)[0];
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
window.scrollTo({
top: rect.top + scrollTop,
behavior: 'smooth' // Optional: for smooth scrolling
});
}
}
/**
* Toggles expand_more / expand_more svgs in buttons or anchors
* @param {Element} element - DOM element
*/
export function toggleCaret(element) {
// Get a reference to the use element inside the button
const useElement = element.querySelector('use');
// Check if the span element text is 'Hide'
if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') {
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
}
/**
* Slow down event handlers by limiting how frequently they fire.
*
* A wait period must occur with no activity (activity means "this
* debounce function being called") before the handler is invoked.
*
* @param {Function} handler - any JS function
* @param {number} cooldown - the wait period, in milliseconds
*/
export function debounce(handler, cooldown=600) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => handler.apply(context, args), cooldown);
}
}
/**
* Helper function to get the CSRF token from the cookie
*
*/
export function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}

View file

@ -0,0 +1,44 @@
import { hookupYesNoListener, hookupRadioTogglerListener } from './radios.js';
import { initDomainValidators } from './domain-validators.js';
import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js';
import { initializeUrbanizationToggle } from './urbanization.js';
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
import { loadInitialValuesForComboBoxes } from './combobox.js';
import { handleRequestingEntityFieldset } from './requesting-entity.js';
import { initDomainsTable } from './table-domains.js';
import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
initDomainValidators();
initFormsetsForms();
triggerModalOnDsDataForm();
nameserversFormListener();
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
initializeUrbanizationToggle();
userProfileListener();
finishUserSetupListener();
loadInitialValuesForComboBoxes();
handleRequestingEntityFieldset();
initDomainsTable();
initDomainRequestsTable();
initMembersTable();
initMemberDomainsTable();
initPortfolioMemberPageToggle();

View file

@ -0,0 +1,43 @@
import { uswdsInitializeModals } from './helpers-uswds.js';
import { generateKebabHTML } from './table-base.js';
import { MembersTable } from './table-members.js';
// This is specifically for the Member Profile (Manage Member) Page member/invitation removal
export function initPortfolioMemberPageToggle() {
document.addEventListener("DOMContentLoaded", () => {
const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
if (wrapperDeleteAction) {
const member_type = wrapperDeleteAction.getAttribute("data-member-type");
const member_id = wrapperDeleteAction.getAttribute("data-member-id");
const num_domains = wrapperDeleteAction.getAttribute("data-num-domains");
const member_name = wrapperDeleteAction.getAttribute("data-member-name");
const member_email = wrapperDeleteAction.getAttribute("data-member-email");
const member_delete_url = `${member_type}-${member_id}/delete`;
const unique_id = `${member_type}-${member_id}`;
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`);
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
uswdsInitializeModals();
// Now the DOM and modals are ready, add listeners to the submit buttons
const modals = document.querySelectorAll('.usa-modal__content');
modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', () => {
closeButton.click();
let delete_member_form = document.getElementById("member-delete-form");
if (delete_member_form) {
delete_member_form.submit();
}
});
});
}
});
}

View file

@ -0,0 +1,77 @@
import { hideElement, showElement } from './helpers.js';
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions.
/** Hookup listeners for yes/no togglers for form fields
* Parameters:
* - radioButtonName: The "name=" value for the radio buttons being used as togglers
* - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given
* radio button is true (hides this element if false)
* - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given
* radio button is false (hides this element if true)
* **/
export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) {
hookupRadioTogglerListener(radioButtonName, {
'True': elementIdToShowIfYes,
'False': elementIdToShowIfNo
});
}
/**
* Hookup listeners for radio togglers in form fields.
*
* Parameters:
* - radioButtonName: The "name=" value for the radio buttons being used as togglers
* - valueToElementMap: An object where keys are the values of the radio buttons,
* and values are the corresponding DOM element IDs to show. All other elements will be hidden.
*
* Usage Example:
* Assuming you have radio buttons with values 'option1', 'option2', and 'option3',
* and corresponding DOM IDs 'section1', 'section2', 'section3'.
*
* HookupValueBasedListener('exampleRadioGroup', {
* 'option1': 'section1',
* 'option2': 'section2',
* 'option3': 'section3'
* });
**/
export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
// Get the radio buttons
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]');
// Extract the list of all element IDs from the valueToElementMap
let allElementIds = Object.values(valueToElementMap);
function handleRadioButtonChange() {
// Find the checked radio button
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked');
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
// Hide all elements by default
allElementIds.forEach(function (elementId) {
let element = document.getElementById(elementId);
if (element) {
hideElement(element);
}
});
// Show the relevant element for the selected value
if (selectedValue && valueToElementMap[selectedValue]) {
let elementToShow = document.getElementById(valueToElementMap[selectedValue]);
if (elementToShow) {
showElement(elementToShow);
}
}
}
if (radioButtons.length) {
// Add event listener to each radio button
radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange);
});
// Initialize by checking the current state
handleRadioButtonChange();
}
}

View file

@ -0,0 +1,50 @@
import { hideElement, showElement } from './helpers.js';
/** A function that intializes the requesting entity page.
* This page has a radio button that dynamically toggles some fields
* Within that, the dropdown also toggles some additional form elements.
*/
export function handleRequestingEntityFieldset() {
// Sadly, these ugly ids are the auto generated with this prefix
const formPrefix = "portfolio_requesting_entity";
const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`);
const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`);
const select = document.getElementById(`id_${formPrefix}-sub_organization`);
const selectParent = select?.parentElement;
const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding.
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return;
// requestingSuborganization: This just broadly determines if they're requesting a suborg at all
// requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not.
var requestingSuborganization = Array.from(radios).find(radio => radio.checked)?.value === "True";
var requestingNewSuborganization = document.getElementById(`id_${formPrefix}-is_requesting_new_suborganization`);
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False";
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
}
// Add fake "other" option to sub_organization select
if (select && !Array.from(select.options).some(option => option.value === "other")) {
select.add(new Option(subOrgCreateNewOption, "other"));
}
if (requestingNewSuborganization.value === "True") {
select.value = "other";
}
// Add event listener to is_suborganization radio buttons, and run for initial display
toggleSuborganization();
radios.forEach(radio => {
radio.addEventListener("click", () => toggleSuborganization(radio));
});
// Add event listener to the suborg dropdown to show/hide the suborg details section
select.addEventListener("change", () => toggleSuborganization());
}

View file

@ -0,0 +1,651 @@
import { hideElement, showElement, toggleCaret, scrollToElement } from './helpers.js';
/**
* Creates and adds a modal dialog to the DOM with customizable attributes and content.
*
* @param {string} id - A unique identifier for the modal, appended to the action for uniqueness.
* @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility.
* @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility.
* @param {string} modalHeading - The heading text displayed at the top of the modal.
* @param {string} modalDescription - The main descriptive text displayed within the modal.
* @param {string} modalSubmit - The HTML content for the submit button, allowing customization.
* @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`.
* @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control.
*
* The modal includes a heading, description, submit button, and a cancel button, along with a close button.
* The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality.
*/
export function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) {
const modal = document.createElement('div');
modal.setAttribute('class', 'usa-modal');
modal.setAttribute('id', id);
modal.setAttribute('aria-labelledby', ariaLabelledby);
modal.setAttribute('aria-describedby', ariaDescribedby);
if (forceAction)
modal.setAttribute('data-force-action', '');
modal.innerHTML = `
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading">
${modalHeading}
</h2>
<div class="usa-prose">
<p>
${modalDescription}
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
${modalSubmit}
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
`
if (wrapper_element) {
wrapper_element.appendChild(modal);
} else {
document.body.appendChild(modal);
}
}
/**
* Helper function that creates a dynamic accordion navigation
* @param {string} action - The action type or identifier used to create a unique DOM IDs.
* @param {string} unique_id - An ID that when combined with action makes a unique identifier
* @param {string} modal_button_text - The action button's text
* @param {string} screen_reader_text - A screen reader helper
*/
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) {
const generateModalButton = (mobileOnly = false) => `
<a
role="button"
id="button-trigger-${action}-${unique_id}"
href="#toggle-${action}-${unique_id}"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
aria-controls="toggle-${action}-${unique_id}"
data-open-modal
>
${mobileOnly ? `<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg>` : ''}
${modal_button_text}
<span class="usa-sr-only">${screen_reader_text}</span>
</a>
`;
// Main kebab structure
const kebab = `
${generateModalButton(true)} <!-- Mobile button -->
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions-${unique_id}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden>
<h2>More options</h2>
${generateModalButton()} <!-- Desktop button -->
</div>
</div>
`;
return kebab;
}
export class BaseTable {
constructor(itemName) {
this.itemName = itemName;
this.sectionSelector = itemName + 's';
this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`);
this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`);
this.currentSortBy = 'id';
this.currentOrder = 'asc';
this.currentStatus = [];
this.currentSearchTerm = '';
this.scrollToTable = false;
this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`);
this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`);
this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`);
this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`);
this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`);
this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`);
this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`);
this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`);
this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`);
this.portfolioElement = document.getElementById('portfolio-js-value');
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
this.initializeTableHeaders();
this.initializeSearchHandler();
this.initializeStatusToggleHandler();
this.initializeFilterCheckboxes();
this.initializeResetSearchButton();
this.initializeResetFiltersButton();
this.initializeAccordionAccessibilityListeners();
}
/**
* Generalized function to update pagination for a list.
* @param {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages.
* @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} total - The total number of items.
*/
updatePagination(
currentPage,
numPages,
hasPrevious,
hasNext,
totalItems
) {
const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`);
const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`);
const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`);
const parentTableSelector = `#${this.sectionSelector}`;
counterSelectorEl.innerHTML = '';
paginationButtons.innerHTML = '';
// Buttons should only be displayed if there are more than one pages of results
paginationButtons.classList.toggle('display-none', numPages <= 1);
// Counter should only be displayed if there is more than 1 item
paginationSelectorEl.classList.toggle('display-none', totalItems < 1);
counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
// Helper function to create a pagination item
const createPaginationItem = (page) => {
const paginationItem = document.createElement('li');
paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no');
paginationItem.innerHTML = `
<a href="${parentTableSelector}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
paginationItem.querySelector('a').classList.add('usa-current');
paginationItem.querySelector('a').setAttribute('aria-current', 'page');
}
paginationItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
this.loadTable(page);
});
return paginationItem;
};
if (hasPrevious) {
const prevPaginationItem = document.createElement('li');
prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow';
prevPaginationItem.innerHTML = `
<a href="${parentTableSelector}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg>
<span class="usa-pagination__link-text">Previous</span>
</a>
`;
prevPaginationItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
this.loadTable(currentPage - 1);
});
paginationButtons.appendChild(prevPaginationItem);
}
// Add first page and ellipsis if necessary
if (currentPage > 2) {
paginationButtons.appendChild(createPaginationItem(1));
if (currentPage > 3) {
const ellipsis = document.createElement('li');
ellipsis.className = 'usa-pagination__item usa-pagination__overflow';
ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages');
ellipsis.innerHTML = '<span>…</span>';
paginationButtons.appendChild(ellipsis);
}
}
// Add pages around the current page
for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) {
paginationButtons.appendChild(createPaginationItem(i));
}
// Add last page and ellipsis if necessary
if (currentPage < numPages - 1) {
if (currentPage < numPages - 2) {
const ellipsis = document.createElement('li');
ellipsis.className = 'usa-pagination__item usa-pagination__overflow';
ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages');
ellipsis.innerHTML = '<span>…</span>';
paginationButtons.appendChild(ellipsis);
}
paginationButtons.appendChild(createPaginationItem(numPages));
}
if (hasNext) {
const nextPaginationItem = document.createElement('li');
nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow';
nextPaginationItem.innerHTML = `
<a href="${parentTableSelector}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<span class="usa-pagination__link-text">Next</span>
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_next"></use>
</svg>
</a>
`;
nextPaginationItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
this.loadTable(currentPage + 1);
});
paginationButtons.appendChild(nextPaginationItem);
}
}
/**
* A helper that toggles content/ no content/ no search results based on results in data.
* @param {Object} data - Data representing current page of results data.
* @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page.
* @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period.
* @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search.
*/
updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data;
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);
}
};
/**
* A helper that resets sortable table headers
*
*/
unsetHeader = (header) => {
header.removeAttribute('aria-sort');
let headerName = header.innerText;
const headerLabel = `${headerName}, sortable column, currently unsorted"`;
const headerButtonLabel = `Click to sort by ascending order.`;
header.setAttribute("aria-label", headerLabel);
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
};
/**
* Generates search params for filtering and sorting
* @param {number} page - The current page number for pagination (starting with 1)
* @param {*} sortBy - The sort column option
* @param {*} order - The order of sorting {asc, desc}
* @param {string} searchTerm - The search term used to filter results for a specific keyword
* @param {*} status - The status filter applied {ready, dns_needed, etc}
* @param {string} portfolio - The portfolio id
*/
getSearchParams(page, sortBy, order, searchTerm, status, portfolio) {
let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"search_term": searchTerm,
}
);
let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null;
let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null;
let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null;
if (portfolio)
searchParams.append("portfolio", portfolio);
if (emailValue)
searchParams.append("email", emailValue);
if (memberIdValue)
searchParams.append("member_id", memberIdValue);
if (memberOnly)
searchParams.append("member_only", memberOnly);
if (status)
searchParams.append("status", status);
return searchParams;
}
/**
* Gets the base URL of API requests
* Placeholder function in a parent class - method should be implemented by child class for specifics
* Throws an error if called directly from the parent class
*/
getBaseUrl() {
throw new Error('getBaseUrl must be defined');
}
/**
* Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences
* from leftover event listeners + can be properly re-initialized
*/
unloadModals(){}
/**
* Loads modals + sets up event listeners for the modal submit actions
* "Activates" the modals after the DOM updates
* Utilizes "uswdsInitializeModals"
* Adds click event listeners to each modal's submit button so we can handle a user's actions
*
* When the submit button is clicked:
* - Triggers the close button to reset modal classes
* - Determines if the page needs refreshing if the last item is deleted
* @param {number} page - The current page number for pagination
* @param {number} total - The total # of items on the current page
* @param {number} unfiltered_total - The total # of items across all pages
*/
loadModals(page, total, unfiltered_total) {}
/**
* Allows us to customize the table display based on specific conditions and a user's permissions
* Dynamically manages the visibility set up of columns, adding/removing headers
* (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions
* for a member, they will also see the kebab column)
* @param {Object} dataObjects - Data which contains info on domain requests or a user's permission
* Currently returns a dictionary of either:
* - "needsAdditionalColumn": If a new column should be displayed
* - "UserPortfolioPermissionChoices": A user's portfolio permission choices
*/
customizeTable(dataObjects){ return {}; }
/**
* Retrieves specific data objects
* Placeholder function in a parent class - method should be implemented by child class for specifics
* Throws an error if called directly from the parent class
* Returns either: data.members, data.domains or data.domain_requests
* @param {Object} data - The full data set from which a subset of objects is extracted.
*/
getDataObjects(data) {
throw new Error('getDataObjects must be defined');
}
/**
* Creates + appends a row to a tbody element
* Tailored structure set up for each data object (domain, domain_request, member, etc)
* Placeholder function in a parent class - method should be implemented by child class for specifics
* Throws an error if called directly from the parent class
* Returns either: data.members, data.domains or data.domain_requests
* @param {Object} dataObject - The data used to populate the row content
* @param {HTMLElement} tbody - The table body to which the new row is appended to
* @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn)
*/
addRow(dataObject, tbody, customTableOptions) {
throw new Error('addRow must be defined');
}
/**
* See function for more details
*/
initShowMoreButtons(){}
/**
* Loads rows in the members list, as well as updates pagination around the members list
* based on the supplied attributes.
* @param {*} page - The page number of the results (starts with 1)
* @param {*} sortBy - The sort column option
* @param {*} order - The sort order {asc, desc}
* @param {*} scroll - The control for the scrollToElement functionality
* @param {*} searchTerm - The search term
* @param {*} portfolio - The portfolio id
*/
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// --------- SEARCH
let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
// --------- FETCH DATA
// fetch json of page of domains, given params
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return;
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
return;
}
// handle the display of proper messaging in the event that no members exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the list of results will be inserted into the DOM
const tbody = this.tableWrapper.querySelector('tbody');
tbody.innerHTML = '';
// remove any existing modal elements from the DOM so they can be properly re-initialized
// after the DOM content changes and there are new delete modal buttons added
this.unloadModals();
let dataObjects = this.getDataObjects(data);
let customTableOptions = this.customizeTable(data);
dataObjects.forEach(dataObject => {
this.addRow(dataObject, tbody, customTableOptions);
});
this.initShowMoreButtons();
this.loadModals(data.page, data.total, data.unfiltered_total);
// Do not scroll on first page load
if (scroll)
scrollToElement('class', this.sectionSelector);
this.scrollToTable = true;
// update pagination
this.updatePagination(
data.page,
data.num_pages,
data.has_previous,
data.has_next,
data.total,
);
this.currentSortBy = sortBy;
this.currentOrder = order;
this.currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching objects:', error));
}
// Add event listeners to table headers for sorting
initializeTableHeaders() {
this.tableHeaders.forEach(header => {
header.addEventListener('click', () => {
const sortBy = header.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
// is selecting the same column to sort in descending order
if (sortBy === this.currentSortBy) {
order = this.currentOrder === 'asc' ? 'desc' : 'asc';
}
// load the results with the updated sort
this.loadTable(1, sortBy, order);
});
});
}
initializeSearchHandler() {
this.searchSubmit.addEventListener('click', (e) => {
e.preventDefault();
this.currentSearchTerm = this.searchInput.value;
// If the search is blank, we match the resetSearch functionality
if (this.currentSearchTerm) {
showElement(this.resetSearchButton);
} else {
hideElement(this.resetSearchButton);
}
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
});
}
initializeStatusToggleHandler() {
if (this.statusToggle) {
this.statusToggle.addEventListener('click', () => {
toggleCaret(this.statusToggle);
});
}
}
// Add event listeners to status filter checkboxes
initializeFilterCheckboxes() {
this.statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const checkboxValue = checkbox.value;
// Update currentStatus array based on checkbox state
if (checkbox.checked) {
this.currentStatus.push(checkboxValue);
} else {
const index = this.currentStatus.indexOf(checkboxValue);
if (index > -1) {
this.currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (this.currentStatus.length == 0) {
hideElement(this.resetFiltersButton);
} else {
showElement(this.resetFiltersButton);
}
// Disable the auto scroll
this.scrollToTable = false;
// Call loadTable with updated status
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
});
});
}
// Reset UI and accessibility
resetHeaders() {
this.tableHeaders.forEach(header => {
// Unset sort UI in headers
this.unsetHeader(header);
});
// Reset the announcement region
this.tableAnnouncementRegion.innerHTML = '';
}
resetSearch() {
this.searchInput.value = '';
this.currentSearchTerm = '';
hideElement(this.resetSearchButton);
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
}
initializeResetSearchButton() {
if (this.resetSearchButton) {
this.resetSearchButton.addEventListener('click', () => {
this.resetSearch();
});
}
}
resetFilters() {
this.currentStatus = [];
this.statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(this.resetFiltersButton);
// Disable the auto scroll
this.scrollToTable = false;
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
initializeResetFiltersButton() {
if (this.resetFiltersButton) {
this.resetFiltersButton.addEventListener('click', () => {
this.resetFilters();
});
}
}
updateStatusIndicator() {
this.statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
hideElement(this.statusIndicator);
if (this.currentStatus.length)
this.statusIndicator.innerHTML = '(' + this.currentStatus.length + ')';
showElement(this.statusIndicator);
}
closeFilters() {
if (this.statusToggle.getAttribute("aria-expanded") === "true") {
this.statusToggle.click();
}
}
initializeAccordionAccessibilityListeners() {
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
}
}

View file

@ -0,0 +1,279 @@
import { hideElement, showElement, getCsrfToken } from './helpers.js';
import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
const utcDateString = (dateString) => {
const date = new Date(dateString);
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
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`;
};
export class DomainRequestsTable extends BaseTable {
constructor() {
super('domain-request');
}
getBaseUrl() {
return document.getElementById("get_domain_requests_json_url");
}
toggleExportButton(requests) {
const exportButton = document.getElementById('export-csv');
if (exportButton) {
if (requests.length > 0) {
showElement(exportButton);
} else {
hideElement(exportButton);
}
}
}
getDataObjects(data) {
return data.domain_requests;
}
unloadModals() {
uswdsUnloadModals();
}
customizeTable(data) {
// Manage "export as CSV" visibility for domain requests
this.toggleExportButton(data.domain_requests);
let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable);
// Remove existing delete th and td if they exist
let existingDeleteTh = document.querySelector('.delete-header');
if (!needsDeleteColumn) {
if (existingDeleteTh)
existingDeleteTh.remove();
} else {
if (!existingDeleteTh) {
const delheader = document.createElement('th');
delheader.setAttribute('scope', 'col');
delheader.setAttribute('role', 'columnheader');
delheader.setAttribute('class', 'delete-header width-5');
delheader.innerHTML = `
<span class="usa-sr-only">Delete Action</span>`;
let tableHeaderRow = this.tableWrapper.querySelector('thead tr');
tableHeaderRow.appendChild(delheader);
}
}
return { 'needsAdditionalColumn': needsDeleteColumn };
}
addRow(dataObject, tbody, customTableOptions) {
const request = dataObject;
const options = { year: 'numeric', month: 'short', day: 'numeric' };
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
const actionUrl = request.action_url;
const actionLabel = request.action_label;
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
// If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user
let modalTrigger = `
<span class="usa-sr-only">Domain request cannot be deleted now. Edit the request for more information.</span>`;
let markupCreatorRow = '';
if (this.portfolioValue) {
markupCreatorRow = `
<td>
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
</td>
`
}
if (request.is_deletable) {
// 1st path: Just a modal trigger in any screen size for non-org users
modalTrigger = `
<a
role="button"
id="button-toggle-delete-domain-${request.id}"
href="#toggle-delete-domain-${request.id}"
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger line-height-sans-5"
aria-controls="toggle-delete-domain-${request.id}"
data-open-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg> Delete <span class="usa-sr-only">${domainName}</span>
</a>`
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (this.portfolioValue) {
// 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName);
}
}
const row = document.createElement('tr');
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
${domainName}
</th>
<td data-sort-value="${new Date(request.last_submitted_date).getTime()}" data-label="Date submitted">
${submissionDate}
</td>
${markupCreatorRow}
<td data-label="Status">
${request.status}
</td>
<td>
<a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>
</svg>
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
</a>
</td>
${customTableOptions.needsAdditionalColumn ? '<td>'+modalTrigger+'</td>' : ''}
`;
tbody.appendChild(row);
if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody);
}
loadModals(page, total, unfiltered_total) {
// initialize modals immediately after the DOM content is updated
uswdsInitializeModals();
// Now the DOM and modals are ready, add listeners to the submit buttons
const modals = document.querySelectorAll('.usa-modal__content');
modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', () => {
let pk = submitButton.getAttribute('data-pk');
// Workaround: Close the modal to remove the USWDS UI local classes
closeButton.click();
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
let pageToDisplay = page;
if (total == 1 && unfiltered_total > 1) {
pageToDisplay--;
}
this.deleteDomainRequest(pk, pageToDisplay);
});
});
}
/**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
* @param {*} domainRequestPk - the identifier for the request that we're deleting
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
*/
deleteDomainRequest(domainRequestPk, pageToDisplay) {
// Use to debug uswds modal issues
//console.log('deleteDomainRequest')
// Get csrf token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
fetch(`/domain-request/${domainRequestPk}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
/**
* Modal that displays when deleting a domain request
* @param {string} requested_domain - The requested domain URL
* @param {string} id - The request's ID
* @param {string}} created_at - When the request was created at
* @param {HTMLElement} wrapper_element - The element to which the modal is appended
*/
static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) {
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
let modalHeading = '';
let modalDescription = '';
if (requested_domain) {
modalHeading = `Are you sure you want to delete ${requested_domain}?`;
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
} else {
if (created_at) {
modalHeading = 'Are you sure you want to delete this domain request?';
modalDescription = `This will remove the domain request (created ${utcDateString(created_at)}) from the .gov registrar. This action cannot be undone`;
} else {
modalHeading = 'Are you sure you want to delete New domain request?';
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
}
}
const modalSubmit = `
<button type="button"
class="usa-button usa-button--secondary usa-modal__submit"
data-pk = ${id}
name="delete-domain-request">Yes, delete request</button>
`
addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true);
}
}
export function initDomainRequestsTable() {
document.addEventListener('DOMContentLoaded', function() {
const domainRequestsSectionWrapper = document.getElementById('domain-requests');
if (domainRequestsSectionWrapper) {
const domainRequestsTable = new DomainRequestsTable();
if (domainRequestsTable.tableWrapper) {
domainRequestsTable.loadTable(1);
}
}
document.addEventListener('focusin', function(event) {
closeOpenAccordions(event);
});
document.addEventListener('click', function(event) {
closeOpenAccordions(event);
});
function closeMoreActionMenu(accordionThatIsOpen) {
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
accordionThatIsOpen.click();
}
}
function closeOpenAccordions(event) {
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => {
// Find the corresponding accordion
const accordion = openAccordionButton.closest('.usa-accordion--more-actions');
if (accordion && !accordion.contains(event.target)) {
// Close the accordion if the click is outside
closeMoreActionMenu(openAccordionButton);
}
});
}
});
}

View file

@ -0,0 +1,79 @@
import { BaseTable } from './table-base.js';
export class DomainsTable extends BaseTable {
constructor() {
super('domain');
}
getBaseUrl() {
return document.getElementById("get_domains_json_url");
}
getDataObjects(data) {
return data.domains;
}
addRow(dataObject, tbody, customTableOptions) {
const domain = dataObject;
const options = { year: 'numeric', month: 'short', day: 'numeric' };
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
const row = document.createElement('tr');
let markupForSuborganizationRow = '';
if (this.portfolioValue) {
markupForSuborganizationRow = `
<td>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
</td>
`
}
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
${domain.name}
</th>
<td data-sort-value="${expirationDateSortValue}" data-label="Expires">
${expirationDateFormatted}
</td>
<td data-label="Status">
${domain.state_display}
<svg
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
data-position="top"
title="${domain.get_state_help_text}"
focusable="true"
aria-label="${domain.get_state_help_text}"
role="tooltip"
>
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg>
</td>
${markupForSuborganizationRow}
<td>
<a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
</svg>
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
</a>
</td>
`;
tbody.appendChild(row);
}
}
export function initDomainsTable() {
document.addEventListener('DOMContentLoaded', function() {
const isDomainsPage = document.getElementById("domains")
if (isDomainsPage){
const domainsTable = new DomainsTable();
if (domainsTable.tableWrapper) {
// Initial load
domainsTable.loadTable(1);
}
}
});
}

View file

@ -0,0 +1,40 @@
import { BaseTable } from './table-base.js';
export class MemberDomainsTable extends BaseTable {
constructor() {
super('member-domain');
this.currentSortBy = 'name';
}
getBaseUrl() {
return document.getElementById("get_member_domains_json_url");
}
getDataObjects(data) {
return data.domains;
}
addRow(dataObject, tbody, customTableOptions) {
const domain = dataObject;
const row = document.createElement('tr');
row.innerHTML = `
<td scope="row" data-label="Domain name">
${domain.name}
</td>
`;
tbody.appendChild(row);
}
}
export function initMemberDomainsTable() {
document.addEventListener('DOMContentLoaded', function() {
const isMemberDomainsPage = document.getElementById("member-domains")
if (isMemberDomainsPage){
const memberDomainsTable = new MemberDomainsTable();
if (memberDomainsTable.tableWrapper) {
// Initial load
memberDomainsTable.loadTable(1);
}
}
});
}

View file

@ -0,0 +1,462 @@
import { hideElement, showElement, getCsrfToken } from './helpers.js';
import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
export class MembersTable extends BaseTable {
constructor() {
super('member');
}
getBaseUrl() {
return document.getElementById("get_members_json_url");
}
// Abstract method (to be implemented in the child class)
getDataObjects(data) {
return data.members;
}
unloadModals() {
uswdsUnloadModals();
}
loadModals(page, total, unfiltered_total) {
// initialize modals immediately after the DOM content is updated
uswdsInitializeModals();
// Now the DOM and modals are ready, add listeners to the submit buttons
const modals = document.querySelectorAll('.usa-modal__content');
modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', () => {
let pk = submitButton.getAttribute('data-pk');
// Close the modal to remove the USWDS UI local classes
closeButton.click();
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
let pageToDisplay = page;
if (total == 1 && unfiltered_total > 1) {
pageToDisplay--;
}
this.deleteMember(pk, pageToDisplay);
});
});
}
customizeTable(data) {
// Get whether the logged in user has edit members permission
const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null;
let existingExtraActionsHeader = document.querySelector('.extra-actions-header');
if (hasEditPermission && !existingExtraActionsHeader) {
const extraActionsHeader = document.createElement('th');
extraActionsHeader.setAttribute('id', 'extra-actions');
extraActionsHeader.setAttribute('role', 'columnheader');
extraActionsHeader.setAttribute('class', 'extra-actions-header width-5');
extraActionsHeader.innerHTML = `
<span class="usa-sr-only">Extra Actions</span>`;
let tableHeaderRow = this.tableWrapper.querySelector('thead tr');
tableHeaderRow.appendChild(extraActionsHeader);
}
return {
'needsAdditionalColumn': hasEditPermission,
'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices
};
}
addRow(dataObject, tbody, customTableOptions) {
const member = dataObject;
// member is based on either a UserPortfolioPermission or a PortfolioInvitation
// and also includes information from related domains; the 'id' of the org_member
// is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id
// member.type is either invitedmember or member
const unique_id = member.type + member.id; // unique string for use in dom, this is
// not the id of the associated user
const member_delete_url = member.action_url + "/delete";
const num_domains = member.domain_urls.length;
const last_active = this.handleLastActive(member.last_active);
let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member";
const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): '';
const row = document.createElement('tr');
let admin_tagHTML = ``;
if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url);
let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices);
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
let showMoreButton = '';
const showMoreRow = document.createElement('tr');
if (domainsHTML || permissionsHTML) {
showMoreButton = `
<button
type="button"
class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1"
data-for=${unique_id}
aria-label="Expand for additional information"
>
<span>Expand</span>
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
`;
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${unique_id}" class="padding-top-0"><div class='grid-row'>${domainsHTML} ${permissionsHTML}</div></td>`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.id = unique_id;
}
row.innerHTML = `
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML} ${showMoreButton}
</th>
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
${last_active.display_value}
</td>
<td headers="header-action row-header-${unique_id}">
<a href="${member.action_url}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
</svg>
${member.action_label} <span class="usa-sr-only">${member.name}</span>
</a>
</td>
${customTableOptions.needsAdditionalColumn ? '<td>'+kebabHTML+'</td>' : ''}
`;
tbody.appendChild(row);
if (domainsHTML || permissionsHTML) {
tbody.appendChild(showMoreRow);
}
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row);
}
/**
* Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content.
*
* The function finds elements with "Show More" buttons and sets up a click event listener to toggle the visibility
* of a corresponding content div. When clicked, the button updates its visual state (e.g., text/icon change),
* and the associated content is shown or hidden based on its current visibility status.
*
* @function initShowMoreButtons
*/
initShowMoreButtons() {
/**
* Toggles the visibility of a content section when the "Show More" button is clicked.
* Updates the button text/icon based on whether the content is shown or hidden.
*
* @param {HTMLElement} toggleButton - The button that toggles the content visibility.
* @param {HTMLElement} contentDiv - The content div whose visibility is toggled.
* @param {HTMLElement} buttonParentRow - The parent row element containing the button.
*/
function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) {
const spanElement = toggleButton.querySelector('span');
const useElement = toggleButton.querySelector('use');
if (contentDiv.classList.contains('display-none')) {
showElement(contentDiv);
spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Close additional information');
} else {
hideElement(contentDiv);
spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Expand for additional information');
}
}
let toggleButtons = document.querySelectorAll('.usa-button--show-more-button');
toggleButtons.forEach((toggleButton) => {
// get contentDiv for element specified in data-for attribute of toggleButton
let dataFor = toggleButton.dataset.for;
let contentDiv = document.getElementById(dataFor);
let buttonParentRow = toggleButton.parentElement.parentElement;
if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow);
});
} else {
console.warn('Found a toggle button with no associated toggleable content or parent row');
}
});
}
/**
* Converts a given `last_active` value into a display value and a numeric sort value.
* The input can be a UTC date, the strings "Invited", "Invalid date", or null/undefined.
*
* @param {string} last_active - UTC date string or special status like "Invited" or "Invalid date".
* @returns {Object} - An object containing `display_value` (formatted date or status string)
* and `sort_value` (numeric value for sorting).
*/
handleLastActive(last_active) {
const invited = 'Invited';
const invalid_date = 'Invalid date';
const options = { year: 'numeric', month: 'long', day: 'numeric' }; // Date display format
let display_value = invalid_date; // Default display value for invalid or null dates
let sort_value = -1; // Default sort value for invalid or null dates
if (last_active === invited) {
// Handle "Invited" status: special case with 0 sort value
display_value = invited;
sort_value = 0;
} else if (last_active && last_active !== invalid_date) {
// Parse and format valid UTC date strings
const parsedDate = new Date(last_active);
if (!isNaN(parsedDate.getTime())) {
// Valid date
display_value = parsedDate.toLocaleDateString('en-US', options);
sort_value = parsedDate.getTime(); // Use timestamp for sorting
} else {
console.error(`Error: Invalid date string provided: ${last_active}`);
}
}
return { display_value, sort_value };
}
/**
* Generates HTML for the list of domains assigned to a member.
*
* @param {number} num_domains - The number of domains the member is assigned to.
* @param {Array} domain_names - An array of domain names.
* @param {Array} domain_urls - An array of corresponding domain URLs.
* @returns {string} - A string of HTML displaying the domains assigned to the member.
*/
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) {
// Initialize an empty string for the HTML
let domainsHTML = '';
// Only generate HTML if the member has one or more assigned domains
if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += "<h4 class='margin-y-0 text-primary'>Domains assigned</h4>";
domainsHTML += `<p class='margin-y-0'>This member is assigned to ${num_domains} domains:</p>`;
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
}
domainsHTML += "</ul>";
// If there are more than 6 domains, display a "View assigned domains" link
if (num_domains >= 6) {
domainsHTML += `<p><a href="${action_url}/domains">View assigned domains</a></p>`;
}
domainsHTML += "</div>";
}
return domainsHTML;
}
/**
* The POST call for deleting a Member and which error or success message it should return
* and redirection if necessary
*
* @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete``
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
* Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present
* (consent via CORS) so it knows it's not from a random request attempt
*/
deleteMember(member_delete_url, pageToDisplay) {
// Get CSRF token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`;
fetch(`${member_delete_url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrfToken,
},
body: formData
})
.then(response => {
if (response.status === 200) {
response.json().then(data => {
if (data.success) {
this.addAlert("success", data.success);
}
this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm);
});
} else {
response.json().then(data => {
if (data.error) {
// This should display the error given from backend for
// either only admin OR in progress requests
this.addAlert("error", data.error);
} else {
throw new Error(`Unexpected status: ${response.status}`);
}
});
}
})
.catch(error => {
console.error('Error deleting member:', error);
});
}
/**
* Adds an alert message to the page with an alert class.
*
* @param {string} alertClass - {error, warning, info, success}
* @param {string} alertMessage - The text that will be displayed
*
*/
addAlert(alertClass, alertMessage) {
let toggleableAlertDiv = document.getElementById("toggleable-alert");
this.resetAlerts();
toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`);
let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text");
alertParagraph.innerHTML = alertMessage
showElement(toggleableAlertDiv);
}
/**
* Resets the reusable alert message
*/
resetAlerts() {
// Create a list of any alert that's leftover and remove
document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => {
alert.remove();
});
let toggleableAlertDiv = document.getElementById("toggleable-alert");
toggleableAlertDiv.classList.remove('usa-alert--error');
toggleableAlertDiv.classList.remove('usa-alert--success');
hideElement(toggleableAlertDiv);
}
/**
* Generates an HTML string summarizing a user's additional permissions within a portfolio,
* based on the user's permissions and predefined permission choices.
*
* @param {Array} member_permissions - An array of permission strings that the member has.
* @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants.
* Expected keys include:
* - VIEW_ALL_DOMAINS
* - VIEW_MANAGED_DOMAINS
* - EDIT_REQUESTS
* - VIEW_ALL_REQUESTS
* - EDIT_MEMBERS
* - VIEW_MEMBERS
*
* @returns {string} - A string of HTML representing the user's additional permissions.
* If the user has no specific permissions, it returns a default message
* indicating no additional permissions.
*
* Behavior:
* - The function checks the user's permissions (`member_permissions`) and generates
* corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`.
* - Permissions are categorized into domains, requests, and members:
* - Domains: Determines whether the user can view or manage all or assigned domains.
* - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges.
* - Members: Distinguishes between members who can manage or only view other members.
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
// Check domain-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
}
// Check request-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>";
}
// Check member-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>";
}
// If no specific permissions are assigned, display a message indicating no additional permissions
if (!permissionsHTML) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><b>No additional permissions:</b> There are no additional permissions for this member.</p>";
}
// Add a permissions header and wrap the entire output in a container
permissionsHTML = "<div class='desktop:grid-col-7'><h4 class='margin-y-0 text-primary'>Additional permissions for this member</h4>" + permissionsHTML + "</div>";
return permissionsHTML;
}
/**
* Modal that displays when deleting a domain request
* @param {string} num_domains - Number of domain a user has within the org
* @param {string} member_email - The member's email
* @param {string} submit_delete_url - `${member_type}-${member_id}/delete`
* @param {HTMLElement} wrapper_element - The element to which the modal is appended
*/
static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
let modalHeading = '';
let modalDescription = '';
if (num_domains == 0){
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `They will no longer be able to access this organization.
This action cannot be undone.`;
} else if (num_domains == 1) {
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain in the organization.
Removing them from the organization will remove all of their domains. They will no longer be able to
access this organization. This action cannot be undone.`;
} else if (num_domains > 1) {
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domains in the organization.
Removing them from the organization will remove all of their domains. They will no longer be able to
access this organization. This action cannot be undone.`;
}
const modalSubmit = `
<button type="button"
class="usa-button usa-button--secondary usa-modal__submit"
data-pk = ${submit_delete_url}
name="delete-member">Yes, remove from organization</button>
`
addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true);
}
}
export function initMembersTable() {
document.addEventListener('DOMContentLoaded', function() {
const isMembersPage = document.getElementById("members")
if (isMembersPage){
const membersTable = new MembersTable();
if (membersTable.tableWrapper) {
// Initial load
membersTable.loadTable(1);
}
}
});
}

View file

@ -0,0 +1,28 @@
function setupUrbanizationToggle(stateTerritoryField) {
var urbanizationField = document.getElementById('urbanization-field');
function toggleUrbanizationField() {
// Checking specifically for Puerto Rico only
if (stateTerritoryField.value === 'PR') {
urbanizationField.style.display = 'block';
} else {
urbanizationField.style.display = 'none';
}
}
toggleUrbanizationField();
stateTerritoryField.addEventListener('change', toggleUrbanizationField);
}
export function initializeUrbanizationToggle() {
document.addEventListener('DOMContentLoaded', function() {
let stateTerritoryField = document.querySelector('select[name="organization_contact-state_territory"]');
if (!stateTerritoryField) {
return; // Exit if the field not found
}
setupUrbanizationToggle(stateTerritoryField);
});
}

View file

@ -0,0 +1,171 @@
export function userProfileListener() {
const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal');
if (showConfirmationModalTrigger) {
showConfirmationModalTrigger.click();
}
}
export function finishUserSetupListener() {
function getInputField(fieldName){
return document.querySelector(`#id_${fieldName}`)
}
// Shows the hidden input field and hides the readonly one
function showInputFieldHideReadonlyField(fieldName, button) {
let inputField = getInputField(fieldName)
let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`)
readonlyField.classList.toggle('display-none');
inputField.classList.toggle('display-none');
// Toggle the bold style on the grid row
let gridRow = button.closest(".grid-col-2").closest(".grid-row")
if (gridRow){
gridRow.classList.toggle("bold-usa-label")
}
}
function handleFullNameField(fieldName = "full_name") {
// Remove the display-none class from the nearest parent div
let nameFieldset = document.querySelector("#profile-name-group");
if (nameFieldset){
nameFieldset.classList.remove("display-none");
}
// Hide the "full_name" field
let inputField = getInputField(fieldName);
if (inputField) {
let inputFieldParentDiv = inputField.closest("div");
if (inputFieldParentDiv) {
inputFieldParentDiv.classList.add("display-none");
}
}
}
function handleEditButtonClick(fieldName, button){
button.addEventListener('click', function() {
// Lock the edit button while this operation occurs
button.disabled = true
if (fieldName == "full_name"){
handleFullNameField();
}else {
showInputFieldHideReadonlyField(fieldName, button);
}
// Hide the button itself
button.classList.add("display-none");
// Unlock after it completes
button.disabled = false
});
}
function setupListener(){
document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) {
// Get the "{field_name}" and "edit-button"
let fieldIdParts = button.id.split("__")
if (fieldIdParts && fieldIdParts.length > 0){
let fieldName = fieldIdParts[0]
// When the edit button is clicked, show the input field under it
handleEditButtonClick(fieldName, button);
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
}
let inputFieldValue = inputField.value
if (inputFieldValue || fieldName == "full_name"){
if (fieldName == "full_name"){
let firstName = document.querySelector("#id_first_name");
let middleName = document.querySelector("#id_middle_name");
let lastName = document.querySelector("#id_last_name");
if (firstName && lastName && firstName.value && lastName.value) {
let values = [firstName.value, middleName.value, lastName.value]
readonlyField.innerHTML = values.join(" ");
}else {
let fullNameField = document.querySelector('#full_name__edit-button-readonly');
let svg = fullNameField.querySelector("svg use")
if (svg) {
const currentHref = svg.getAttribute('xlink:href');
if (currentHref) {
const parts = currentHref.split('#');
if (parts.length === 2) {
// Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref);
fullNameField.classList.add("toggleable_input__error")
label = fullNameField.querySelector(".toggleable_input__readonly-field")
label.innerHTML = "Unknown";
}
}
}
}
// Technically, the full_name field is optional, but we want to display it as required.
// This style is applied to readonly fields (gray text). This just removes it, as
// this is difficult to achieve otherwise by modifying the .readonly property.
if (readonlyField.classList.contains("text-base")) {
readonlyField.classList.remove("text-base")
}
}else {
readonlyField.innerHTML = inputFieldValue
}
}
}
}
});
}
function showInputOnErrorFields(){
document.addEventListener('DOMContentLoaded', function() {
// Get all input elements within the form
let form = document.querySelector("#finish-profile-setup-form");
let inputs = form ? form.querySelectorAll("input") : null;
if (!inputs) {
return null;
}
let fullNameButtonClicked = false
inputs.forEach(function(input) {
let fieldName = input.name;
let errorMessage = document.querySelector(`#id_${fieldName}__error-message`);
// If no error message is found, do nothing
if (!fieldName || !errorMessage) {
return null;
}
let editButton = document.querySelector(`#${fieldName}__edit-button`);
if (editButton){
// Show the input field of the field that errored out
editButton.click();
}
// If either the full_name field errors out,
// or if any of its associated fields do - show all name related fields.
let nameFields = ["first_name", "middle_name", "last_name"];
if (nameFields.includes(fieldName) && !fullNameButtonClicked){
// Click the full name button if any of its related fields error out
fullNameButton = document.querySelector("#full_name__edit-button");
if (fullNameButton) {
fullNameButton.click();
fullNameButtonClicked = true;
}
}
});
});
};
setupListener();
// Show the input fields if an error exists
showInputOnErrorFields();
}

View file

@ -47,7 +47,3 @@
background-color: color('base-darkest');
}
}
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
background-image: url('../img/usa-icons-bg/error.svg');
}

View file

@ -35,7 +35,7 @@ class RequestingEntityForm(RegistrarForm):
# If this selection is made on the form (tracked by js), then it will toggle the form value of this.
# In other words, this essentially tracks if the suborganization field == "Other".
# "Other" is just an imaginary value that is otherwise invalid.
# Note the logic in `def clean` and `handleRequestingEntityFieldset` in get-gov.js
# Note the logic in `def clean` and `handleRequestingEntityFieldset` in getgov.min.js
is_requesting_new_suborganization = forms.BooleanField(required=False, widget=forms.HiddenInput())
sub_organization = forms.ModelChoiceField(

View file

@ -21,7 +21,7 @@
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %}

View file

@ -47,7 +47,7 @@
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
<!-- We override with our own copy to make some classes accessible in our JS -->
<script src="{% static 'js/uswds-edited.js' %}" defer></script>
<script src="{% static 'js/get-gov.js' %}" defer></script>
<script src="{% static 'js/getgov.min.js' %}" defer></script>
{% endblock %}
{% block canonical %}

View file

@ -49,7 +49,7 @@
<p id="domain_instructions" class="margin-top-05">After you enter your domain, well make sure its available and that it meets some of our naming requirements. If your domain passes these initial checks, well verify that it meets all our requirements after you complete the rest of this form.</p>
{% with attr_aria_describedby="domain_instructions domain_instructions2" %}
{# attr_validate / validate="domain" invokes code in get-gov.js #}
{# attr_validate / validate="domain" invokes code in getgov.min.js #}
{% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.requested_domain %}
{% endwith %}

View file

@ -44,5 +44,5 @@
</fieldset>
{% endblock %}
<script src="{% static 'js/get-gov.js' %}" defer></script>
<script src="{% static 'js/getgov.min.js' %}" defer></script>

View file

@ -654,7 +654,9 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "copy-to-clipboard", count=3)
# We expect 3 in the form + 2 from the js module copy-to-clipboard.js
# that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder
self.assertContains(response, "copy-to-clipboard", count=5)
# cleanup this test
domain_info.delete()

View file

@ -1526,7 +1526,9 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "copy-to-clipboard", count=5)
# We expect 5 in the form + 2 from the js module copy-to-clipboard.js
# that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder
self.assertContains(response, "copy-to-clipboard", count=7)
# Test that Creator counts display properly
self.assertNotContains(response, "Approved domains")

View file

@ -12,4 +12,4 @@ else
npx gulp init
npx gulp compile
fi
npx gulp watch
npx gulp watchAll

View file

@ -29,8 +29,8 @@
10027 OUTOFSCOPE http://app:8080/public/js/uswds.min.js
# UNCLEAR WHY THIS ONE IS FAILING. Giving 404 error.
10027 OUTOFSCOPE http://app:8080/public/js/uswds-init.min.js
# get-gov.js contains suspicious word "from" as in `Array.from()`
10027 OUTOFSCOPE http://app:8080/public/js/get-gov.js
# getgov.min.js contains suspicious word "from" as in `Array.from()`
10027 OUTOFSCOPE http://app:8080/public/js/getgov.min.js
# Ignores suspicious word "TODO"
10027 OUTOFSCOPE http://app:8080.*$
10028 FAIL (Open Redirect - Passive/beta)