Fixed merge conflicts

This commit is contained in:
alexjanousekGSA
2025-02-03 11:51:05 -05:00
26 changed files with 1024 additions and 2054 deletions
+11
View File
@@ -64,6 +64,17 @@ body:
validations: validations:
required: false required: false
- type: markdown
attributes:
value: '**Accessibility:**'
- type: textarea
id: accessibility
attributes:
label: "List any specific accessibility guidance or tests that need to be considered for this user story."
description: "List what type of accessibility tests need to pass."
validations:
required: false
- type: markdown - type: markdown
attributes: attributes:
value: '**Notes:**' value: '**Notes:**'
+3 -1
View File
@@ -23,5 +23,7 @@ Please enter a detailed description here.
## A11y Checks (if applicable) ## A11y Checks (if applicable)
* Conduct automated tests through [AxeDevTools](https://www.deque.com/axe/devtools/) and [WAVE](https://wave.webaim.org/) * Double check work is getting picked up by the automated E2E tests
* Conduct browser-based tests through [AxeDevTools](https://www.deque.com/axe/devtools/) and [WAVE](https://wave.webaim.org/)
* Review the [Manual Checklist](https://docs.google.com/document/d/192bBXStebdXWtYhZQ73qaWMJhGcuSB1W6c9YBXhWZvc/edit?usp=sharing) * Review the [Manual Checklist](https://docs.google.com/document/d/192bBXStebdXWtYhZQ73qaWMJhGcuSB1W6c9YBXhWZvc/edit?usp=sharing)
* Make sure there are no linting errors in VSCode or other IDE of choice
+2 -1
View File
@@ -165,8 +165,9 @@ jobs:
run: make run-flask & run: make run-flask &
env: env:
NOTIFY_ENVIRONMENT: scanning NOTIFY_ENVIRONMENT: scanning
FEATURE_ABOUT_PAGE_ENABLED: true
- name: Run OWASP Baseline Scan - name: Run OWASP Baseline Scan
uses: zaproxy/action-baseline@v0.9.0 uses: zaproxy/action-baseline@v0.14.0
with: with:
docker_name: "ghcr.io/zaproxy/zaproxy:weekly" docker_name: "ghcr.io/zaproxy/zaproxy:weekly"
target: "http://localhost:6012" target: "http://localhost:6012"
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
env: env:
NOTIFY_ENVIRONMENT: scanning NOTIFY_ENVIRONMENT: scanning
- name: Run OWASP Full Scan - name: Run OWASP Full Scan
uses: zaproxy/action-full-scan@v0.7.0 uses: zaproxy/action-full-scan@v0.12.0
with: with:
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
target: 'http://localhost:6012' target: 'http://localhost:6012'
+89 -41
View File
@@ -1,10 +1,14 @@
(function (window) { (function (window) {
if (document.getElementById('activityChartContainer')) { if (document.getElementById('activityChartContainer')) {
let currentType = 'service';
const tableContainer = document.getElementById('activityContainer');
const currentUserName = tableContainer.getAttribute('data-currentUserName');
const currentServiceId = tableContainer.getAttribute('data-currentServiceId');
const COLORS = { const COLORS = {
delivered: '#0076d6', delivered: '#0076d6',
failed: '#fa9441', failed: '#fa9441',
pending: '#C7CACE',
text: '#666' text: '#666'
}; };
@@ -12,7 +16,7 @@
const FONT_WEIGHT = 'bold'; const FONT_WEIGHT = 'bold';
const MAX_Y = 120; const MAX_Y = 120;
const createChart = function(containerId, labels, deliveredData, failedData) { const createChart = function(containerId, labels, deliveredData, failedData, pendingData) {
const container = d3.select(containerId); const container = d3.select(containerId);
container.selectAll('*').remove(); // Clear any existing content container.selectAll('*').remove(); // Clear any existing content
@@ -35,7 +39,7 @@
} }
// Calculate total messages // Calculate total messages
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData); const totalMessages = d3.sum(deliveredData) + d3.sum(failedData) + d3.sum(pendingData);
// Create legend only if there are messages // Create legend only if there are messages
const legendContainer = d3.select('.chart-legend'); const legendContainer = d3.select('.chart-legend');
@@ -45,7 +49,8 @@
// Show legend if there are messages // Show legend if there are messages
const legendData = [ const legendData = [
{ label: 'Delivered', color: COLORS.delivered }, { label: 'Delivered', color: COLORS.delivered },
{ label: 'Failed', color: COLORS.failed } { label: 'Failed', color: COLORS.failed },
{ label: 'Pending', color: COLORS.pending }
]; ];
const legendItem = legendContainer.selectAll('.legend-item') const legendItem = legendContainer.selectAll('.legend-item')
@@ -76,8 +81,9 @@
.range([0, width]) .range([0, width])
.padding(0.1); .padding(0.1);
// Adjust the y-axis domain to add some space above the tallest bar // Adjust the y-axis domain to add some space above the tallest bar
const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0))); const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0) + (pendingData[i] || 0)));
const y = d3.scaleSqrt()
const y = d3.scaleSymlog()
.domain([0, maxY + 2]) // Add 2 units of space at the top .domain([0, maxY + 2]) // Add 2 units of space at the top
.nice() .nice()
.range([height, 0]); .range([height, 0]);
@@ -89,7 +95,7 @@
// Generate the y-axis with whole numbers // Generate the y-axis with whole numbers
const yAxis = d3.axisLeft(y) const yAxis = d3.axisLeft(y)
.ticks(Math.min(maxY + 2, 10)) // Generate up to 10 ticks based on the data .ticks(Math.min(maxY + 2, 3))
.tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis .tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis
svg.append('g') svg.append('g')
@@ -100,12 +106,13 @@
const stackData = labels.map((label, i) => ({ const stackData = labels.map((label, i) => ({
label: label, label: label,
delivered: deliveredData[i], delivered: deliveredData[i],
failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0 failed: failedData[i] || 0,
pending: pendingData[i] || 0
})); }));
// Stack the data // Stack the data
const stack = d3.stack() const stack = d3.stack()
.keys(['delivered', 'failed']) .keys(['delivered', 'failed', 'pending'])
.order(d3.stackOrderNone) .order(d3.stackOrderNone)
.offset(d3.stackOffsetNone); .offset(d3.stackOffsetNone);
@@ -113,8 +120,8 @@
// Color scale // Color scale
const color = d3.scaleOrdinal() const color = d3.scaleOrdinal()
.domain(['delivered', 'failed']) .domain(['delivered', 'failed', 'pending'])
.range([COLORS.delivered, COLORS.failed]); .range([COLORS.delivered, COLORS.failed, COLORS.pending]);
// Create bars with animation // Create bars with animation
const barGroups = svg.selectAll('.bar-group') const barGroups = svg.selectAll('.bar-group')
@@ -152,7 +159,7 @@
}; };
// Function to create an accessible table // Function to create an accessible table
const createTable = function(tableId, chartType, labels, deliveredData, failedData) { const createTable = function(tableId, chartType, labels, deliveredData, failedData, pendingData) {
const table = document.getElementById(tableId); const table = document.getElementById(tableId);
table.innerHTML = ""; // Clear previous data table.innerHTML = ""; // Clear previous data
@@ -164,7 +171,7 @@
// Create table header // Create table header
const headerRow = document.createElement('tr'); const headerRow = document.createElement('tr');
const headers = ['Day', 'Delivered', 'Failed']; const headers = ['Day', 'Delivered', 'Failed', 'Pending'];
headers.forEach(headerText => { headers.forEach(headerText => {
const th = document.createElement('th'); const th = document.createElement('th');
th.textContent = headerText; th.textContent = headerText;
@@ -187,6 +194,10 @@
cellFailed.textContent = failedData[index]; cellFailed.textContent = failedData[index];
row.appendChild(cellFailed); row.appendChild(cellFailed);
const cellPending = document.createElement('td');
cellPending.textContent = pendingData[index];
row.appendChild(cellPending);
tbody.appendChild(row); tbody.appendChild(row);
}); });
@@ -196,6 +207,7 @@
}; };
const fetchData = function(type) { const fetchData = function(type) {
var ctx = document.getElementById('weeklyChart'); var ctx = document.getElementById('weeklyChart');
if (!ctx) { if (!ctx) {
return; return;
@@ -203,9 +215,11 @@
// get local timezone // get local timezone
var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
var url = type === 'service'
? `/daily_stats.json?timezone=${encodeURIComponent(userTimezone)}` // build the URL depending on "type"
: `/daily_stats_by_user.json`; var url = (type === 'service')
? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}`
: `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`;
return fetch(url) return fetch(url)
.then(response => { .then(response => {
@@ -220,7 +234,7 @@
labels = []; labels = [];
deliveredData = []; deliveredData = [];
failedData = []; failedData = [];
pendingData = [];
let totalMessages = 0; let totalMessages = 0;
for (var dateString in data) { for (var dateString in data) {
@@ -231,6 +245,8 @@
labels.push(formattedDate); labels.push(formattedDate);
deliveredData.push(data[dateString].sms.delivered); deliveredData.push(data[dateString].sms.delivered);
failedData.push(data[dateString].sms.failure); failedData.push(data[dateString].sms.failure);
pendingData.push(data[dateString].sms.pending || 0);
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure + data[dateString].sms.pending;
// Calculate the total number of messages // Calculate the total number of messages
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure; totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure;
@@ -259,17 +275,18 @@
} }
} else { } else {
// If there are messages, create the chart and table // If there are messages, create the chart and table
createChart('#weeklyChart', labels, deliveredData, failedData); createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
} }
return data;
})
.catch(error => console.error('Error fetching daily stats:', error));
};
return data;
})
.catch(error => console.error('Error fetching daily stats:', error));
};
setInterval(() => fetchData(currentType), 25000);
const handleDropdownChange = function(event) { const handleDropdownChange = function(event) {
const selectedValue = event.target.value; const selectedValue = event.target.value;
currentType = selectedValue;
const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`); const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`);
const selectElement = document.getElementById('options'); const selectElement = document.getElementById('options');
const selectedText = selectElement.options[selectElement.selectedIndex].text; const selectedText = selectElement.options[selectElement.selectedIndex].text;
@@ -277,36 +294,67 @@
subTitle.textContent = `${selectedText} - last 7 days`; subTitle.textContent = `${selectedText} - last 7 days`;
fetchData(selectedValue); fetchData(selectedValue);
// Update ARIA live region
const liveRegion = document.getElementById('aria-live-account'); const liveRegion = document.getElementById('aria-live-account');
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`; liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
// Switch tables based on dropdown selection const tableHeading = document.querySelector('#tableActivity h2');
const selectedTable = selectedValue === "individual" ? "table1" : "table2"; const senderColumns = document.querySelectorAll('.sender-column');
const tables = document.querySelectorAll('.table-overflow-x-auto'); const allRows = document.querySelectorAll('#activity-table tbody tr');
tables.forEach(function(table) { const caption = document.querySelector('#activity-table caption');
table.classList.add('hidden'); // Hide all tables by adding the hidden class
table.classList.remove('visible'); // Ensure they are not visible if (selectedValue === 'individual') {
});
const tableToShow = document.getElementById(selectedTable); tableHeading.textContent = 'My activity';
tableToShow.classList.remove('hidden'); // Remove hidden class caption.textContent = `Table showing the sent jobs for ${currentUserName}`;
tableToShow.classList.add('visible'); // Add visible class
senderColumns.forEach(col => {
col.style.display = 'none';
});
allRows.forEach(row => row.style.display = 'none');
const userRows = Array.from(allRows).filter(row => {
const senderCell = row.querySelector('.sender-column');
const rowSender = senderCell ? senderCell.textContent.trim() : '';
return rowSender === currentUserName;
});
userRows.slice(0, 5).forEach(row => {
row.style.display = '';
});
} else {
tableHeading.textContent = 'Service activity';
caption.textContent = `Table showing the sent jobs for service`;
senderColumns.forEach(col => {
col.style.display = '';
});
allRows.forEach((row, index) => {
row.style.display = (index < 5) ? '' : 'none';
});
}
}; };
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize activityChart chart and table with service data by default // Initialize activityChart chart and table with service data by default
fetchData('service'); fetchData(currentType);
const allRows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
allRows.forEach((row, index) => {
row.style.display = (index < 5) ? '' : 'none';
});
// Add event listener to the dropdown
const dropdown = document.getElementById('options'); const dropdown = document.getElementById('options');
dropdown.addEventListener('change', handleDropdownChange); dropdown.addEventListener('change', handleDropdownChange);
}); });
// Resize chart on window resize // Resize chart on window resize
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) { if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0 && pendingData.length > 0) {
createChart('#weeklyChart', labels, deliveredData, failedData); createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
} }
}); });
@@ -1024,3 +1024,7 @@ nav.nav {
font-size: units(3); font-size: units(3);
font-weight: bold; font-weight: bold;
} }
.form-control-error {
border: 4px solid #b10e1e
}
+12 -30
View File
@@ -14,7 +14,7 @@ from app import (
service_api_client, service_api_client,
template_statistics_client, template_statistics_client,
) )
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left from app.formatters import format_date_numeric, format_datetime_numeric
from app.main import main from app.main import main
from app.main.views.user_profile import set_timezone from app.main.views.user_profile import set_timezone
from app.statistics_utils import get_formatted_percentage from app.statistics_utils import get_formatted_percentage
@@ -62,41 +62,23 @@ def service_dashboard(service_id):
job_response = job_api_client.get_jobs(service_id)["data"] job_response = job_api_client.get_jobs(service_id)["data"]
service_data_retention_days = 7 service_data_retention_days = 7
jobs = [ filtered_jobs = [job for job in job_response if job["job_status"] != "cancelled"]
{ sorted_jobs = sorted(filtered_jobs, key=lambda job: job["created_at"], reverse=True)
"job_id": job["id"],
"time_left": get_time_left(job["created_at"]),
"download_link": url_for(
".view_job_csv", service_id=current_service.id, job_id=job["id"]
),
"view_job_link": url_for(
".view_job", service_id=current_service.id, job_id=job["id"]
),
"created_at": job["created_at"],
"processing_finished": job.get("processing_finished"),
"processing_started": job.get("processing_started"),
"notification_count": job["notification_count"],
"created_by": job["created_by"],
"template_name": job["template_name"],
"original_file_name": job["original_file_name"],
}
for job in job_response
if job["job_status"] != "cancelled"
]
return render_template( return render_template(
"views/dashboard/dashboard.html", "views/dashboard/dashboard.html",
updates_url=url_for(".service_dashboard_updates", service_id=service_id), updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id), partials=get_dashboard_partials(service_id),
jobs=jobs, jobs=sorted_jobs,
service_data_retention_days=service_data_retention_days, service_data_retention_days=service_data_retention_days,
sms_sent=sms_sent, sms_sent=sms_sent,
sms_allowance_remaining=sms_allowance_remaining, sms_allowance_remaining=sms_allowance_remaining,
) )
@main.route("/daily_stats.json") @main.route("/services/<uuid:service_id>/daily-stats.json")
def get_daily_stats(): @user_has_permissions()
service_id = session.get("service_id") def get_daily_stats(service_id):
date_range = get_stats_date_range() date_range = get_stats_date_range()
# Get timezone from request (default to UTC if not provided) # Get timezone from request (default to UTC if not provided)
@@ -109,14 +91,14 @@ def get_daily_stats():
return jsonify(stats) return jsonify(stats)
@main.route("/daily_stats_by_user.json") @main.route("/services/<uuid:service_id>/daily-stats-by-user.json")
def get_daily_stats_by_user(): @user_has_permissions()
def get_daily_stats_by_user(service_id):
service_id = session.get("service_id") service_id = session.get("service_id")
date_range = get_stats_date_range() date_range = get_stats_date_range()
user_id = current_user.id
stats = service_api_client.get_user_service_notification_statistics_by_day( stats = service_api_client.get_user_service_notification_statistics_by_day(
service_id, service_id,
user_id, user_id=current_user.id,
start_date=date_range["start_date"], start_date=date_range["start_date"],
days=date_range["days"], days=date_range["days"],
) )
+3 -2
View File
@@ -57,7 +57,6 @@ def view_job(service_id, job_id):
filter_args = parse_filter_args(request.args) filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args) filter_args["status"] = set_status_filters(filter_args)
return render_template( return render_template(
"views/jobs/job.html", "views/jobs/job.html",
job=job, job=job,
@@ -402,7 +401,9 @@ def get_job_partials(job):
) )
if request.referrer is not None: if request.referrer is not None:
session["arrived_from_preview_page"] = "check" in request.referrer session["arrived_from_preview_page"] = ("check" in request.referrer) or (
"help=0" in request.referrer
)
else: else:
session["arrived_from_preview_page"] = False session["arrived_from_preview_page"] = False
+37 -3
View File
@@ -6,17 +6,36 @@ from app.notify_client import NotifyAdminAPIClient
class BillingAPIClient(NotifyAdminAPIClient): class BillingAPIClient(NotifyAdminAPIClient):
def get_monthly_usage_for_service(self, service_id, year): def get_monthly_usage_for_service(self, service_id, year):
return self.get( monthly_usage = redis_client.get(f"monthly-usage-summary-{service_id}-{year}")
if monthly_usage is not None:
return json.loads(monthly_usage.decode("utf-8"))
result = self.get(
"/service/{0}/billing/monthly-usage".format(service_id), "/service/{0}/billing/monthly-usage".format(service_id),
params=dict(year=year), params=dict(year=year),
) )
redis_client.set(
f"monthly-usage-summary-{service_id}-{year}",
json.dumps(result),
ex=30,
)
return result
def get_annual_usage_for_service(self, service_id, year=None): def get_annual_usage_for_service(self, service_id, year=None):
return self.get( annual_usage = redis_client.get(f"yearly-usage-summary-{service_id}-{year}")
if annual_usage is not None:
return json.loads(annual_usage.decode("utf-8"))
result = self.get(
"/service/{0}/billing/yearly-usage-summary".format(service_id), "/service/{0}/billing/yearly-usage-summary".format(service_id),
params=dict(year=year), params=dict(year=year),
) )
redis_client.set(
f"yearly-usage-summary-{service_id}-{year}",
json.dumps(result),
ex=30,
)
return result
def get_free_sms_fragment_limit_for_year(self, service_id, year=None): def get_free_sms_fragment_limit_for_year(self, service_id, year=None):
frag_limit = redis_client.get(f"free-sms-fragment-limit-{service_id}-{year}") frag_limit = redis_client.get(f"free-sms-fragment-limit-{service_id}-{year}")
if frag_limit is not None: if frag_limit is not None:
@@ -48,13 +67,28 @@ class BillingAPIClient(NotifyAdminAPIClient):
) )
def get_data_for_billing_report(self, start_date, end_date): def get_data_for_billing_report(self, start_date, end_date):
return self.get( x_start_date = str(start_date)
x_start_date = x_start_date.replace(" ", "_")
x_end_date = str(end_date)
x_end_date = x_end_date.replace(" ", "_")
billing_data = redis_client.get(
f"get-data-for-billing-report-{x_start_date}-{x_end_date}"
)
if billing_data is not None:
return json.loads(billing_data.decode("utf-8"))
result = self.get(
url="/platform-stats/data-for-billing-report", url="/platform-stats/data-for-billing-report",
params={ params={
"start_date": str(start_date), "start_date": str(start_date),
"end_date": str(end_date), "end_date": str(end_date),
}, },
) )
redis_client.set(
f"get-data-for-billing-report-{x_start_date}-{x_end_date}",
json.dumps(result),
ex=30,
)
return result
def get_data_for_volumes_by_service_report(self, start_date, end_date): def get_data_for_volumes_by_service_report(self, start_date, end_date):
return self.get( return self.get(
+17 -3
View File
@@ -1,3 +1,6 @@
import json
from app.extensions import redis_client
from app.notify_client import NotifyAdminAPIClient, _attach_current_user from app.notify_client import NotifyAdminAPIClient, _attach_current_user
@@ -41,7 +44,7 @@ class NotificationApiClient(NotifyAdminAPIClient):
if job_id: if job_id:
return method( return method(
url="/service/{}/job/{}/notifications".format(service_id, job_id), url="/service/{}/job/{}/notifications".format(service_id, job_id),
**kwargs **kwargs,
) )
else: else:
if limit_days is not None: if limit_days is not None:
@@ -96,9 +99,20 @@ class NotificationApiClient(NotifyAdminAPIClient):
) )
def get_notification_count_for_job_id(self, *, service_id, job_id): def get_notification_count_for_job_id(self, *, service_id, job_id):
return self.get( counts = redis_client.get(
f"notification-count-for-job-id-{service_id}-{job_id}"
)
if counts is not None:
return json.loads(counts.decode("utf-8"))
result = self.get(
url="/service/{}/job/{}/notification_count".format(service_id, job_id) url="/service/{}/job/{}/notification_count".format(service_id, job_id)
)["count"] )
redis_client.set(
f"notification-count-for-job-id-{service_id}-{job_id}",
json.dumps(result["count"]),
ex=30,
)
return result["count"]
notification_api_client = NotificationApiClient() notification_api_client = NotificationApiClient()
@@ -34,7 +34,7 @@
attributes: params.errorMessage.attributes, attributes: params.errorMessage.attributes,
html: params.errorMessage.html, html: params.errorMessage.html,
text: params.errorMessage.text, text: params.errorMessage.text,
visuallyHiddenText: params.errorMessage.visuallyHiddenText visuallyHiddenText: params.errorMessage.visuallyHiddenText,
}) | indent(2) | trim }} }) | indent(2) | trim }}
{% endif %} {% endif %}
<input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}" <input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}"
@@ -42,5 +42,7 @@
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %} {%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %} {%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %} {%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}> {%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
{%- if params.required %} required{% endif %}
/>
</div> </div>
+9 -11
View File
@@ -16,19 +16,9 @@
placeholder='' placeholder=''
) %} ) %}
<div <div
class="form-group{% if field.errors %} form-group-error{% endif %} {{ extra_form_group_classes }}" class="usa-form-group{% if field.errors %} usa-form-group--error{% endif %} {{ extra_form_group_classes }}"
data-module="{% if autofocus %}autofocus{% elif colour_preview %}colour-preview{% endif %}" data-module="{% if autofocus %}autofocus{% elif colour_preview %}colour-preview{% endif %}"
> >
{% if field.errors %}
<div class="usa-alert usa-alert--error edit-textbox-error-mt" role="alert">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">Error message</h4>
<p class="usa-alert__text" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</p>
</div>
</div>
{% endif %}
<label class="usa-label" for="{{ field.name }}"> <label class="usa-label" for="{{ field.name }}">
{% if label %} {% if label %}
{{ label }} {{ label }}
@@ -41,6 +31,12 @@
{{ hint }} {{ hint }}
</div> </div>
{% endif %} {% endif %}
{% if field.errors %}
<span id="{{ field.name}}-error" class="usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
<span class="usa-sr-only">Error:</span>
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</span>
{% endif %}
{% {%
if highlight_placeholders or autosize if highlight_placeholders or autosize
%} %}
@@ -59,6 +55,8 @@
data_highlight_placeholders='true' if highlight_placeholders else 'false', data_highlight_placeholders='true' if highlight_placeholders else 'false',
rows=rows|string, rows=rows|string,
placeholder=placeholder, placeholder=placeholder,
aria_describedby=field.name+"-error",
required='required' if required else None,
**kwargs **kwargs
) }} ) }}
{% if suffix %} {% if suffix %}
+39 -11
View File
@@ -21,11 +21,11 @@
<div class="ajax-block-container"> <div class="ajax-block-container">
<p class='bottom-gutter'> <p class='bottom-gutter'>
{% if job.still_processing or arrived_from_preview_page_url %} {% if not job.finished_processing %}
{% if job.scheduled_for %} {% if job.scheduled_for %}
<div class="usa-alert usa-alert--info"> <div class="usa-alert usa-alert--info">
<div class="usa-alert__body"> <div class="usa-alert__body">
<h2 class="usa-alert__heading">Your text has been scheduled</h2> <h2 class="usa-alert__heading">Your message has been scheduled</h2>
<p class="usa-alert__text"> <p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }} was scheduled on {{ job.scheduled_for|format_datetime_normal }} by {{ job.created_by.name }} {{ job.template_name }} - {{ current_service.name }} was scheduled on {{ job.scheduled_for|format_datetime_normal }} by {{ job.created_by.name }}
</p> </p>
@@ -33,18 +33,46 @@
</div> </div>
{{display_message_status}} {{display_message_status}}
{% else %} {% else %}
<div class="usa-alert usa-alert--success"> {% if job.processing_started %}
<div class="usa-alert__body"> <div class="usa-alert usa-alert--success">
<h2 class="usa-alert__heading">Your text has been sent</h2> <div class="usa-alert__body">
<p class="usa-alert__text"> <h2 class="usa-alert__heading">
{{ job.template_name }} - {{ current_service.name }} was sent on {% if job.processing_started %} Your message is sending
{{ job.processing_started|format_datetime_table }} {% else %} </h2>
{{ job.created_at|format_datetime_table }} {% endif %} by {{ job.created_by.name }} <p class="usa-alert__text">
</p> {{ job.template_name }} - {{ current_service.name }}
has been sending since {{job.processing_started| format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
</div> </div>
</div> {% else %}
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">
Your message is pending
</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }}
has been pending since {{job.created_at|format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
</div>
{% endif %}
{{display_message_status}} {{display_message_status}}
{% endif %} {% endif %}
{% elif arrived_from_preview_page_url %}
<div class="usa-alert usa-alert--success">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">
Your message has been sent
</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }}
was sent on {{job.processing_started|format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
</div>
{{display_message_status}}
{% endif %} {% endif %}
</p> </p>
{% if job.status == 'sending limits exceeded'%} {% if job.status == 'sending limits exceeded'%}
@@ -0,0 +1,72 @@
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2>
<div id="activityChartContainer">
<form class="usa-form">
<label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options">
<option value disabled>- Select -</option>
<option value="service" selected>{{ current_service.name }}</option>
<option value="individual">{{ current_user.name }}</option>
</select>
</form>
<div id="activityChart">
<div class="chart-header">
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
<div class="chart-legend" role="region" aria-label="Legend"></div>
</div>
<div class="chart-container" id="weeklyChart"></div>
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
</div>
</div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
<div class="table-container" id="activityContainer" data-currentUserName="{{ current_user.name }}" data-currentServiceId="{{current_service.id}}">
<div id="tableActivity" class="table-overflow-x-auto">
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
<table class="usa-table job-table" id="activity-table">
<caption class="usa-sr-only">Table showing the sent jobs for {{current_service.name}}</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" class="table-field-heading-first" id="jobId">Job ID#</th>
<th data-sortable scope="col" class="table-field-heading" scope="col">Template</th>
<th data-sortable scope="col" class="table-field-heading">Job status</th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading sender-column">Sender
</th>
<th data-sortable scope="col" class="table-field-heading"># of Recipients</th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs %}
<tr id="{{ job.id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ url_for('.view_job', service_id=current_service.id, job_id=job.id )}}">
{{ job.id[:8] if job.id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">
{% if job.scheduled_for and not job.processing_finished %}
Scheduled for {{ job.scheduled_for|format_datetime_table }}
{% elif job.processing_finished and not job.statistics|selectattr('status', 'equalto', 'sending')|list %}
Sent on {{ job.processing_finished|format_datetime_table }}
{% elif job.processing_started %}
Sending since {{ job.processing_started|format_datetime_table }}
{% else %}
Pending since {{ job.created_at|format_datetime_table }}
{% endif %}
</td>
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are
kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
+20 -122
View File
@@ -6,140 +6,38 @@
{% from "components/ajax-block.html" import ajax_block %} {% from "components/ajax-block.html" import ajax_block %}
{% block service_page_title %} {% block service_page_title %}
Dashboard Dashboard
{% endblock %} {% endblock %}
{% block maincolumn_content %} {% block maincolumn_content %}
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script> <script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
<div class="dashboard margin-top-0 margin-bottom-2"> <div class="dashboard margin-top-0 margin-bottom-2">
<h1 class="usa-sr-only">Dashboard</h1> <h1 class="usa-sr-only">Dashboard</h1>
{% if current_user.has_permissions('manage_templates') and not current_service.all_templates %} {% if current_user.has_permissions('manage_templates') and not current_service.all_templates %}
{% include 'views/dashboard/write-first-messages.html' %} {% include 'views/dashboard/write-first-messages.html' %}
{% endif %} {% endif %}
{{ ajax_block(partials, updates_url, 'upcoming') }} {{ ajax_block(partials, updates_url, 'upcoming') }}
<h2 class="font-body-2xl line-height-sans-2 margin-top-0">{{ current_service.name }} Dashboard</h2> <h2 class="font-body-2xl line-height-sans-2 margin-top-0">{{ current_service.name }} Dashboard</h2>
{{ ajax_block(partials, updates_url, 'inbox') }} {{ ajax_block(partials, updates_url, 'inbox') }}
<div id="totalMessageChartContainer" data-sms-sent="{{ sms_sent }}" data-sms-allowance-remaining="{{ sms_allowance_remaining }}"> <div id="totalMessageChartContainer" data-sms-sent="{{ sms_sent }}" data-sms-allowance-remaining="{{ sms_allowance_remaining }}">
<h2 id="chartTitle">Total messages</h2> <h2 id="chartTitle">Total messages</h2>
<svg id="totalMessageChart"></svg> <svg id="totalMessageChart"></svg>
<div id="message"></div> <div id="message"></div>
</div> </div>
<div id="totalMessageTable" class="margin-0"></div> <div id="totalMessageTable" class="margin-0"></div>
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2> {% include 'views/dashboard/activity-table.html' %}
<div id="activityChartContainer">
<form class="usa-form">
<label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options">
<option value disabled>- Select -</option>
<option value="service" selected>{{ current_service.name }}</option>
<option value="individual">{{ current_user.name }}</option>
</select>
</form>
<div id="activityChart">
<div class="chart-header">
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
<div class="chart-legend" role="region" aria-label="Legend"></div>
</div>
<div class="chart-container" id="weeklyChart"></div>
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
</div>
</div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
{% if current_user.has_permissions('manage_service') %}{% endif %} {% if current_user.has_permissions('manage_service') %}{% endif %}
<div class="table-container"> {{ ajax_block(partials, updates_url, 'template-statistics') }}
<div id="table1" class="table-overflow-x-auto hidden"> </div>
<h2 class="margin-top-4 margin-bottom-1">My activity</h2>
<table class="usa-table job-table">
<caption class="usa-sr-only">Table showing the sent jobs for {{current_user.name}}</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
<th data-sortable scope="col" class="table-field-heading"><span>Template</span></th>
<th data-sortable scope="col" class="table-field-heading"><span>Job status</span></th>
<th data-sortable scope="col" class="table-field-heading"><span># of Recipients</span></th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs[:5] %}
{% if job.created_by.name == current_user.name %}
{% set notification = job.notifications[0] %}
<tr id="{{ job.job_id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ job.view_job_link }}">
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">Sent on
{{ (job.processing_finished if job.processing_finished else job.processing_started
if job.processing_started else job.created_at)|format_datetime_table }}
</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div id="table2" class="table-overflow-x-auto visible">
<h2 class="margin-top-4 margin-bottom-1">Service activity</h2>
<table class="usa-table job-table">
<caption class="usa-sr-only">Table showing the sent jobs for this service</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" role="columnheader" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Template</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Job status</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Sender</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span># of Recipients</span></th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs[:5] %}
{% set notification = job.notifications[0] %}
<tr id="{{ job.job_id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ job.view_job_link }}">
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">Sent on
{{ (job.processing_finished if job.processing_finished else job.processing_started
if job.processing_started else job.created_at)|format_datetime_table }}
</td>
<td class="table-field sender">{{ job.created_by.name }}</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{{ ajax_block(partials, updates_url, 'template-statistics') }}
</div>
{% endblock %} {% endblock %}
+4 -1
View File
@@ -32,6 +32,8 @@
<div class="tablet:grid-col-9 mobile-lg:grid-col-12"> <div class="tablet:grid-col-9 mobile-lg:grid-col-12">
{{ form.name(param_extensions={ {{ form.name(param_extensions={
"extra_form_group_classes": "margin-bottom-2", "extra_form_group_classes": "margin-bottom-2",
"id": "name",
"required": True,
"hint": {"text": "Your recipients will not see this"} "hint": {"text": "Your recipients will not see this"}
}) }} }) }}
{{ textbox( {{ textbox(
@@ -41,7 +43,8 @@
hint=content_hint, hint=content_hint,
rows=5, rows=5,
extra_form_group_classes='margin-bottom-1', extra_form_group_classes='margin-bottom-1',
placeholder='Edit me! Check out the Personalization section below for details on cool ((stuff)) you can do with your messages!' placeholder='Edit me! Check out the Personalization section below for details on cool ((stuff)) you can do with your messages!',
required=True
) }} ) }}
{% if current_user.platform_admin %} {% if current_user.platform_admin %}
{{ form.process_type }} {{ form.process_type }}
+12 -1
View File
@@ -11,7 +11,18 @@
{% block maincolumn_content %} {% block maincolumn_content %}
{{ page_header("Message status") }} {{ page_header("Message status") }}
{{ partials['status']|safe }} {% if not job.processing_finished %}
<div
data-module="update-content"
data-resource="{{ updates_url }}"
data-key="status"
data-form=""
>
{% endif %}
{{ partials['status']|safe }}
{% if not job.processing_finished %}
</div>
{% endif %}
{% if not finished %} {% if not finished %}
<div <div
data-module="update-content" data-module="update-content"
+2 -2
View File
@@ -37,8 +37,8 @@
data_kwargs={'force-focus': True} data_kwargs={'force-focus': True}
) %} ) %}
<div class="grid-row"> <div class="grid-row">
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}" aria-live="polite" role="alert"> <div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
{{ form.placeholder_value(param_extensions={"classes": ""}) }} {{ form.placeholder_value(param_extensions={"classes": "", "id": "phone-number"}) }}
</div> </div>
{% if skip_link or link_to_upload %} {% if skip_link or link_to_upload %}
<div class="grid-col-12 margin-top-1"> <div class="grid-col-12 margin-top-1">
@@ -90,8 +90,4 @@
{% endif %} {% endif %}
</div> </div>
<!--<div class="">
{{ copy_to_clipboard(template.id, name="Template ID", thing='template ID') }}
</div>-->
{% endblock %} {% endblock %}
+518 -1755
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -37,7 +37,7 @@
"hogan": "1.0.2", "hogan": "1.0.2",
"jquery": "3.7.1", "jquery": "3.7.1",
"morphdom": "^2.7.4", "morphdom": "^2.7.4",
"playwright": "^1.49.1", "playwright": "^1.50.0",
"python": "^0.0.4", "python": "^0.0.4",
"query-command-supported": "1.0.0", "query-command-supported": "1.0.0",
"sass-embedded": "^1.83.4", "sass-embedded": "^1.83.4",
@@ -47,8 +47,8 @@
"vinyl-source-stream": "^2.0.0" "vinyl-source-stream": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.26.7",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.7",
"@uswds/compile": "^1.2.1", "@uswds/compile": "^1.2.1",
"backstopjs": "^6.3.25", "backstopjs": "^6.3.25",
"better-npm-audit": "^3.11.0", "better-npm-audit": "^3.11.0",
@@ -61,12 +61,12 @@
"gulp-jshint": "2.1.0", "gulp-jshint": "2.1.0",
"gulp-prettyerror": "2.0.0", "gulp-prettyerror": "2.0.0",
"gulp-uglify": "3.0.2", "gulp-uglify": "3.0.2",
"jest": "29.7.0", "jest": "^29.7.0",
"jest-each": "^29.2.1", "jest-each": "^29.2.1",
"jest-environment-jsdom": "^29.2.2", "jest-environment-jsdom": "^29.2.2",
"jshint": "2.13.6", "jshint": "2.13.6",
"jshint-stylish": "2.2.1", "jshint-stylish": "2.2.1",
"rollup": "^4.31.0", "rollup": "^4.32.0",
"rollup-plugin-commonjs": "10.1.0", "rollup-plugin-commonjs": "10.1.0",
"rollup-plugin-node-resolve": "5.2.0" "rollup-plugin-node-resolve": "5.2.0"
} }
+3 -5
View File
@@ -657,9 +657,8 @@ def test_should_show_recent_templates_on_dashboard(
] ]
assert "Total messages" in headers assert "Total messages" in headers
table_rows = page.find_all("tbody")[0].find_all("tr") table_rows = page.find_all("tbody")[1].find_all("tr")
assert len(table_rows) == 2
assert len(table_rows) == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1943,7 +1942,6 @@ def test_service_dashboard_shows_batched_jobs(
job_table_body = page.find("table", class_="job-table") job_table_body = page.find("table", class_="job-table")
rows = job_table_body.find_all("tbody")[0].find_all("tr") rows = job_table_body.find_all("tbody")[0].find_all("tr")
assert len(rows) == 1
assert len(rows) == 0
assert job_table_body is not None assert job_table_body is not None
+1 -1
View File
@@ -1521,7 +1521,7 @@ def test_link_to_upload_not_offered_when_entering_personalisation(
# Were entering personalization # Were entering personalization
assert page.select_one("input[type=text]")["name"] == "placeholder_value" assert page.select_one("input[type=text]")["name"] == "placeholder_value"
assert page.select_one("label[for=placeholder_value]").text.strip() == "name" assert page.select_one("label[for=phone-number]").text.strip() == "name"
# No Upload link shown # No Upload link shown
assert len(page.select("main a")) == 0 assert len(page.select("main a")) == 0
assert "Upload" not in page.select_one("main").text assert "Upload" not in page.select_one("main").text
+1 -3
View File
@@ -174,9 +174,7 @@ def test_should_show_empty_text_box(
# data-module=autofocus is set on a containing element so it # data-module=autofocus is set on a containing element so it
# shouldnt also be set on the textbox itself # shouldnt also be set on the textbox itself
assert "data-module" not in textbox assert "data-module" not in textbox
assert ( assert normalize_spaces(page.select_one("label[for=phone-number]").text) == "one"
normalize_spaces(page.select_one("label[for=placeholder_value]").text) == "one"
)
def test_should_prefill_answers_for_get_tour_step( def test_should_prefill_answers_for_get_tour_step(
@@ -141,8 +141,15 @@ def test_get_notification(mocker):
def test_get_notification_count_for_job_id(mocker): def test_get_notification_count_for_job_id(mocker):
mock_get = mocker.patch( mock_get = mocker.patch(
"app.notify_client.notification_api_client.NotificationApiClient.get" "app.notify_client.notification_api_client.NotificationApiClient.get",
return_value={"count": 0},
) )
mocker.patch(
"app.notify_client.notification_api_client.redis_client.get", return_value=None
)
mocker.patch("app.notify_client.billing_api_client.redis_client.set")
NotificationApiClient().get_notification_count_for_job_id( NotificationApiClient().get_notification_count_for_job_id(
service_id="foo", job_id="bar" service_id="foo", job_id="bar"
) )
+147 -48
View File
@@ -21,7 +21,7 @@ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
beforeAll(done => { beforeAll(done => {
// Set up the DOM with the D3 script included // Set up the DOM with the D3 script included
document.body.innerHTML = ` document.body.innerHTML = `
<div id="activityChartContainer""> <div id="activityChartContainer">
<form class="usa-form"> <form class="usa-form">
<label class="usa-label" for="options">Account</label> <label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options"> <select class="usa-select margin-bottom-2" name="options" id="options">
@@ -40,6 +40,9 @@ beforeAll(done => {
</div> </div>
</div> </div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div> <div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
<div id="activityContainer" data-currentUserName="Test User" data-currentServiceId="12345"></div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
`; `;
// Load the D3 script dynamically // Load the D3 script dynamically
@@ -61,13 +64,13 @@ test('D3 is loaded correctly', () => {
test('Populates the accessible table for activity chart correctly', () => { test('Populates the accessible table for activity chart correctly', () => {
const sampleData = { const sampleData = {
'2024-07-01': { sms: { delivered: 50, failed: 5 } }, '2024-07-01': { sms: { delivered: 50, failed: 5, pending: 10 } },
'2024-07-02': { sms: { delivered: 60, failed: 2 } }, '2024-07-02': { sms: { delivered: 60, failed: 2, pending: 5 } },
'2024-07-03': { sms: { delivered: 70, failed: 1 } }, '2024-07-03': { sms: { delivered: 70, failed: 1, pending: 3 } },
'2024-07-04': { sms: { delivered: 80, failed: 0 } }, '2024-07-04': { sms: { delivered: 80, failed: 0, pending: 0 } },
'2024-07-05': { sms: { delivered: 90, failed: 3 } }, '2024-07-05': { sms: { delivered: 90, failed: 3, pending: 8 } },
'2024-07-06': { sms: { delivered: 100, failed: 4 } }, '2024-07-06': { sms: { delivered: 100, failed: 4, pending: 7 } },
'2024-07-07': { sms: { delivered: 110, failed: 2 } }, '2024-07-07': { sms: { delivered: 110, failed: 2, pending: 6 } },
}; };
const labels = Object.keys(sampleData).map(dateString => { const labels = Object.keys(sampleData).map(dateString => {
@@ -76,8 +79,9 @@ test('Populates the accessible table for activity chart correctly', () => {
}); });
const deliveredData = Object.values(sampleData).map(d => d.sms.delivered); const deliveredData = Object.values(sampleData).map(d => d.sms.delivered);
const failedData = Object.values(sampleData).map(d => d.sms.failed); const failedData = Object.values(sampleData).map(d => d.sms.failed);
const pendingData = Object.values(sampleData).map(d => d.sms.pending);
window.createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); window.createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
const table = document.getElementById('weeklyTable'); const table = document.getElementById('weeklyTable');
expect(table).toBeDefined(); expect(table).toBeDefined();
@@ -89,6 +93,7 @@ test('Populates the accessible table for activity chart correctly', () => {
expect(headers[0].textContent).toBe('Day'); expect(headers[0].textContent).toBe('Day');
expect(headers[1].textContent).toBe('Delivered'); expect(headers[1].textContent).toBe('Delivered');
expect(headers[2].textContent).toBe('Failed'); expect(headers[2].textContent).toBe('Failed');
expect(headers[3].textContent).toBe('Pending');
const firstRowCells = rows[1].getElementsByTagName('td'); const firstRowCells = rows[1].getElementsByTagName('td');
expect(firstRowCells[0].textContent).toBe('07/01/24'); expect(firstRowCells[0].textContent).toBe('07/01/24');
@@ -97,58 +102,73 @@ test('Populates the accessible table for activity chart correctly', () => {
}); });
test('SVG element is correctly set up', () => { test('SVG element is correctly set up', () => {
window.createChart('#weeklyChart', ['07/01/24', '07/02/24', '07/03/24', '07/04/24', '07/05/24', '07/06/24', '07/07/24'], [50, 60, 70, 80, 90, 100, 110], [5, 2, 1, 0, 3, 4, 2]); window.createChart(
'#weeklyChart',
['07/01/24', '07/02/24', '07/03/24', '07/04/24', '07/05/24', '07/06/24', '07/07/24'],
[50, 60, 70, 80, 90, 100, 110],
[5, 2, 1, 0, 3, 4, 2],
[10, 5, 3, 0, 8, 7, 6]
);
const svg = document.getElementById('weeklyChart').querySelector('svg'); const svg = document.getElementById('weeklyChart').querySelector('svg');
expect(svg).not.toBeNull(); expect(svg).not.toBeNull();
expect(svg.getAttribute('width')).toBe('0'); expect(svg.querySelectorAll('.bar-group').length).toBe(3);
expect(svg.getAttribute('height')).toBe('400');
}); });
test('Check HTML content after chart creation', () => { test('Check HTML content after chart creation', () => {
// Create sample data for the chart
const labels = ['07/01/24', '07/02/24', '07/03/24', '07/04/24', '07/05/24', '07/06/24', '07/07/24']; const labels = ['07/01/24', '07/02/24', '07/03/24', '07/04/24', '07/05/24', '07/06/24', '07/07/24'];
const deliveredData = [50, 60, 70, 80, 90, 100, 110]; const deliveredData = [50, 60, 70, 80, 90, 100, 110];
const failedData = [5, 2, 1, 0, 3, 4, 2]; const failedData = [5, 2, 1, 0, 3, 4, 2];
const pendingData = [10, 5, 8, 3, 6, 7, 4];
// Ensure the container has the correct width
const container = document.getElementById('weeklyChart'); const container = document.getElementById('weeklyChart');
container.style.width = '600px'; // Force a specific width container.style.width = '600px';
const containerWidth = container.clientWidth; const containerWidth = container.clientWidth;
expect(containerWidth).toBeGreaterThan(0); expect(containerWidth).toBeGreaterThan(0);
// Call the function to create the chart window.createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
window.createChart('#weeklyChart', labels, deliveredData, failedData);
// Optionally, you can add assertions to check for specific elements const svg = container.querySelector('svg');
expect(container.querySelector('svg')).not.toBeNull(); expect(svg).not.toBeNull();
expect(container.querySelectorAll('rect').length).toBeGreaterThan(0);
const bars = container.querySelectorAll('rect');
expect(bars.length).toBeGreaterThan(0);
const barGroups = svg.querySelectorAll('.bar-group');
expect(barGroups.length).toBe(3);
const pendingBars = Array.from(bars).filter(bar =>
bar.parentNode.getAttribute('fill') === '#C7CACE'
);
expect(pendingBars.length).toBe(labels.length);
}); });
test('Legend is visible when there are delivered or failed messages', () => { test('Legend includes pending when data exists', () => {
// Example data with delivered and failed messages
const labels = ['Day 1', 'Day 2']; const labels = ['Day 1', 'Day 2'];
const deliveredData = [10, 20]; // Mock delivered data const deliveredData = [10, 20];
const failedData = [5, 0]; // Mock failed data const failedData = [5, 0];
const pendingData = [3, 2];
// Call the createChart function window.createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
window.createChart('#weeklyChart', labels, deliveredData, failedData);
// Check if the legend is displayed using computed style
const legendContainer = document.querySelector('.chart-legend'); const legendContainer = document.querySelector('.chart-legend');
const legendDisplayStyle = window.getComputedStyle(legendContainer).display; const legendItems = legendContainer.querySelectorAll('.legend-item');
expect(legendDisplayStyle).toBe('flex'); expect(legendItems.length).toBe(3);
expect(legendContainer.querySelectorAll('.legend-item').length).toBe(2); // Ensure two legend items
const pendingLegend = Array.from(legendItems).find(item =>
item.textContent.includes('Pending')
);
expect(pendingLegend).not.toBeNull();
}); });
test('Legend is hidden when there are no delivered or failed messages', () => { test('Legend is hidden when there are no delivered, failed, or pending messages', () => {
// Example data with no delivered and no failed messages
const labels = ['Day 1', 'Day 2']; const labels = ['Day 1', 'Day 2'];
const deliveredData = [0, 0]; // No delivered messages const deliveredData = [0, 0];
const failedData = [0, 0]; // No failed messages const failedData = [0, 0];
const pendingData = [0, 0];
// Call the createChart function // Call the createChart function
window.createChart('#weeklyChart', labels, deliveredData, failedData); window.createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
// Check if the legend is hidden using computed style // Check if the legend is hidden using computed style
const legendContainer = document.querySelector('.chart-legend'); const legendContainer = document.querySelector('.chart-legend');
@@ -158,23 +178,102 @@ test('Legend is hidden when there are no delivered or failed messages', () => {
test('Fetches data and creates chart and table correctly', async () => { test('Fetches data and creates chart and table correctly', async () => {
const mockResponse = { const mockResponse = {
'2024-07-01': { sms: { delivered: 50, failed: 5 } }, '2024-07-01': { sms: { delivered: 50, failed: 5, pending: 10 } },
'2024-07-02': { sms: { delivered: 60, failed: 2 } }, '2024-07-02': { sms: { delivered: 60, failed: 2, pending: 8 } },
'2024-07-03': { sms: { delivered: 70, failed: 1 } }, '2024-07-03': { sms: { delivered: 70, failed: 1, pending: 6 } },
'2024-07-04': { sms: { delivered: 80, failed: 0 } }, '2024-07-04': { sms: { delivered: 80, failed: 0, pending: 4 } },
'2024-07-05': { sms: { delivered: 90, failed: 3 } }, '2024-07-05': { sms: { delivered: 90, failed: 3, pending: 7 } },
'2024-07-06': { sms: { delivered: 100, failed: 4 } }, '2024-07-06': { sms: { delivered: 100, failed: 4, pending: 5 } },
'2024-07-07': { sms: { delivered: 110, failed: 2 } }, '2024-07-07': { sms: { delivered: 110, failed: 2, pending: 3 } },
}; };
const tableContainer = document.getElementById('activityContainer');
const currentServiceId = tableContainer.getAttribute('data-currentServiceId');
global.fetch = jest.fn(() => global.fetch = jest.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) })
); );
const data = await fetchData('service'); const data = await fetchData('service');
expect(global.fetch).toHaveBeenCalledWith('/daily_stats.json'); expect(global.fetch).toHaveBeenCalledWith(`/services/${currentServiceId}/daily-stats.json`);
expect(data).toEqual(mockResponse); expect(data).toEqual(mockResponse);
const labels = Object.keys(mockResponse).map(dateString => {
const dateParts = dateString.split('-');
return `${dateParts[1]}/${dateParts[2]}/${dateParts[0].slice(2)}`;
});
const deliveredData = Object.values(mockResponse).map(d => d.sms.delivered);
const failedData = Object.values(mockResponse).map(d => d.sms.failed);
const pendingData = Object.values(mockResponse).map(d => d.sms.pending);
window.createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
window.createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
const chart = document.getElementById('weeklyChart').querySelector('svg');
expect(chart).not.toBeNull();
const table = document.getElementById('weeklyTable');
expect(table).toBeDefined();
const rows = table.getElementsByTagName('tr');
expect(rows.length).toBe(8);
});
test('handleDropdownChange updates DOM for individual selection', () => {
document.body.innerHTML = `
<div id="activityChartContainer">
<div class="chart-subtitle"></div>
</div>
<div id="aria-live-account"></div>
<div id="activityContainer" data-currentUserName="Test User" data-currentServiceId="12345"></div>
<div id="tableActivity">
<h2 id="table-heading"></h2>
<table id="activity-table">
<caption id="caption"></caption>
<tbody>
<tr><td class="sender-column">Test User</td></tr>
<tr><td class="sender-column">Other User</td></tr>
<tr><td class="sender-column">Test User</td></tr>
<tr><td class="sender-column">Test User</td></tr>
<tr><td class="sender-column">Other User</td></tr>
<tr><td class="sender-column">Test User</td></tr>
<tr><td class="sender-column">Test User</td></tr>
</tbody>
</table>
</div>
<select id="options">
<option value="service">Service</option>
<option value="individual">Individual</option>
</select>
`;
window.currentUserName = "Test User";
jest.spyOn(window, 'fetchData').mockImplementation(() => {});
const selectElement = document.getElementById('options');
selectElement.value = 'individual';
const event = { target: selectElement };
window.handleDropdownChange(event);
expect(document.getElementById('table-heading').textContent).toBe('My activity');
expect(document.getElementById('caption').textContent).toContain('Test User');
document.querySelectorAll('.sender-column').forEach(col => {
expect(col.style.display).toBe('none');
});
const rows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
expect(visibleRows.length).toBeLessThanOrEqual(5);
visibleRows.forEach(row => {
const sender = row.querySelector('.sender-column').textContent.trim();
expect(sender).toBe('Test User');
});
window.fetchData.mockRestore();
}); });