diff --git a/.ds.baseline b/.ds.baseline index 91907a154..c303c3c45 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -127,13 +127,24 @@ } ], "results": { + ".github/actions/deploy-proxy/action.yml": [ + { + "type": "Hex High Entropy String", + "filename": ".github/actions/deploy-proxy/action.yml", + "hashed_secret": "a6c13f5da3788e8d654cd24001dc79a238723248", + "is_verified": false, + "line_number": 18, + "is_secret": false + } + ], ".github/workflows/checks.yml": [ { "type": "Secret Keyword", "filename": ".github/workflows/checks.yml", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 68 + "line_number": 68, + "is_secret": false } ], "app/assets/js/uswds.min.js": [ @@ -633,5 +644,5 @@ } ] }, - "generated_at": "2025-05-01T13:44:35Z" + "generated_at": "2025-05-12T16:50:20Z" } diff --git a/.github/actions/deploy-proxy/action.yml b/.github/actions/deploy-proxy/action.yml index 0ffc05066..02393d6a1 100644 --- a/.github/actions/deploy-proxy/action.yml +++ b/.github/actions/deploy-proxy/action.yml @@ -15,7 +15,7 @@ inputs: default: https://github.com/GSA-TTS/cg-egress-proxy.git proxy_version: description: git ref to be deployed - default: main + default: 1500c67157c1a7a6fbbda7a2de172b3d0a67e703 runs: using: composite steps: diff --git a/.profile b/.profile index 6a37f1caf..eddc47e04 100644 --- a/.profile +++ b/.profile @@ -6,4 +6,4 @@ export NEW_RELIC_PROXY_HOST=$egress_proxy export http_proxy=$egress_proxy export https_proxy=$egress_proxy -export no_proxy="apps.internal" +export no_proxy="apps.internal,s3-fips.us-gov-west-1.amazonaws.com" diff --git a/Makefile b/Makefile index 34b26d2fb..b1e39e21b 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,6 @@ bootstrap: ## Set up everything to run the app .PHONY: bootstrap-with-git-hooks bootstrap-with-git-hooks: ## Sets everything up and accounts for pre-existing git hooks make generate-version-file - poetry self add poetry-dotenv-plugin poetry lock --no-update poetry install --sync --no-root poetry run playwright install --with-deps diff --git a/app/assets/javascripts/notifyModal.js b/app/assets/javascripts/notifyModal.js new file mode 100644 index 000000000..ccda2a1a2 --- /dev/null +++ b/app/assets/javascripts/notifyModal.js @@ -0,0 +1,100 @@ +let activeModal = null; +let lastFocusedElement = null; + +function openModal(modalId) { + const wrapper = document.getElementById(modalId); + if (!wrapper) return; + + const modal = wrapper.querySelector('.usa-modal, dialog'); + if (!modal) return; + + lastFocusedElement = document.activeElement; + + wrapper.classList.remove('is-hidden'); + modal.removeAttribute('aria-hidden'); + modal.removeAttribute('inert'); + modal.removeAttribute('hidden'); + document.body.classList.add('modal-open'); + + + // Set focus to the first focusable element inside modal + const focusTarget = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusTarget) focusTarget.focus(); + + modal.addEventListener('keydown', function(e) { + if (e.key !== 'Tab') return; + + const focusableElements = modal.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + }); + + activeModal = wrapper; +} + +function closeModal() { + if (!activeModal) return; + + const modal = activeModal.querySelector('.usa-modal, dialog'); + if (modal) { + modal.setAttribute('aria-hidden', 'true'); + modal.setAttribute('inert', ''); + modal.setAttribute('hidden', ''); + } + + activeModal.classList.add('is-hidden'); + document.body.classList.remove('modal-open'); + + if (lastFocusedElement) lastFocusedElement.focus(); + + activeModal = null; + +} + +function attachModalTriggers() { + document.querySelectorAll('[data-open-modal]').forEach(btn => { + btn.addEventListener('click', () => { + const modalId = btn.getAttribute('data-open-modal'); + openModal(modalId); + }); + }); + + document.querySelectorAll('[data-close-modal]').forEach(btn => { + btn.addEventListener('click', () => { + closeModal(); + }); + }); +} + +// Escape key closes modal +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && activeModal) { + closeModal(); + } +}); + +// Optional: click outside modal closes it +document.addEventListener('click', (e) => { + if (activeModal && e.target.classList.contains('usa-modal-overlay')) { + closeModal(); + } +}); + +document.addEventListener('DOMContentLoaded', () => { + attachModalTriggers(); +}); + +// ✅ Check if we're in a Node.js environment (for Jest) before using `module.exports` +if (typeof module !== "undefined" && typeof module.exports !== "undefined") { + module.exports = { closeModal, openModal, attachModalTriggers }; +} diff --git a/app/assets/javascripts/preventDuplicateFormSubmissions.js b/app/assets/javascripts/preventDuplicateFormSubmissions.js index e3221d9d2..c62aaa9ec 100644 --- a/app/assets/javascripts/preventDuplicateFormSubmissions.js +++ b/app/assets/javascripts/preventDuplicateFormSubmissions.js @@ -1,37 +1,45 @@ -(function() { - +(function () { "use strict"; - let disableSubmitButtons = function(event) { - - var $submitButton = $(this).find(':submit'); - - if ($submitButton.data('clicked') == 'true') { + const disableSubmitButtons = function (event) { + const $submitButton = $(this).find(':submit'); + if ($submitButton.data('clicked') === 'true') { event.preventDefault(); + return; + } - } else { + $submitButton.data('clicked', 'true'); - $submitButton.data('clicked', 'true'); + // Add dot animation for Send/Schedule/Cancel buttons + const buttonName = $submitButton.attr('name')?.toLowerCase(); + if (["send", "schedule", "cancel"].includes(buttonName)) { + $submitButton.prop('disabled', true); - if ($submitButton.is('[name="Send"], [name="Schedule"]')) { - $submitButton.prop('disabled', true); - - setTimeout(() => { - renableSubmitButton($submitButton); - }, 10000); - } else { - setTimeout(renableSubmitButton($submitButton), 1500); + // Inject dot animation span if not already present + if ($submitButton.find('.dot-anim').length === 0) { + $submitButton.append(''); } + + // Disable Cancel button too + const $cancelButton = $('button[name]').filter(function () { + return $(this).attr('name')?.toLowerCase() === 'cancel'; + }); + $cancelButton.prop('disabled', true); + + setTimeout(() => { + renableSubmitButton($submitButton); + }, 10000); // fallback safety + } else { + setTimeout(renableSubmitButton($submitButton), 1500); } }; - let renableSubmitButton = $submitButton => () => { - + const renableSubmitButton = ($submitButton) => () => { $submitButton.data('clicked', ''); $submitButton.prop('disabled', false); + $submitButton.find('.dot-anim').remove(); // clean up if needed }; $('form').on('submit', disableSubmitButtons); - })(); diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js index e8e212257..c0bf0cb5e 100644 --- a/app/assets/javascripts/radioSelect.js +++ b/app/assets/javascripts/radioSelect.js @@ -38,7 +38,7 @@ {{/choices}} - + `), 'chosen': Hogan.compile(` diff --git a/app/assets/js/init.uswds.js b/app/assets/js/init.uswds.js new file mode 100644 index 000000000..688fff06f --- /dev/null +++ b/app/assets/js/init.uswds.js @@ -0,0 +1,8 @@ +document.addEventListener("DOMContentLoaded", function () { + if (window.uswds && typeof window.uswds.init === 'function') { + console.log("Calling USWDS init"); + window.uswds.init(); + } else { + console.error("USWDS not found or init is not a function"); + } + }); diff --git a/app/assets/sass/uswds/_main.scss b/app/assets/sass/uswds/_main.scss index 88e44042d..2933f8316 100644 --- a/app/assets/sass/uswds/_main.scss +++ b/app/assets/sass/uswds/_main.scss @@ -351,7 +351,24 @@ h2.recipient-list { margin-top: units(1); } +.usa-search .search-form__button { + height: 40px; +} + // Button ellipses loading +.dot-anim { + display: inline-block; + margin-left: 0; /* remove left margin if it exists */ + padding-left: 0; + font-size: 1em; + animation: dots 1s steps(3, end) infinite; +} + +/* Optional: reduce spacing by removing whitespace node */ +button span.dot-anim { + margin-left: 0; /* forces no space even if white-space exists */ +} + .dot-anim::after { content: '.'; animation: dotPulse 1.5s steps(3, end) infinite; @@ -363,3 +380,7 @@ h2.recipient-list { 66% { content: '..'; } 100% { content: '...'; } } + +.modal-open { + overflow: hidden; +} diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index 31cbeb3fa..737d45172 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -179,10 +179,19 @@ td.table-empty-message { display: flex; flex-wrap: wrap; justify-content: space-between; - padding: 1rem; + padding: 1rem 0; } .user-list-item { + border: 2px solid color('gray-5'); + padding: units(2); + button, .usa-accordion__content { + background-color: white; + border: 2px solid color('gray-5'); + } + button:hover, button[aria-expanded="true"] { + background-color: color('gray-5'); + } @include at-media(desktop) { width: calc(50% - units(1)); margin-bottom: 1rem; @@ -199,7 +208,6 @@ td.table-empty-message { } .hint { - display: block; font-size: size('body', 'sm'); font-weight: normal; } diff --git a/app/main/views/send.py b/app/main/views/send.py index 61e0c0083..b1b393a90 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -566,18 +566,18 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): "url": url_for( "main.send_one_off", service_id=service_id, template_id=template.id ), - "text": "Back to message personalization" + "text": "Back to message personalization", }, - "html": "Back to message personalization" + "html": "Back to message personalization", } back_link_from_preview = { "href": { "url": url_for( "main.send_one_off", service_id=service_id, template_id=template.id ), - "text": "Back to message personalization" + "text": "Back to message personalization", }, - "html": "Back to message personalization" + "html": "Back to message personalization", } choose_time_form = None else: @@ -586,9 +586,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): "url": url_for( "main.send_messages", service_id=service_id, template_id=template.id ), - "text": "Back to upload a file" + "text": "Back to upload a file", }, - "html": "Back to upload a file" + "html": "Back to upload a file", } back_link_from_preview = { "href": { @@ -598,9 +598,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): template_id=template.id, upload_id=upload_id, ), - "text": "Back to check messages" + "text": "Back to check messages", }, - "html": "Back to check messages" + "html": "Back to check messages", } choose_time_form = ChooseTimeForm() @@ -786,9 +786,9 @@ def get_back_link( service_id=service_id, template_id=template.id, ), - "text": "Back to select delivery time" + "text": "Back to select delivery time", }, - "html": "Back to select delivery time" + "html": "Back to select delivery time", } if step_index == 0: @@ -799,9 +799,9 @@ def get_back_link( ".choose_template", service_id=service_id, ), - "text": "Back to all templates" + "text": "Back to all templates", }, - "html": "Back to all templates" + "html": "Back to all templates", } else: return { @@ -811,14 +811,16 @@ def get_back_link( service_id=service_id, template_id=template.id, ), - "text": "Back to confirm your template" + "text": "Back to confirm your template", }, - "html": "Back to confirm your template" + "html": "Back to confirm your template", } # fallback for other steps back_to_text = ( - "Back to select recipients" if step_index == 1 else "Back to message personalization" + "Back to select recipients" + if step_index == 1 + else "Back to message personalization" ) return { @@ -829,9 +831,9 @@ def get_back_link( template_id=template.id, step_index=step_index - 1, ), - "text": back_to_text + "text": back_to_text, }, - "html": back_to_text + "html": back_to_text, } diff --git a/app/main/views/tour.py b/app/main/views/tour.py index e253167c8..d42ff1edf 100644 --- a/app/main/views/tour.py +++ b/app/main/views/tour.py @@ -142,13 +142,11 @@ def _get_tour_step_back_link(service_id, template_id, step_index): return { "href": { "url": url_for( - 'main.begin_tour', - service_id=service_id, - template_id=template_id + "main.begin_tour", service_id=service_id, template_id=template_id ), - "text": "Back to tour start" + "text": "Back to tour start", }, - "html": "Back to tour start" + "html": "Back to tour start", } else: return { @@ -207,9 +205,9 @@ def check_tour_notification(service_id, template_id): template_id=template_id, step_index=len(placeholders), ), - "text": "Back to previous step" + "text": "Back to previous step", }, - "html": "Back to previous step" + "html": "Back to previous step", } template.values = get_recipient_and_placeholders_from_session( diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index 4d8f33a07..426191aec 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -31,7 +31,7 @@ def get_csv_upload(service_id, upload_id): def remove_blank_lines(filedata): # sometimes people upload files with hundreds of blank lines at the end data = filedata["data"] - cleaned_data = "\n".join(line for line in data.splitlines() if line.strip()) + cleaned_data = "\r\n".join(line for line in data.splitlines() if line.strip()) filedata["data"] = cleaned_data return filedata diff --git a/app/templates/base.html b/app/templates/base.html index c75fae406..b647a21a8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -65,7 +65,7 @@ {% endblock %} {% block main %} -