mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 18:56:15 +02:00
Merge branch 'main' into el/3157-document-updating-sandbox-credentials
This commit is contained in:
commit
f56f3c12a1
84 changed files with 8404 additions and 5164 deletions
4
.github/workflows/clone-db.yaml
vendored
4
.github/workflows/clone-db.yaml
vendored
|
@ -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
1
.gitignore
vendored
|
@ -2,6 +2,7 @@
|
|||
|
||||
docs/research/data/**
|
||||
**/assets/*
|
||||
!**/assets/src/
|
||||
!**/assets/sass/
|
||||
!**/assets/img/registrar/
|
||||
public/
|
||||
|
|
104
src/gulpfile.js
104
src/gulpfile.js
|
@ -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
|
||||
|
3698
src/package-lock.json
generated
3698
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
}
|
30
src/registrar/assets/src/js/getgov-admin/domain-form.js
Normal file
30
src/registrar/assets/src/js/getgov-admin/domain-form.js
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
640
src/registrar/assets/src/js/getgov-admin/domain-request-form.js
Normal file
640
src/registrar/assets/src/js/getgov-admin/domain-request-form.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
24
src/registrar/assets/src/js/getgov-admin/helpers-admin.js
Normal file
24
src/registrar/assets/src/js/getgov-admin/helpers-admin.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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, it’s 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 aren’t 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();
|
||||
}
|
41
src/registrar/assets/src/js/getgov-admin/main.js
Normal file
41
src/registrar/assets/src/js/getgov-admin/main.js
Normal 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();
|
30
src/registrar/assets/src/js/getgov-admin/modals.js
Normal file
30
src/registrar/assets/src/js/getgov-admin/modals.js
Normal 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();
|
||||
})
|
||||
})
|
||||
}
|
259
src/registrar/assets/src/js/getgov-admin/portfolio-form.js
Normal file
259
src/registrar/assets/src/js/getgov-admin/portfolio-form.js
Normal 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";
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
57
src/registrar/assets/src/js/getgov-admin/submit-bar.js
Normal file
57
src/registrar/assets/src/js/getgov-admin/submit-bar.js
Normal 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);
|
||||
}
|
||||
}
|
113
src/registrar/assets/src/js/getgov/combobox.js
Normal file
113
src/registrar/assets/src/js/getgov/combobox.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
253
src/registrar/assets/src/js/getgov/domain-validators.js
Normal file
253
src/registrar/assets/src/js/getgov/domain-validators.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
418
src/registrar/assets/src/js/getgov/formset-forms.js
Normal file
418
src/registrar/assets/src/js/getgov/formset-forms.js
Normal 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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
43
src/registrar/assets/src/js/getgov/helpers-uswds.js
Normal file
43
src/registrar/assets/src/js/getgov/helpers-uswds.js
Normal 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();
|
||||
}
|
77
src/registrar/assets/src/js/getgov/helpers.js
Normal file
77
src/registrar/assets/src/js/getgov/helpers.js
Normal 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;
|
||||
}
|
44
src/registrar/assets/src/js/getgov/main.js
Normal file
44
src/registrar/assets/src/js/getgov/main.js
Normal 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();
|
43
src/registrar/assets/src/js/getgov/portfolio-member-page.js
Normal file
43
src/registrar/assets/src/js/getgov/portfolio-member-page.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
77
src/registrar/assets/src/js/getgov/radios.js
Normal file
77
src/registrar/assets/src/js/getgov/radios.js
Normal 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();
|
||||
}
|
||||
}
|
50
src/registrar/assets/src/js/getgov/requesting-entity.js
Normal file
50
src/registrar/assets/src/js/getgov/requesting-entity.js
Normal 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());
|
||||
}
|
651
src/registrar/assets/src/js/getgov/table-base.js
Normal file
651
src/registrar/assets/src/js/getgov/table-base.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
279
src/registrar/assets/src/js/getgov/table-domain-requests.js
Normal file
279
src/registrar/assets/src/js/getgov/table-domain-requests.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
79
src/registrar/assets/src/js/getgov/table-domains.js
Normal file
79
src/registrar/assets/src/js/getgov/table-domains.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
40
src/registrar/assets/src/js/getgov/table-member-domains.js
Normal file
40
src/registrar/assets/src/js/getgov/table-member-domains.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
462
src/registrar/assets/src/js/getgov/table-members.js
Normal file
462
src/registrar/assets/src/js/getgov/table-members.js
Normal 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--large" 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
28
src/registrar/assets/src/js/getgov/urbanization.js
Normal file
28
src/registrar/assets/src/js/getgov/urbanization.js
Normal 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);
|
||||
});
|
||||
}
|
171
src/registrar/assets/src/js/getgov/user-profile.js
Normal file
171
src/registrar/assets/src/js/getgov/user-profile.js
Normal 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();
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -254,3 +254,10 @@ abbr[title] {
|
|||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
//Icon size adjustment used by buttons and form errors
|
||||
.usa-icon.usa-icon--large {
|
||||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
|
@ -243,12 +243,6 @@ a .usa-icon,
|
|||
width: 1.3em;
|
||||
}
|
||||
|
||||
.usa-icon.usa-icon--big {
|
||||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
// Red, for delete buttons
|
||||
// Used on: All delete buttons
|
||||
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
|
@ -1,5 +1,6 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "cisa_colors" as *;
|
||||
@use "typography" as *;
|
||||
|
||||
.usa-form .usa-button {
|
||||
margin-top: units(3);
|
||||
|
@ -69,9 +70,9 @@ legend.float-left-tablet + button.float-right-tablet {
|
|||
}
|
||||
|
||||
.read-only-label {
|
||||
font-size: size('body', 'sm');
|
||||
@extend .h4--sm-05;
|
||||
font-weight: bold;
|
||||
color: color('primary-dark');
|
||||
margin-bottom: units(0.5);
|
||||
}
|
||||
|
||||
.read-only-value {
|
|
@ -23,6 +23,13 @@ h2 {
|
|||
color: color('primary-darker');
|
||||
}
|
||||
|
||||
.h4--sm-05 {
|
||||
font-size: size('body', 'sm');
|
||||
font-weight: normal;
|
||||
color: color('primary');
|
||||
margin-bottom: units(0.5);
|
||||
}
|
||||
|
||||
// Normalize typography in forms
|
||||
.usa-form,
|
||||
.usa-form fieldset {
|
|
@ -68,6 +68,7 @@ def portfolio_permissions(request):
|
|||
"has_organization_feature_flag": False,
|
||||
"has_organization_requests_flag": False,
|
||||
"has_organization_members_flag": False,
|
||||
"is_portfolio_admin": False,
|
||||
}
|
||||
try:
|
||||
portfolio = request.session.get("portfolio")
|
||||
|
@ -88,6 +89,7 @@ def portfolio_permissions(request):
|
|||
"has_organization_feature_flag": True,
|
||||
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
|
||||
"has_organization_members_flag": request.user.has_organization_members_flag(),
|
||||
"is_portfolio_admin": request.user.is_portfolio_admin(portfolio),
|
||||
}
|
||||
return portfolio_context
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -258,6 +258,9 @@ class User(AbstractUser):
|
|||
def has_edit_suborganization_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
||||
|
||||
def is_portfolio_admin(self, portfolio):
|
||||
return "Admin" in self.portfolio_role_summary(portfolio)
|
||||
|
||||
def get_first_portfolio(self):
|
||||
permission = self.portfolio_permissions.first()
|
||||
if permission:
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -5,6 +5,25 @@
|
|||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain-users' pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Add a domain manager</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% else %}
|
||||
{% url 'domain-users' pk=domain.id as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
|
@ -16,6 +35,7 @@
|
|||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
<h1>Add a domain manager</h1>
|
||||
{% if has_organization_feature_flag %}
|
||||
|
|
|
@ -3,6 +3,22 @@
|
|||
{% load custom_filters %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>{{ domain.name }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{{ block.super }}
|
||||
<div class="margin-top-4 tablet:grid-col-10">
|
||||
<h2 class="text-bold text-primary-dark domain-name-wrap">{{ domain.name }}</h2>
|
||||
|
@ -74,13 +90,17 @@
|
|||
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
|
||||
{% if portfolio and has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
|
||||
{% if portfolio %}
|
||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
|
||||
{% elif has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_suborganization_portfolio_permission view_button=True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% url 'domain-org-name-address' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
|
||||
|
||||
{% url 'domain-senior-official' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
|
@ -92,7 +112,11 @@
|
|||
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
{% url 'domain-users' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=is_editable %}
|
||||
{% if portfolio %}
|
||||
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='Domain managers' list=True users=True value=domain.permissions.all edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %} {# domain_content #}
|
||||
|
|
|
@ -4,6 +4,24 @@
|
|||
{% block title %}DNS | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>DNS</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
<h1>DNS</h1>
|
||||
|
||||
|
|
|
@ -5,6 +5,28 @@
|
|||
|
||||
{% block domain_content %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>DNSSEC</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
<h1>DNSSEC</h1>
|
||||
|
||||
<p>DNSSEC, or DNS Security Extensions, is an additional security layer to protect your website. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that it’s connecting to the correct server, preventing potential hijacking or tampering with your domain's records.</p>
|
||||
|
|
|
@ -4,6 +4,32 @@
|
|||
{% block title %}DS data | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain-dns-dnssec' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>DS data</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{% if domain.dnssecdata is None %}
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-3">
|
||||
<div class="usa-alert__body">
|
||||
|
|
|
@ -4,6 +4,28 @@
|
|||
{% block title %}DNS name servers | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>DNS name servers</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{# this is right after the messages block in the parent template #}
|
||||
{% for form in formset %}
|
||||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
<p id="domain_instructions" class="margin-top-05">After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. If your domain passes these initial checks, we’ll 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 %}
|
||||
|
|
|
@ -44,5 +44,5 @@
|
|||
</fieldset>
|
||||
{% endblock %}
|
||||
|
||||
<script src="{% static 'js/get-gov.js' %}" defer></script>
|
||||
<script src="{% static 'js/getgov.min.js' %}" defer></script>
|
||||
|
||||
|
|
|
@ -4,6 +4,25 @@
|
|||
{% block title %}Security email | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Security email</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
||||
<h1>Security email</h1>
|
||||
|
|
|
@ -4,9 +4,30 @@
|
|||
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Suborganization</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{# this is right after the messages block in the parent template #}
|
||||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
||||
|
||||
<h1>Suborganization</h1>
|
||||
|
||||
<p>
|
||||
|
|
|
@ -4,6 +4,25 @@
|
|||
{% block title %}Domain managers | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% if portfolio %}
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Domain managers</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
<h1>Domain managers</h1>
|
||||
|
||||
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
|
||||
<section aria-label="Domain Requests report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
|
|
|
@ -52,9 +52,12 @@ error messages, if necessary.
|
|||
{% if field.errors %}
|
||||
<div id="{{ widget.attrs.id }}__error-message">
|
||||
{% for error in field.errors %}
|
||||
<span class="usa-error-message" role="alert">
|
||||
{{ error }}
|
||||
</span>
|
||||
<div class="usa-error-message display-flex" role="alert">
|
||||
<svg class="usa-icon usa-icon--large" focusable="true" role="img" aria-label="Error">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
||||
</svg>
|
||||
<span class="margin-left-05">{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
|
|
|
@ -106,6 +106,26 @@
|
|||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% elif domain_permissions %}
|
||||
{% if value.permissions.all %}
|
||||
{% if value.permissions|length == 1 %}
|
||||
<p class="margin-top-0">{{ value.permissions.0.user.email }} </p>
|
||||
{% else %}
|
||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||
{% for item in value.permissions.all %}
|
||||
<li>{{ item.user.email }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if value.invitations.all %}
|
||||
<h4 class="h4--sm-05">Invited domain managers</h4>
|
||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||
{% for item in value.invitations.all %}
|
||||
<li>{{ item.email }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="margin-top-0 margin-bottom-0">
|
||||
{% if value %}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -824,6 +824,15 @@ class TestUser(TestCase):
|
|||
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_user_with_admin_portfolio_role(self):
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
self.assertFalse(self.user.is_portfolio_admin(portfolio))
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
self.assertTrue(self.user.is_portfolio_admin(portfolio))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self):
|
||||
# There is no portfolio referenced in session so should return 0
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.urls import reverse
|
|||
from django.contrib.auth import get_user_model
|
||||
from waffle.testutils import override_flag
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .common import MockEppLib, MockSESClient, create_user # type: ignore
|
||||
from django_webtest import WebTest # type: ignore
|
||||
import boto3_mocking # type: ignore
|
||||
|
@ -142,6 +142,7 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
def tearDown(self):
|
||||
try:
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainInvitation.objects.all().delete()
|
||||
if hasattr(self.domain, "contacts"):
|
||||
self.domain.contacts.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
|
@ -341,7 +342,7 @@ class TestDomainDetail(TestDomainOverview):
|
|||
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
|
||||
|
||||
self.assertNotContains(
|
||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||
detail_page, "If you need to make updates, contact one of the listed domain managers."
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -363,7 +364,12 @@ class TestDomainDetail(TestDomainOverview):
|
|||
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
],
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.client.force_login(user)
|
||||
|
@ -377,6 +383,45 @@ class TestDomainDetail(TestDomainOverview):
|
|||
)
|
||||
# Check that user does not have option to Edit domain
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
# Check that invited domain manager section not displayed when no invited domain managers
|
||||
self.assertNotContains(detail_page, "Invited domain managers")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_domain_readonly_on_detail_page_for_org_admin_not_manager(self):
|
||||
"""Test that a domain, which is part of a portfolio, but for which the user is not a domain manager,
|
||||
properly displays read only"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
# need to create a different user than self.user because the user needs permission assignments
|
||||
user = get_user_model().objects.create(
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="bogus@example.gov",
|
||||
phone="8003111234",
|
||||
title="test title",
|
||||
)
|
||||
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
|
||||
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
# add a domain invitation
|
||||
DomainInvitation.objects.get_or_create(email="invited@example.com", domain=domain)
|
||||
user.refresh_from_db()
|
||||
self.client.force_login(user)
|
||||
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||
# Check that alert message displays properly
|
||||
self.assertContains(
|
||||
detail_page,
|
||||
"If you need to make updates, contact one of the listed domain managers.",
|
||||
)
|
||||
# Check that user does not have option to Edit domain
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
# Check that invited domain manager is displayed
|
||||
self.assertContains(detail_page, "Invited domain managers")
|
||||
self.assertContains(detail_page, "invited@example.com")
|
||||
|
||||
|
||||
class TestDomainManagers(TestDomainOverview):
|
||||
|
|
|
@ -12,4 +12,4 @@ else
|
|||
npx gulp init
|
||||
npx gulp compile
|
||||
fi
|
||||
npx gulp watch
|
||||
npx gulp watchAll
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue