mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-16 20:49:00 -04:00
Merge branch 'main' of https://github.com/GSA/notifications-admin into 2568-css-cleanup
This commit is contained in:
100
app/assets/javascripts/notifyModal.js
Normal file
100
app/assets/javascripts/notifyModal.js
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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('<span class="dot-anim" aria-hidden="true"></span>');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
})();
|
||||
|
||||
8
app/assets/js/init.uswds.js
Normal file
8
app/assets/js/init.uswds.js
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -356,6 +356,19 @@ h2.recipient-list {
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -367,3 +380,7 @@ h2.recipient-list {
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
}
|
||||
|
||||
.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft">
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="sessionTimerHeading">
|
||||
<h2 class="usa-modal__heading font-body-lg" id="sessionTimerHeading">
|
||||
Your session will end soon.
|
||||
<span class="usa-sr-only">Please choose to extend your session or sign out. Your session will expire in 5 minutes or less.</span>
|
||||
</h2>
|
||||
@@ -176,7 +176,7 @@
|
||||
{% block extra_javascripts %}
|
||||
{% endblock %}
|
||||
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
|
||||
<script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script>
|
||||
<script src="{{ asset_url('js/uswds.min.js') }}"></script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{# Determine type of element to use, if not explicitly set -#}
|
||||
|
||||
{% if params.element %}
|
||||
{% set element = params.element | lower %}
|
||||
{% else %}
|
||||
@@ -10,26 +9,35 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{#- Define common attributes that we can use across all element types #}
|
||||
|
||||
{# Define common attributes to use across all element types -#}
|
||||
{%- set commonAttributes %} class="usa-button{% if params.classes %} {{ params.classes }}{% endif %}{% if params.disabled %} usa-button--disabled{% endif %}"{% for attribute, value in params.attributes %} {{attribute}}="{{value}}"{% endfor %}{% endset %}
|
||||
|
||||
{#- Define common attributes we can use for both button and input types #}
|
||||
|
||||
{# Define attributes for button/input -#}
|
||||
{%- set buttonAttributes %}{% if params.name %} name="{{ params.name | trim }}"{% endif %} type="{{ params.type if params.type else 'submit' }}"{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick %} data-prevent-double-click="true"{% endif %}{% endset %}
|
||||
|
||||
{#- Actually create a button... or a link! #}
|
||||
|
||||
{%- if element == 'a' %}
|
||||
<a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{- commonAttributes | safe }}>
|
||||
{# Auto-append .dot-anim span for Send/Schedule buttons -#}
|
||||
{%- set isSendOrSchedule = params.name and (params.name | lower == 'send' or params.name | lower == 'schedule') -%}
|
||||
{%- set textContent %}
|
||||
{{ params.html | safe if params.html else params.text }}
|
||||
</a>
|
||||
{%- if isSendOrSchedule -%}<span class="dot-anim" aria-hidden="true"></span>{%- endif -%}
|
||||
{%- endset %}
|
||||
|
||||
{%- elseif element == 'button' %}
|
||||
<button {%- if params.value %} value="{{ params.value }}"{% endif %} {{- buttonAttributes | safe }} {{- commonAttributes | safe }} {% if params.disabled %}disabled{% endif %}>
|
||||
{{ params.html | safe if params.html else params.text }}
|
||||
</button>
|
||||
{# Render the appropriate element -#}
|
||||
{% if element == 'a' %}
|
||||
<a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{ commonAttributes | safe }}>
|
||||
{{ textContent | safe }}
|
||||
</a>
|
||||
|
||||
{%- elseif element == 'input' %}
|
||||
<input value="{{ params.text }}" {{- buttonAttributes | safe }} {{- commonAttributes | safe }}>
|
||||
{%- endif %}
|
||||
{% elseif element == 'button' %}
|
||||
<button
|
||||
{% if params.value %} value="{{ params.value }}"{% endif %}
|
||||
{{ buttonAttributes | safe }}
|
||||
{{ commonAttributes | safe }}
|
||||
{% if params.disabled %}disabled{% endif %}
|
||||
>
|
||||
{{ textContent | safe }}
|
||||
</button>
|
||||
|
||||
{% elseif element == 'input' %}
|
||||
<input value="{{ params.text }}" {{ buttonAttributes | safe }} {{ commonAttributes | safe }}>
|
||||
{% endif %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
{% if choose_time_form %}
|
||||
{{ choose_time_form.scheduled_for(param_extensions={
|
||||
'formGroup': {'classes': 'bottom-gutter-2-3'},
|
||||
'formGroup': {'classes': ''},
|
||||
'attributes': {
|
||||
'data-module': 'radio-select',
|
||||
'data-categories': choose_time_form.scheduled_for.categories|join(','),
|
||||
@@ -39,7 +39,7 @@
|
||||
{% set button_text %}
|
||||
Preview
|
||||
{% endset %}
|
||||
{{ usaButton({ "text": button_text }) }}
|
||||
{{ usaButton({ "text": button_text, "classes": "margin-top-4" }) }}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{% block maincolumn_content %}
|
||||
|
||||
{{ page_header("Message status") }}
|
||||
{% if not job.finished_processing %}
|
||||
<p class="max-width-full">This page refreshes automatically to show the latest message activity delivery rates, details, and reports.<br>You can watch it in progress or check back later.</p>
|
||||
{% endif %}
|
||||
<div data-job-id="{{ job.id }}" data-feature="{{FEATURE_SOCKET_ENABLED | lower}}" data-host="{{ api_host_name }}">
|
||||
{% if not job.finished_processing %}
|
||||
<div
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{% if not error %}
|
||||
{% if choose_time_form %}
|
||||
{{ choose_time_form.scheduled_for(param_extensions={
|
||||
'formGroup': {'classes': 'bottom-gutter-2-3'},
|
||||
'formGroup': {'classes': ''},
|
||||
'attributes': {
|
||||
'data-module': 'radio-select',
|
||||
'data-categories': choose_time_form.scheduled_for.categories|join(','),
|
||||
@@ -66,8 +66,8 @@
|
||||
{% set button_text %}
|
||||
Preview
|
||||
{% endset %}
|
||||
{{ usaButton({ "text": button_text }) }}
|
||||
{% endif %}
|
||||
{{ usaButton({ "text": button_text, "classes": "margin-top-2" }) }}
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
help='3' if help else 0
|
||||
)}}" class='page-footer'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<h3>Does everything look good?</h3>
|
||||
<h3 class="margin-bottom-">Does everything look good?</h3>
|
||||
{% if not error %}
|
||||
{% set button_text %}
|
||||
{{ "Schedule" if scheduled_for else 'Send'}}
|
||||
@@ -84,9 +84,54 @@
|
||||
{{ usaButton({
|
||||
"text": button_text,
|
||||
"name": button_text
|
||||
}) }}
|
||||
}) }}
|
||||
{{ usaButton({
|
||||
"text": "Cancel",
|
||||
"name": "Cancel",
|
||||
"classes": "usa-button--secondary",
|
||||
"type": "button",
|
||||
"attributes": {
|
||||
"data-open-modal": "cancelModal"
|
||||
}
|
||||
}) }}
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="usa-modal"
|
||||
data-module="usa-modal"
|
||||
id="cancelModal"
|
||||
aria-hidden="true"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cancelModalHeading"
|
||||
aria-describedby="cancelModalDesc"
|
||||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading font-body-lg" id="cancelModalHeading">Are you sure you want to cancel this message?</h2>
|
||||
<p id="cancelModalDesc">Your template is saved, but your message and recipient details will not be. Canceling will bring you back to the template selection page.</p>
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{{ url_for('main.choose_template', service_id=current_service.id) }}" class="usa-button">Yes, cancel
|
||||
<span class="usa-sr-only">and return to the template selection page</span></a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="usa-button usa-button--unstyled padding-105 text-center" data-close-modal type="button">
|
||||
No, go back <span class="usa-sr-only">to message send</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="usa-button usa-modal__close" aria-label="Close this window" data-close-modal type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="grid-row bottom-gutter">
|
||||
{% for noti_type in global_stats %}
|
||||
<div class="grid-col-6">
|
||||
<span class="big-number-dark bottom-gutter-2-3">
|
||||
<span class="big-number-dark">
|
||||
<span class="big-number-number">
|
||||
{{ "{:,}".format(noti_type.black_box.number) }}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user