Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2854 - Org Member Invitation - [NL] #3080

Merged
merged 18 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/registrar/assets/js/get-gov.js
Original file line number Diff line number Diff line change
Expand Up @@ -2955,4 +2955,117 @@ document.addEventListener("DOMContentLoaded", () => {

// Add event listener to the suborg dropdown to show/hide the suborg details section
select.addEventListener("change", () => toggleSuborganization());
})();


/**
* An IIFE that handles the modal associated with adding a new member to a portfolio.
*/
(function handleNewMemberModal() {

/*
Populates contents of the "Add Member" confirmation modal
*/
function populatePermissionDetails(permission_details_div_id) {
const permissionDetailsContainer = document.getElementById("permission_details");
permissionDetailsContainer.innerHTML = ""; // Clear previous content

// Get all permission sections (divs with h3 and radio inputs)
const permissionSections = document.querySelectorAll("#"+permission_details_div_id+" > h3");
CocoByte marked this conversation as resolved.
Show resolved Hide resolved

permissionSections.forEach(section => {
// Find the <h3> element text
const sectionTitle = section.textContent;

// Find the associated radio buttons container (next fieldset)
const fieldset = section.nextElementSibling;

if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') {
// Get the selected radio button within this fieldset
const selectedRadio = fieldset.querySelector('input[type="radio"]:checked');

// If a radio button is selected, get its label text
let selectedPermission = "No permission selected";
if (selectedRadio) {
const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`);
selectedPermission = label ? label.textContent : "No permission selected";
}

// Create new elements for the modal content
const titleElement = document.createElement("h4");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary");
titleElement.classList.add("margin-bottom-0");

const permissionElement = document.createElement("p");
permissionElement.textContent = selectedPermission;
permissionElement.classList.add("margin-top-0");

// Append to the modal content container
permissionDetailsContainer.appendChild(titleElement);
permissionDetailsContainer.appendChild(permissionElement);
}
});
}

/*
Updates and opens the "Add Member" confirmation modal.
*/
function openAddMemberConfirmationModal() {
//------- Populate modal details
// Get email value
let emailValue = document.getElementById('id_email').value;
document.getElementById('modalEmail').textContent = emailValue;

// Get selected radio button for access level
let selectedAccess = document.querySelector('input[name="member_access_level"]:checked');
let accessText = selectedAccess ? selectedAccess.value : "No access level selected"; //nextElementSibling.textContent.trim()
CocoByte marked this conversation as resolved.
Show resolved Hide resolved
document.getElementById('modalAccessLevel').textContent = accessText;

// Populate permission details based on access level
if (selectedAccess && selectedAccess.value === 'admin') {
populatePermissionDetails('new-member-admin-permissions')
} else {
populatePermissionDetails('new-member-basic-permissions')
}
CocoByte marked this conversation as resolved.
Show resolved Hide resolved

//------- Show the modal
let modalTrigger = document.querySelector("#invite_member_trigger");
if (modalTrigger) {
modalTrigger.click()
}
}

document.getElementById("confirm_new_member_submit").addEventListener("click", function() {
// Upon confirmation, submit the form
document.getElementById("add_member_form").submit();
});

document.getElementById("add_member_form").addEventListener("submit", function(event) {
event.preventDefault(); // Prevents the form from submitting
const form = document.getElementById("add_member_form")
const formData = new FormData(form);

// Check if the form is valid
// If the form is valid, open the confirmation modal
// If the form is invalid, submit it to trigger error
fetch(form.action, {
method: "POST",
body: formData,
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRFToken": getCsrfToken()
}
})
.then(response => response.json())
.then(data => {
if (data.is_valid) {
// If the form is valid, show the confirmation modal before submitting
openAddMemberConfirmationModal();
} else {
// If the form is not valid, trigger error messages by firing a submit event
form.submit();
}
});
});
CocoByte marked this conversation as resolved.
Show resolved Hide resolved
})();
88 changes: 82 additions & 6 deletions src/registrar/templates/portfolio_members_add_new.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ <h1>Add a new member</h1>

{% include "includes/required_fields.html" %}

<form class="usa-form usa-form--large" method="post" novalidate>
<form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate>
CocoByte marked this conversation as resolved.
Show resolved Hide resolved

<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Email</h2>
Expand Down Expand Up @@ -80,12 +81,17 @@ <h2>Member Access</h2>
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>

<h3 class="margin-bottom-0">Organization domain requests</h3>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_domain_request_permissions %}
{% endwith %}

<h3 class="margin-bottom-0 margin-top-3">Organization members</h3>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_members_permissions %}
{% endwith %}
Expand All @@ -94,8 +100,12 @@ <h3 class="margin-bottom-0 margin-top-3">Organization members</h3>
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level access</p>
{% input_with_errors form.basic_org_domain_request_permissions %}
<p>Member permissions available for basic-level acccess.</p>

<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.basic_org_domain_request_permissions %}
{% endwith %}
</div>

<!-- Submit/cancel buttons -->
Expand All @@ -108,10 +118,76 @@ <h2>Basic member permissions</h2>
aria-label="Cancel adding new member"
>Cancel
</a>
<button type="submit" class="usa-button">Invite Member</button>
<a
id="invite_member_trigger"
href="#invite-member-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
<button id="invite_new_member_submit" class="usa-button">Invite Member</button>

Assuming that this button just opens the model, this shouldn't be type="submit" right? I think we can trigger that with js

</div>
</form>

<div
class="usa-modal"
id="invite-member-modal"
aria-labelledby="invite-member-heading"
aria-describedby="confirm-invite-description"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Q) Is this id defined anywhere? I can't find it in the html (at least looking briefly)

style="display: none;"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
style="display: none;"

I think this is hidden by default via uswds. If this is appearing after page load, there may be a bug

>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="invite-member-heading">
Invite this member to the organization?
</h2>
<h3 class="summary-item__title
text-primary-dark">Member information and permissions</h3>
<div class="usa-prose">
<!-- Display email as a header and access level -->
<h4 class="text-primary">Email</h4>
<p class="margin-top-0" id="modalEmail"></p>

<h4 class="text-primary">Member Access</h4>
<p class="margin-top-0" id="modalAccessLevel"></p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nitpick/optional) Maybe better described as modalMemberAccessLevel


<!-- Dynamic Permissions Details -->
<div id="permission_details"></div>
Comment on lines +154 to +155
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky. Nice job

</div>

<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button id="confirm_new_member_submit" type="submit" class="usa-button">Yes, invite member</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled"
data-close-modal
onclick="closeModal()"
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
onclick="closeModal()"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#close"></use>
</svg>
</button>
</div>
</div>


{% endblock portfolio_content%}


133 changes: 133 additions & 0 deletions src/registrar/tests/test_views_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2380,3 +2380,136 @@ def test_requesting_entity_manage(self):
self.assertContains(response, "Requesting entity")
self.assertContains(response, "moon")
self.assertContains(response, "kepler, AL")


class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):

@classmethod
def setUpClass(cls):
super().setUpClass()

# Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")

# Add an invited member who has been invited to manage domains
cls.invited_member_email = "[email protected]"
cls.invitation = PortfolioInvitation.objects.create(
email=cls.invited_member_email,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)

cls.new_member_email = "[email protected]"

# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=cls.user,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)

@classmethod
def tearDownClass(cls):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
super().tearDownClass()

@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_new_users(self):
"""Tests the member invitation flow for new users."""
self.client.force_login(self.user)

# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

# Simulate submission of member invite for new user
final_response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.new_member_email,
},
)

# Ensure the final submission is successful
self.assertEqual(final_response.status_code, 302) # redirects after success

# Validate Database Changes
portfolio_invite = PortfolioInvitation.objects.filter(
email=self.new_member_email, portfolio=self.portfolio
).first()
self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, self.new_member_email)

@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_previously_invited_member(self):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)

# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

invite_count_before = PortfolioInvitation.objects.count()

# Simulate submission of member invite for user who has already been invited
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.invited_member_email,
},
)
self.assertEqual(response.status_code, 302) # Redirects

# TODO: verify messages

# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)

@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_existing_member(self):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)

# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

invite_count_before = PortfolioInvitation.objects.count()

# Simulate submission of member invite for user who has already been invited
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.user.email,
},
)
self.assertEqual(response.status_code, 302) # Redirects

# TODO: verify messages

# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
Loading
Loading