mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-03 16:32:15 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/3464-fix-fixtures-load
This commit is contained in:
commit
bd0f8b668a
24 changed files with 304 additions and 189 deletions
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
@ -1,6 +1,6 @@
|
|||
name: Bug
|
||||
description: Report a bug or problem with the application
|
||||
labels: ["bug"]
|
||||
labels: ["bug","dev"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
|
|
102
docs/architecture/decisions/0027-ajax-for-dynamic-content.md
Normal file
102
docs/architecture/decisions/0027-ajax-for-dynamic-content.md
Normal file
|
@ -0,0 +1,102 @@
|
|||
# 26. Django Waffle library for Feature Flags
|
||||
|
||||
Date: 2024-05-22 (back dated)
|
||||
|
||||
## Status
|
||||
|
||||
Approved
|
||||
|
||||
## Context
|
||||
|
||||
When we decided to implement server-side rendering ([ADR#8 - Server-Side rendering](./0008-server-side-rendering.md)), we identified a potential risk: users and stakeholders might expect increasingly interactive experiences similar to those found in single-page applications (SPAs). Modern JavaScript frameworks such as React, Angular, and Vue enable rich interactivity by allowing applications to update portions of the page dynamically—often without requiring a full-page reload. These frameworks abstract AJAX and DOM manipulation, creating a high-level interface between JavaScript, HTML, and the browser.
|
||||
|
||||
Our decision to use Django for server-rendered pages allowed us to deliver an MVP quickly and facilitated easy onboarding for new developers. However, the anticipated risk materialized, and stakeholders now expect a more seamless, SPA-like experience.
|
||||
|
||||
We already leverage vanilla JavaScript for interactive components throughout the application. These implementations are neatly contained within Immediately Invoked Function Expressions (IIFEs) and are designed to extend specific components without interfering with Django’s server-rendered structure.
|
||||
|
||||
However, new components that require features like pagination, search, and filtering demand a more responsive, real-time user experience. This prompted an exploration of possible solutions.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**Option 1:** Migrate to a Full SPA with Django as a Backend API
|
||||
This approach involves refactoring Django into a backend-only service and adopting a modern JavaScript framework for the frontend.
|
||||
|
||||
✅ Pros:
|
||||
- Future-proof solution that aligns with modern web development practices.
|
||||
- Enables highly interactive and dynamic UI.
|
||||
- Clean separation of concerns between frontend and backend.
|
||||
|
||||
❌ Cons:
|
||||
- Requires significant investment in development and infrastructure changes.
|
||||
- Major refactoring effort, delaying feature delivery.
|
||||
- Increased complexity for testing and deployment.
|
||||
|
||||
This approach was deemed too costly in terms of both time and resources.
|
||||
|
||||
---
|
||||
|
||||
**Option 2:** Adopt a Modern JS Framework for Select Parts of the Application
|
||||
Instead of a full migration, this approach involves integrating a modern JavaScript framework (e.g., React or Vue) only in areas that require high interactivity.
|
||||
|
||||
✅ Pros:
|
||||
- Avoids a complete rewrite, allowing incremental improvements.
|
||||
- More flexibility in choosing the level of interactivity per feature.
|
||||
|
||||
❌ Cons:
|
||||
- Introduces multiple frontend paradigms, increasing developer onboarding complexity.
|
||||
- Requires new deployment and build infrastructure.
|
||||
- Creates long-term technical debt if legacy Django templates and new JS-driven components coexist indefinitely.
|
||||
|
||||
This approach would still introduce diverging implementation stacks, leading to long-term maintenance challenges.
|
||||
|
||||
---
|
||||
|
||||
**Option 3:** Use a Lightweight JavaScript Framework (e.g., HTMX, HTMZ)
|
||||
Instead of React or Vue, this approach involves using a minimal JavaScript framework like HTMX or HTMZ to enhance interactivity while preserving Django’s server-rendered structure.
|
||||
|
||||
✅ Pros:
|
||||
- Reduces the need for a full rewrite.
|
||||
- Keeps Django templates largely intact.
|
||||
- Minimizes complexity compared to React or Vue.
|
||||
|
||||
❌ Cons:
|
||||
- Limited community support and long-term viability concerns.
|
||||
- Still introduces new technology and learning curves.
|
||||
- Unclear whether it fully meets our interactivity needs.
|
||||
|
||||
Ultimately, we determined that the benefits did not outweigh the potential downsides.
|
||||
|
||||
---
|
||||
|
||||
**Option 4:** Extend Vanilla JavaScript with AJAX (Selected Option)
|
||||
This approach involves incrementally enhancing Django’s server-rendered pages with AJAX while maintaining our existing architecture.
|
||||
|
||||
✅ Pros:
|
||||
Avoids expensive refactors and new dependencies.
|
||||
- Fully customized to our existing codebase.
|
||||
- Keeps Django templates intact while allowing dynamic updates.
|
||||
- No need for additional build tools or frontend frameworks.
|
||||
|
||||
❌ Cons:
|
||||
- Requires designing our own structured approach to AJAX.
|
||||
- Testing and maintainability must be carefully considered.
|
||||
|
||||
This approach aligns with our existing architecture and skill set while meeting stakeholder demands for interactivity.
|
||||
|
||||
## Decision
|
||||
We chose Option 4: Extending our use of vanilla JavaScript with AJAX.
|
||||
|
||||
## Consequences
|
||||
1. Ownership of Solution
|
||||
- We fully control the implementation without external dependencies.
|
||||
|
||||
2. Maintainability
|
||||
- Our AJAX implementation will follow an object-oriented approach, with a base class for components requiring pagination, search, and filtering.
|
||||
|
||||
3. Backend Considerations
|
||||
- Views handling AJAX responses will be explicitly designated as JSON views.
|
||||
|
||||
4. Scalability
|
||||
- While this approach works now, we may need to reassess in the future if interactivity demands continue to grow.
|
||||
|
||||
This decision allows us to enhance the application's responsiveness without disrupting existing architecture or delaying feature development.
|
|
@ -163,6 +163,18 @@ class MyUserAdminForm(UserChangeForm):
|
|||
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
||||
}
|
||||
|
||||
# Loads "tabtitle" for this admin page so that on render the <title>
|
||||
# element will only have the model name instead of
|
||||
# the default string loaded by native Django admin code.
|
||||
# (Eg. instead of "Select contact to change", display "Contacts")
|
||||
# see "base_site.html" for the <title> code.
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Custom init to modify the user form"""
|
||||
super(MyUserAdminForm, self).__init__(*args, **kwargs)
|
||||
|
@ -523,6 +535,18 @@ class CustomLogEntryAdmin(LogEntryAdmin):
|
|||
"user_url",
|
||||
]
|
||||
|
||||
# Loads "tabtitle" for this admin page so that on render the <title>
|
||||
# element will only have the model name instead of
|
||||
# the default string loaded by native Django admin code.
|
||||
# (Eg. instead of "Select contact to change", display "Contacts")
|
||||
# see "base_site.html" for the <title> code.
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
# We name the custom prop 'resource' because linter
|
||||
# is not allowing a short_description attr on it
|
||||
# This gets around the linter limitation, for now.
|
||||
|
@ -542,13 +566,6 @@ class CustomLogEntryAdmin(LogEntryAdmin):
|
|||
change_form_template = "admin/change_form_no_submit.html"
|
||||
add_form_template = "admin/change_form_no_submit.html"
|
||||
|
||||
# Select log entry to change -> Log entries
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Log entries"
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
# #786: Skipping on updating audit log tab titles for now
|
||||
# def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
# if extra_context is None:
|
||||
|
@ -629,6 +646,18 @@ class AdminSortFields:
|
|||
class AuditedAdmin(admin.ModelAdmin):
|
||||
"""Custom admin to make auditing easier."""
|
||||
|
||||
# Loads "tabtitle" for this admin page so that on render the <title>
|
||||
# element will only have the model name instead of
|
||||
# the default string loaded by native Django admin code.
|
||||
# (Eg. instead of "Select contact to change", display "Contacts")
|
||||
# see "base_site.html" for the <title> code.
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def history_view(self, request, object_id, extra_context=None):
|
||||
"""On clicking 'History', take admin to the auditlog view for an object."""
|
||||
return HttpResponseRedirect(
|
||||
|
@ -1029,6 +1058,18 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
# Loads "tabtitle" for this admin page so that on render the <title>
|
||||
# element will only have the model name instead of
|
||||
# the default string loaded by native Django admin code.
|
||||
# (Eg. instead of "Select contact to change", display "Contacts")
|
||||
# see "base_site.html" for the <title> code.
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class HostIPInline(admin.StackedInline):
|
||||
"""Edit an ip address on the host page."""
|
||||
|
@ -1053,14 +1094,6 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
|
|||
search_help_text = "Search by domain or host name."
|
||||
inlines = [HostIPInline]
|
||||
|
||||
# Select host to change -> Host
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Host"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class HostIpResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -1076,14 +1109,6 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
|
|||
resource_classes = [HostIpResource]
|
||||
model = models.HostIP
|
||||
|
||||
# Select host ip to change -> Host ip
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Host IP"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class ContactResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -1205,14 +1230,6 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
return super().change_view(request, object_id, form_url, extra_context=extra_context)
|
||||
|
||||
# Select contact to change -> Contacts
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Contacts"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
# Clear warning messages before saving
|
||||
storage = messages.get_messages(request)
|
||||
|
@ -1527,14 +1544,6 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
|
||||
|
||||
# Select domain invitations to change -> Domain invitations
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Domain invitations"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Override the change_view to add the invitation obj for the change_form_object_tools template"""
|
||||
|
||||
|
@ -1673,14 +1682,6 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
|
|||
change_form_template = "django/admin/portfolio_invitation_change_form.html"
|
||||
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
|
||||
|
||||
# Select portfolio invitations to change -> Portfolio invitations
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Portfolio invitations"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Override the save_model method.
|
||||
|
@ -2070,14 +2071,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields # Read-only fields for analysts
|
||||
|
||||
# Select domain information to change -> Domain information
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Domain information"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
"""Customize the behavior of formfields with foreign key relationships. This will customize
|
||||
the behavior of selects. Customized behavior includes sorting of objects in list."""
|
||||
|
@ -2898,11 +2891,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if next_char.isdigit():
|
||||
should_apply_default_filter = True
|
||||
|
||||
# Select domain request to change -> Domain requests
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Domain requests"
|
||||
|
||||
if should_apply_default_filter:
|
||||
# modify the GET of the request to set the selected filter
|
||||
modified_get = copy.deepcopy(request.GET)
|
||||
|
@ -3959,14 +3947,6 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# If no redirection is needed, return the original response
|
||||
return response
|
||||
|
||||
# Select draft domain to change -> Draft domains
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Draft domains"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class PublicContactResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -4388,14 +4368,6 @@ class UserGroupAdmin(AuditedAdmin):
|
|||
def user_group(self, obj):
|
||||
return obj.name
|
||||
|
||||
# Select user groups to change -> User groups
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "User groups"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class WaffleFlagAdmin(FlagAdmin):
|
||||
"""Custom admin implementation of django-waffle's Flag class"""
|
||||
|
@ -4412,6 +4384,13 @@ class WaffleFlagAdmin(FlagAdmin):
|
|||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
|
||||
|
||||
# Loads "tabtitle" for this admin page so that on render the <title>
|
||||
# element will only have the model name instead of
|
||||
# the default string loaded by native Django admin code.
|
||||
# (Eg. instead of "Select waffle flags to change", display "Waffle Flags")
|
||||
# see "base_site.html" for the <title> code.
|
||||
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
|
|
|
@ -5284,7 +5284,10 @@ const setUpModal = baseComponent => {
|
|||
overlayDiv.classList.add(OVERLAY_CLASSNAME);
|
||||
|
||||
// Set attributes
|
||||
modalWrapper.setAttribute("role", "dialog");
|
||||
// DOTGOV
|
||||
// Removes the dialog role as this causes a double readout bug with screenreaders
|
||||
// modalWrapper.setAttribute("role", "dialog");
|
||||
// END DOTGOV
|
||||
modalWrapper.setAttribute("id", modalID);
|
||||
if (ariaLabelledBy) {
|
||||
modalWrapper.setAttribute("aria-labelledby", ariaLabelledBy);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js';
|
||||
import { hideElement, showElement, addOrRemoveSessionBoolean, announceForScreenReaders } from './helpers-admin.js';
|
||||
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
|
||||
|
||||
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
|
||||
|
@ -684,3 +684,33 @@ export function initDynamicDomainRequestFields(){
|
|||
handleSuborgFieldsAndButtons();
|
||||
}
|
||||
}
|
||||
|
||||
export function initFilterFocusListeners() {
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
let filters = document.querySelectorAll("#changelist-filter li a"); // Get list of all filter links
|
||||
let clickedFilter = false; // Used to determine if we are truly navigating away or not
|
||||
|
||||
// Restore focus from localStorage
|
||||
let lastClickedFilterId = localStorage.getItem("admin_filter_focus_id");
|
||||
if (lastClickedFilterId) {
|
||||
let focusedElement = document.getElementById(lastClickedFilterId);
|
||||
if (focusedElement) {
|
||||
//Focus the element
|
||||
focusedElement.setAttribute("tabindex", "0");
|
||||
focusedElement.focus({ preventScroll: true });
|
||||
|
||||
// Announce focus change for screen readers
|
||||
announceForScreenReaders("Filter refocused on " + focusedElement.textContent);
|
||||
localStorage.removeItem("admin_filter_focus_id");
|
||||
}
|
||||
}
|
||||
|
||||
// Capture clicked filter and store its ID
|
||||
filters.forEach(filter => {
|
||||
filter.addEventListener("click", function() {
|
||||
localStorage.setItem("admin_filter_focus_id", this.id);
|
||||
clickedFilter = true; // Mark that a filter was clicked
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -32,3 +32,22 @@ export function getParameterByName(name, url) {
|
|||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary live region to announce messages for screen readers.
|
||||
*/
|
||||
export function announceForScreenReaders(message) {
|
||||
let liveRegion = document.createElement("div");
|
||||
liveRegion.setAttribute("aria-live", "assertive");
|
||||
liveRegion.setAttribute("role", "alert");
|
||||
liveRegion.setAttribute("class", "usa-sr-only");
|
||||
document.body.appendChild(liveRegion);
|
||||
|
||||
// Delay the update slightly to ensure it's recognized
|
||||
setTimeout(() => {
|
||||
liveRegion.textContent = message;
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(liveRegion);
|
||||
}, 1000);
|
||||
}, 100);
|
||||
}
|
|
@ -10,7 +10,8 @@ import {
|
|||
initRejectedEmail,
|
||||
initApprovedDomain,
|
||||
initCopyRequestSummary,
|
||||
initDynamicDomainRequestFields } from './domain-request-form.js';
|
||||
initDynamicDomainRequestFields,
|
||||
initFilterFocusListeners } from './domain-request-form.js';
|
||||
import { initDomainFormTargetBlankButtons } from './domain-form.js';
|
||||
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
||||
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
||||
|
@ -34,6 +35,7 @@ initRejectedEmail();
|
|||
initApprovedDomain();
|
||||
initCopyRequestSummary();
|
||||
initDynamicDomainRequestFields();
|
||||
initFilterFocusListeners();
|
||||
|
||||
// Domain
|
||||
initDomainFormTargetBlankButtons();
|
||||
|
|
|
@ -15,6 +15,7 @@ import { initDomainManagersPage } from './domain-managers.js';
|
|||
import { initDomainDSData } from './domain-dsdata.js';
|
||||
import { initDomainDNSSEC } from './domain-dnssec.js';
|
||||
import { initFormErrorHandling } from './form-errors.js';
|
||||
import { initButtonLinks } from '../getgov-admin/button-utils.js';
|
||||
|
||||
initDomainValidators();
|
||||
|
||||
|
@ -49,3 +50,5 @@ initFormErrorHandling();
|
|||
initPortfolioMemberPageRadio();
|
||||
initPortfolioNewMemberPageToggle();
|
||||
initAddNewMemberPageListeners();
|
||||
|
||||
initButtonLinks();
|
||||
|
|
|
@ -25,7 +25,6 @@
|
|||
// Note, width is determined by a custom width class on one of the children
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
border-radius: 4px;
|
||||
border: solid 1px color('base-lighter');
|
||||
padding: units(2) units(2) units(3) units(2);
|
||||
|
@ -42,6 +41,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// This will work in responsive tables if we overwrite the overflow value on the table container
|
||||
// Works with styles in _tables
|
||||
@include at-media(desktop) {
|
||||
.usa-accordion--more-actions .usa-accordion__content {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.usa-accordion--select .usa-accordion__content {
|
||||
top: 33.88px;
|
||||
}
|
||||
|
@ -59,10 +66,12 @@
|
|||
// This won't work on the Members table rows because that table has show-more rows
|
||||
// Currently, that's not an issue since that Members table is not wrapped in the
|
||||
// reponsive wrapper.
|
||||
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
right: 30px;
|
||||
@include at-media-max("desktop") {
|
||||
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
// A CSS only show-more/show-less based on usa-accordion
|
||||
|
|
|
@ -226,11 +226,6 @@ abbr[title] {
|
|||
}
|
||||
}
|
||||
|
||||
// Boost this USWDS utility class for the accordions in the portfolio requests table
|
||||
.left-auto {
|
||||
left: auto!important;
|
||||
}
|
||||
|
||||
.usa-banner__inner--widescreen {
|
||||
max-width: $widescreen-max-width;
|
||||
}
|
||||
|
|
|
@ -152,3 +152,12 @@ th {
|
|||
.usa-table--full-borderless th {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
// This is an override to overflow on certain tables (note the custom class)
|
||||
// so that a popup menu can appear and starddle the edge of the table on large
|
||||
// screen sizes. Works with styles in _accordions
|
||||
@include at-media(desktop) {
|
||||
.usa-table-container--scrollable.usa-table-container--override-overflow {
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
Registrar Analytics | Django admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
|
|
|
@ -33,8 +33,8 @@
|
|||
{{ tabtitle }} |
|
||||
{% else %}
|
||||
{{ title }} |
|
||||
{% endif %}
|
||||
{{ site_title|default:_('Django site admin') }}
|
||||
{% endif %}
|
||||
Django admin
|
||||
{% endblock %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
|
|
13
src/registrar/templates/admin/filter.html
Normal file
13
src/registrar/templates/admin/filter.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% comment %} Override of this file: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/filter.html {% endcomment %}
|
||||
{% load i18n %}
|
||||
<details data-filter-title="{{ title }}" open>
|
||||
<summary>
|
||||
{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}
|
||||
</summary>
|
||||
<ul>
|
||||
{% for choice in choices %}
|
||||
<li {% if choice.selected %} class="selected"{% endif %}>
|
||||
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
|
@ -9,16 +9,12 @@
|
|||
{% for choice in choices %}
|
||||
{% if choice.reset %}
|
||||
<li{% if choice.selected %} class="selected"{% endif %}">
|
||||
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
|
||||
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for choice in choices %}
|
||||
{% if not choice.reset %}
|
||||
<li{% if choice.selected %} class="selected"{% endif %}">
|
||||
{% else %}
|
||||
<li{% if choice.selected %} class="selected"{% endif %}>
|
||||
{% if choice.selected and choice.exclude_query_string %}
|
||||
<a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
|
||||
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
|
||||
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
||||
</svg>
|
||||
|
@ -26,9 +22,8 @@
|
|||
<use xlink:href="{%static 'img/sprite.svg'%}#check"></use>
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not choice.selected and choice.include_query_string %}
|
||||
<a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
|
||||
{% elif not choice.selected and choice.include_query_string %}
|
||||
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
|
||||
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
||||
</svg>
|
||||
|
@ -38,4 +33,4 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</details>
|
|
@ -29,7 +29,10 @@
|
|||
{% csrf_token %}
|
||||
|
||||
{% if domain.domain_info.generic_org_type == 'federal' %}
|
||||
{% input_with_errors form.federal_agency %}
|
||||
<h4 class="margin-bottom-05">Federal Agency</h4>
|
||||
<p class="margin-top-0">
|
||||
{{ domain.domain_info.federal_agency }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% input_with_errors form.organization_name %}
|
||||
|
|
|
@ -18,10 +18,10 @@
|
|||
<h1>Manage your domains</h1>
|
||||
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:start' %}" class="usa-button"
|
||||
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{% include "includes/domains_table.html" with user_domain_count=user_domain_count %}
|
||||
|
|
|
@ -163,7 +163,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
|
||||
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
|
|
|
@ -198,7 +198,7 @@
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper">
|
||||
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domains__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
|
|
|
@ -26,10 +26,10 @@
|
|||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="{% url 'domain-request:start' %}" class="usa-button"
|
||||
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -215,14 +215,14 @@ class TestDomainInvitationAdmin(WebTest):
|
|||
)
|
||||
|
||||
# Assert that the filters are added
|
||||
self.assertContains(response, "invited", count=5)
|
||||
self.assertContains(response, "invited", count=6)
|
||||
self.assertContains(response, "Invited", count=2)
|
||||
self.assertContains(response, "retrieved", count=2)
|
||||
self.assertContains(response, "retrieved", count=3)
|
||||
self.assertContains(response, "Retrieved", count=2)
|
||||
|
||||
# Check for the HTML context specificially
|
||||
invited_html = '<a href="?status__exact=invited">Invited</a>'
|
||||
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
|
||||
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
|
||||
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
|
||||
|
||||
self.assertContains(response, invited_html, count=1)
|
||||
self.assertContains(response, retrieved_html, count=1)
|
||||
|
@ -1269,14 +1269,14 @@ class TestPortfolioInvitationAdmin(TestCase):
|
|||
)
|
||||
|
||||
# Assert that the filters are added
|
||||
self.assertContains(response, "invited", count=4)
|
||||
self.assertContains(response, "invited", count=5)
|
||||
self.assertContains(response, "Invited", count=2)
|
||||
self.assertContains(response, "retrieved", count=2)
|
||||
self.assertContains(response, "retrieved", count=3)
|
||||
self.assertContains(response, "Retrieved", count=2)
|
||||
|
||||
# Check for the HTML context specificially
|
||||
invited_html = '<a href="?status__exact=invited">Invited</a>'
|
||||
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
|
||||
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
|
||||
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
|
||||
|
||||
self.assertContains(response, invited_html, count=1)
|
||||
self.assertContains(response, retrieved_html, count=1)
|
||||
|
|
|
@ -888,8 +888,8 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
|
|||
csv_content = csv_file.read()
|
||||
expected_content = (
|
||||
# Header
|
||||
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
|
||||
"Member management,Domain management,Number of domains,Domains\n"
|
||||
"Email,Member access,Invited by,Joined date,Last active,Domain requests,"
|
||||
"Members,Domains,Number domains assigned,Domain assignments\n"
|
||||
# Content
|
||||
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,"
|
||||
"Viewer,True,1,cdomain1.gov\n"
|
||||
|
|
|
@ -712,7 +712,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertRedirects(response, reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}))
|
||||
|
||||
# Check for the updated expiration
|
||||
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y")
|
||||
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%B %-d, %Y")
|
||||
redirect_response = self.client.get(
|
||||
reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}), follow=True
|
||||
)
|
||||
|
@ -2088,62 +2088,6 @@ class TestDomainOrganization(TestDomainOverview):
|
|||
# Check for the value we want to update
|
||||
self.assertContains(success_result_page, "Faketown")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_org_name_address_form_federal(self):
|
||||
"""
|
||||
Submitting a change to federal_agency is blocked for federal domains
|
||||
"""
|
||||
|
||||
fed_org_type = DomainInformation.OrganizationChoices.FEDERAL
|
||||
self.domain_information.generic_org_type = fed_org_type
|
||||
self.domain_information.save()
|
||||
try:
|
||||
federal_agency, _ = FederalAgency.objects.get_or_create(agency="AMTRAK")
|
||||
self.domain_information.federal_agency = federal_agency
|
||||
self.domain_information.save()
|
||||
except ValueError as err:
|
||||
self.fail(f"A ValueError was caught during the test: {err}")
|
||||
|
||||
self.assertEqual(self.domain_information.generic_org_type, fed_org_type)
|
||||
|
||||
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
|
||||
|
||||
form = org_name_page.forms[0]
|
||||
# Check the value of the input field
|
||||
agency_input = form.fields["federal_agency"][0]
|
||||
self.assertEqual(agency_input.value, str(federal_agency.id))
|
||||
|
||||
# Check if the input field is disabled
|
||||
self.assertTrue("disabled" in agency_input.attrs)
|
||||
self.assertEqual(agency_input.attrs.get("disabled"), "")
|
||||
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
org_name_page.form["federal_agency"] = FederalAgency.objects.filter(agency="Department of State").get().id
|
||||
org_name_page.form["city"] = "Faketown"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
# Make the change. The agency should be unchanged, but city should be modifiable.
|
||||
success_result_page = org_name_page.form.submit()
|
||||
self.assertEqual(success_result_page.status_code, 200)
|
||||
|
||||
# Check that the agency has not changed
|
||||
self.assertEqual(self.domain_information.federal_agency.agency, "AMTRAK")
|
||||
|
||||
# Do another check on the form itself
|
||||
form = success_result_page.forms[0]
|
||||
# Check the value of the input field
|
||||
organization_name_input = form.fields["federal_agency"][0]
|
||||
self.assertEqual(organization_name_input.value, str(federal_agency.id))
|
||||
|
||||
# Check if the input field is disabled
|
||||
self.assertTrue("disabled" in organization_name_input.attrs)
|
||||
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
|
||||
|
||||
# Check for the value we want to update
|
||||
self.assertContains(success_result_page, "Faketown")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_federal_agency_submit_blocked(self):
|
||||
"""
|
||||
|
|
|
@ -38,10 +38,15 @@ from django.contrib.admin.models import LogEntry, ADDITION
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from registrar.models.utility.generic_helper import convert_queryset_to_dict
|
||||
from registrar.models.utility.orm_helper import ArrayRemoveNull
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.templatetags.custom_filters import get_region
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from registrar.utility.enums import DefaultEmail, DefaultUserValues
|
||||
from registrar.models.utility.portfolio_helper import (
|
||||
get_role_display,
|
||||
get_domain_requests_display,
|
||||
get_domains_display,
|
||||
get_members_display,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -479,15 +484,15 @@ class MemberExport(BaseExport):
|
|||
"""
|
||||
return [
|
||||
"Email",
|
||||
"Organization admin",
|
||||
"Member access",
|
||||
"Invited by",
|
||||
"Joined date",
|
||||
"Last active",
|
||||
"Domain requests",
|
||||
"Member management",
|
||||
"Domain management",
|
||||
"Number of domains",
|
||||
"Members",
|
||||
"Domains",
|
||||
"Number domains assigned",
|
||||
"Domain assignments",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
@ -503,15 +508,15 @@ class MemberExport(BaseExport):
|
|||
length_user_managed_domains = len(user_managed_domains)
|
||||
FIELDS = {
|
||||
"Email": model.get("email_display"),
|
||||
"Organization admin": bool(UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles),
|
||||
"Member access": get_role_display(roles),
|
||||
"Invited by": model.get("invited_by"),
|
||||
"Joined date": model.get("joined_date"),
|
||||
"Last active": model.get("last_active"),
|
||||
"Domain requests": UserPortfolioPermission.get_domain_request_permission_display(roles, permissions),
|
||||
"Member management": UserPortfolioPermission.get_member_permission_display(roles, permissions),
|
||||
"Domain management": bool(length_user_managed_domains > 0),
|
||||
"Number of domains": length_user_managed_domains,
|
||||
"Domains": ",".join(user_managed_domains),
|
||||
"Domain requests": f"{get_domain_requests_display(roles, permissions)}",
|
||||
"Members": f"{get_members_display(roles, permissions)}",
|
||||
"Domains": f"{get_domains_display(roles, permissions)}",
|
||||
"Number domains assigned": length_user_managed_domains,
|
||||
"Domain assignments": ", ".join(user_managed_domains),
|
||||
}
|
||||
return [FIELDS.get(column, "") for column in columns]
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue