From 4e7201981283e6001a83f5694c7c976819263a4c Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Wed, 30 Oct 2024 07:37:56 -0700 Subject: [PATCH] feat: adds flex groups dropdown menu in assignment modal (#1335) * feat: adds group selection to assignment modal --- .../PeopleManagement/EditGroupNameModal.jsx | 2 +- src/components/PeopleManagement/constants.js | 1 + .../AssignmentModalContent.jsx | 76 ++++++++++- .../AssignmentModalFlexGroup.jsx | 74 ++++++++++ .../AssignmentModalSummaryErrorState.jsx | 2 +- .../NewAssignmentModalButton.jsx | 18 ++- .../cards/tests/CourseCard.test.jsx | 76 +++++++++++ .../data/constants.js | 1 + .../data/hooks/index.js | 1 + .../tests/useEnterpriseFlexGroups.test.jsx | 100 ++++++++++++++ .../data/hooks/useEnterpriseFlexGroups.js | 34 +++++ .../data/hooks/useGroupDropdownToggle.js | 126 ++++++++++++++++++ .../invite-modal/InviteModalContent.jsx | 2 +- .../styles/index.scss | 8 ++ src/data/services/apiServiceUtils.js | 24 ++++ .../services/tests/apiServiceUtils.test.js | 85 ++++++++++++ 16 files changed, 620 insertions(+), 10 deletions(-) create mode 100644 src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx create mode 100644 src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js create mode 100644 src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js create mode 100644 src/data/services/tests/apiServiceUtils.test.js diff --git a/src/components/PeopleManagement/EditGroupNameModal.jsx b/src/components/PeopleManagement/EditGroupNameModal.jsx index ef7f540301..ca43f78bd0 100644 --- a/src/components/PeopleManagement/EditGroupNameModal.jsx +++ b/src/components/PeopleManagement/EditGroupNameModal.jsx @@ -6,7 +6,7 @@ import { ActionRow, Form, ModalDialog, Spinner, StatefulButton, useToggle, } from '@openedx/paragon'; -import MAX_LENGTH_GROUP_NAME from './constants'; +import { MAX_LENGTH_GROUP_NAME } from './constants'; import LmsApiService from '../../data/services/LmsApiService'; import GeneralErrorModal from './GeneralErrorModal'; diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index c253692b5b..abd76681b8 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -2,3 +2,4 @@ export const MAX_LENGTH_GROUP_NAME = 60; export const GROUP_TYPE_BUDGET = 'budget'; export const GROUP_TYPE_FLEX = 'flex'; +export const GROUP_DROPDOWN_TEXT = 'Select group'; diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index 2acd9736be..8b04b3a947 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -16,10 +16,20 @@ import AssignmentModalSummary from './AssignmentModalSummary'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isAssignEmailAddressesInputValueValid } from '../cards/data'; import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpCollapsibles'; import EVENT_NAMES from '../../../eventTracking'; +import AssignmentModalFlexGroup from './AssignmentModalFlexGroup'; +import useGroupDropdownToggle from '../data/hooks/useGroupDropdownToggle'; +import { GROUP_DROPDOWN_TEXT } from '../../PeopleManagement/constants'; const AssignmentModalContent = ({ - enterpriseId, course, courseRun, onEmailAddressesChange, + enterpriseId, + course, + courseRun, + onEmailAddressesChange, + enterpriseFlexGroups, + onGroupSelectionsChanged, + enterpriseFeatures, }) => { + const shouldShowGroupsDropdown = enterpriseFeatures.enterpriseGroupsV2 && enterpriseFlexGroups?.length > 0; const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); const spendAvailable = subsidyAccessPolicy.aggregates.spendAvailableUsd; @@ -28,6 +38,22 @@ const AssignmentModalContent = ({ const [assignmentAllocationMetadata, setAssignmentAllocationMetadata] = useState({}); const intl = useIntl(); const { contentPrice } = courseRun; + const [groupMemberEmails, setGroupMemberEmails] = useState([]); + const [checkedGroups, setCheckedGroups] = useState({}); + const [dropdownToggleLabel, setDropdownToggleLabel] = useState(GROUP_DROPDOWN_TEXT); + const { + dropdownRef, + handleCheckedGroupsChanged, + handleGroupsChanged, + handleSubmitGroup, + } = useGroupDropdownToggle({ + checkedGroups, + dropdownToggleLabel, + onGroupSelectionsChanged, + setCheckedGroups, + setDropdownToggleLabel, + setGroupMemberEmails, + }); const handleEmailAddressInputChange = (e) => { const inputValue = e.target.value; setEmailAddressesInputValue(inputValue); @@ -51,10 +77,22 @@ const AssignmentModalContent = ({ debouncedHandleEmailAddressesChanged(emailAddressesInputValue); }, [emailAddressesInputValue, debouncedHandleEmailAddressesChanged]); + useEffect(() => { + handleGroupsChanged(checkedGroups); + const selectedGroups = Object.keys(checkedGroups).filter(group => checkedGroups[group].checked === true); + if (selectedGroups.length === 1) { + setDropdownToggleLabel(`${checkedGroups[selectedGroups[0]]?.name} (${checkedGroups[selectedGroups[0]]?.memberEmails.length})`); + } else if (selectedGroups.length > 1) { + setDropdownToggleLabel(`${selectedGroups.length} groups selected`); + } else { + setDropdownToggleLabel(GROUP_DROPDOWN_TEXT); + } + }, [checkedGroups, handleGroupsChanged]); + // Validate the learner emails from user input whenever it changes useEffect(() => { const allocationMetadata = isAssignEmailAddressesInputValueValid({ - learnerEmails, + learnerEmails: [...learnerEmails, ...groupMemberEmails], remainingBalance: spendAvailable, contentPrice, }); @@ -68,10 +106,20 @@ const AssignmentModalContent = ({ } if (allocationMetadata.canAllocate) { onEmailAddressesChange(learnerEmails, { canAllocate: true }); + onGroupSelectionsChanged(groupMemberEmails, { canAllocate: true }); } else { onEmailAddressesChange([]); + onGroupSelectionsChanged([]); } - }, [onEmailAddressesChange, learnerEmails, contentPrice, spendAvailable, enterpriseId]); + }, [ + onEmailAddressesChange, + learnerEmails, + contentPrice, + spendAvailable, + enterpriseId, + groupMemberEmails, + onGroupSelectionsChanged, + ]); return ( @@ -97,6 +145,16 @@ const AssignmentModalContent = ({ description="Header for the section where we assign a course to learners" /> + {shouldShowGroupsDropdown && ( + + )}
@@ -209,10 +267,20 @@ AssignmentModalContent.propTypes = { contentPrice: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }).isRequired, onEmailAddressesChange: PropTypes.func.isRequired, + onGroupSelectionsChanged: PropTypes.func.isRequired, + enterpriseFlexGroups: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + uuid: PropTypes.string, + acceptedMembersCount: PropTypes.number, + })), + enterpriseFeatures: PropTypes.shape({ + enterpriseGroupsV2: PropTypes.bool.isRequired, + }), }; const mapStateToProps = state => ({ enterpriseId: state.portalConfiguration.enterpriseId, + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, }); export default connect(mapStateToProps)(AssignmentModalContent); diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx new file mode 100644 index 0000000000..177102d0c1 --- /dev/null +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + Form, MenuItem, Dropdown, Button, +} from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; + +const AssignmentModalFlexGroup = ({ + enterpriseFlexGroups, + onCheckedGroupsChanged, + checkedGroups, + onHandleSubmitGroup, + dropdownToggleLabel, + dropdownRef, +}) => { + const renderFlexGroupSelection = enterpriseFlexGroups.map(flexGroup => ( + + {flexGroup.name} ({flexGroup.acceptedMembersCount}) + + )); + + return ( + + + Groups + + {dropdownToggleLabel} + + + {renderFlexGroupSelection} + + + + + + + + ); +}; + +AssignmentModalFlexGroup.propTypes = { + checkedGroups: PropTypes.shape({ + id: PropTypes.string, + memberEmails: PropTypes.arrayOf(PropTypes.string), + name: PropTypes.string, + checked: PropTypes.bool, + }).isRequired, + onHandleSubmitGroup: PropTypes.func.isRequired, + onCheckedGroupsChanged: PropTypes.func.isRequired, + enterpriseFlexGroups: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + uuid: PropTypes.string, + acceptedMembersCount: PropTypes.number, + })), + dropdownToggleLabel: PropTypes.string.isRequired, + dropdownRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), +}; + +export default AssignmentModalFlexGroup; diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalSummaryErrorState.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalSummaryErrorState.jsx index d1832820ee..f8d0659aed 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalSummaryErrorState.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalSummaryErrorState.jsx @@ -21,7 +21,7 @@ const AssignmentModalSummaryErrorState = () => ( description="Error message when course assignment fails due to invalid learner emails." /> - . + ); diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index ee424aad6d..63b6bfe8ee 100644 --- a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx +++ b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx @@ -17,7 +17,7 @@ import EVENT_NAMES from '../../../eventTracking'; import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper'; import { getAssignableCourseRuns, learnerCreditManagementQueryKeys, LEARNER_CREDIT_ROUTE, useBudgetId, - useSubsidyAccessPolicy, + useSubsidyAccessPolicy, useEnterpriseFlexGroups, } from '../data'; import AssignmentModalContent from './AssignmentModalContent'; import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals'; @@ -41,10 +41,12 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { const { subsidyAccessPolicyId } = useBudgetId(); const [isOpen, open, close] = useToggle(false); const [learnerEmails, setLearnerEmails] = useState([]); + const [groupLearnerEmails, setGroupLearnerEmails] = useState([]); const [canAllocateAssignments, setCanAllocateAssignments] = useState(false); const [assignButtonState, setAssignButtonState] = useState('default'); const [createAssignmentsErrorReason, setCreateAssignmentsErrorReason] = useState(); const [assignmentRun, setAssignmentRun] = useState(); + const { data: enterpriseFlexGroups } = useEnterpriseFlexGroups(enterpriseId); const { successfulAssignmentToast: { displayToastForAssignmentAllocation }, } = useContext(BudgetDetailPageContext); @@ -119,6 +121,14 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { setCanAllocateAssignments(canAllocate); }, []); + const handleGroupSelectionsChanged = useCallback(( + value, + { canAllocate = false } = {}, + ) => { + setGroupLearnerEmails(value); + setCanAllocateAssignments(canAllocate); + }, []); + const onSuccessEnterpriseTrackEvents = ({ totalLearnersAllocated, totalLearnersAlreadyAllocated, @@ -142,7 +152,7 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { const payload = snakeCaseObject({ contentPriceCents: assignmentRun.contentPrice * 100, // Convert to USD cents contentKey: assignmentRun.key, - learnerEmails, + learnerEmails: [...learnerEmails, ...groupLearnerEmails], }); const mutationArgs = { subsidyAccessPolicyId, @@ -198,7 +208,7 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { ...sharedEnterpriseTrackEventMetadata, contentKey: assignmentRun.key, parentContentKey: course.key, - totalAllocatedLearners: learnerEmails.length, + totalAllocatedLearners: learnerEmails.length + groupLearnerEmails.length, errorStatus: httpErrorStatus, errorReason, response: err, @@ -316,6 +326,8 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { course={course} courseRun={assignmentRun} onEmailAddressesChange={handleEmailAddressesChanged} + enterpriseFlexGroups={enterpriseFlexGroups} + onGroupSelectionsChanged={handleGroupSelectionsChanged} /> ({ ...jest.requireActual('@edx/frontend-enterprise-utils'), @@ -48,7 +50,9 @@ jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), useBudgetId: jest.fn(), useSubsidyAccessPolicy: jest.fn(), + useEnterpriseFlexGroups: jest.fn(), })); +jest.mock('../../data/hooks/useEnterpriseFlexGroups'); jest.mock('../../../../data/services/EnterpriseAccessApiService'); const futureStartDate = dayjs().add(10, 'days').toISOString(); @@ -168,6 +172,9 @@ const initialStoreState = { portalConfiguration: { enterpriseId: enterpriseUUID, enterpriseSlug, + enterpriseFeatures: { + enterpriseGroupsV2: true, + }, }, }; @@ -185,6 +192,24 @@ const mockSubsidyAccessPolicy = { isLateRedemptionAllowed: false, }; const mockLearnerEmails = ['hello@example.com', 'world@example.com', 'dinesh@example.com']; +const mockEnterpriseFlexGroup = [ + { + enterpriseCustomer: 'test-enterprise-customer-1', + name: 'Group 1', + uuid: 'test-uuid', + acceptedMembersCount: 2, + groupType: 'flex', + created: '2024-05-31T02:23:33.311109Z', + }, + { + enterpriseCustomer: 'test-enterprise-customer-2', + name: 'Group 2', + uuid: 'test-uuid-2', + acceptedMembersCount: 1, + groupType: 'flex', + created: '2024-05-31T02:23:33.311109Z', + }, +]; const mockDisplaySuccessfulAssignmentToast = jest.fn(); const defaultBudgetDetailPageContextValue = { @@ -258,6 +283,9 @@ describe('Course card works as expected', () => { isLoading: false, isLateRedemptionAllowed: false, }); + useEnterpriseFlexGroups.mockReturnValue({ + data: mockEnterpriseFlexGroup, + }); }); afterEach(() => { @@ -785,4 +813,52 @@ describe('Course card works as expected', () => { } }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); }); + + test('opens assignment modal and selects flex group assignments', async () => { + useSubsidyAccessPolicy.mockReturnValue({ + data: { + ...mockSubsidyAccessPolicy, + aggregates: { + ...mockSubsidyAccessPolicy.aggregates, + spendAvailableUsd: 1000, + }, + }, + isLoading: false, + }); + getGroupMemberEmails.mockReturnValue(mockLearnerEmails); + renderWithRouter(); + const assignCourseCTA = getButtonElement('Assign'); + expect(assignCourseCTA).toBeInTheDocument(); + userEvent.click(assignCourseCTA); + expect(screen.getByText(enrollByDropdownText)).toBeInTheDocument(); + userEvent.click(screen.getByText(enrollByDropdownText)); + const assignmentModal = within(screen.getByRole('dialog')); + + // Verify "Assign" CTA is disabled + expect(getButtonElement('Assign', { screenOverride: assignmentModal })).toBeDisabled(); + + // Verify dropdown menu + expect( + assignmentModal.getByText('Select one or more group to add its members to the assignment.'), + ).toBeInTheDocument(); + const dropdownMenu = assignmentModal.getByText('Select group'); + expect(dropdownMenu).toBeInTheDocument(); + userEvent.click(dropdownMenu); + const group1 = assignmentModal.getByText('Group 1 (2)'); + const group2 = assignmentModal.getByText('Group 2 (1)'); + expect(group1).toBeInTheDocument(); + expect(group2).toBeInTheDocument(); + + userEvent.click(group1); + userEvent.click(group2); + const applyButton = assignmentModal.getByText('Apply selections'); + + await waitFor(() => { + userEvent.click(applyButton); + expect(assignmentModal.getByText('2 groups selected')).toBeInTheDocument(); + expect(assignmentModal.getByText('hello@example.com')).toBeInTheDocument(); + expect(assignmentModal.getByText('world@example.com')).toBeInTheDocument(); + expect(assignmentModal.getByText('dinesh@example.com')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index 7a26c27442..ec15d598c5 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -127,6 +127,7 @@ export const learnerCreditManagementQueryKeys = { group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid], budgetGroupLearners: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'group learners'], enterpriseCustomer: (enterpriseId) => [...learnerCreditManagementQueryKeys.all, 'enterpriseCustomer', enterpriseId], + flexGroup: (enterpriseId) => [...learnerCreditManagementQueryKeys.enterpriseCustomer(enterpriseId), 'flexGroup'], }; // Route to learner credit diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index b9922cf563..7bba977187 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -21,3 +21,4 @@ export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; export { default as useAllEnterpriseGroups } from './useAllEnterpriseGroups'; export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; +export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx new file mode 100644 index 0000000000..a8e279f9fc --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx @@ -0,0 +1,100 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { camelCaseObject } from '@edx/frontend-platform'; + +import { fetchPaginatedData } from '../../../../../data/services/apiServiceUtils'; +import LmsApiService from '../../../../../data/services/LmsApiService'; +import { getGroupMemberEmails } from '../useEnterpriseFlexGroups'; + +const axiosMock = new MockAdapter(axios); +jest.mock('../../../../../data/services/apiServiceUtils'); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockEnterpriseFlexGroupsResponse = { + results: [ + { + enterprise_customer: '66b5922b-a22b-4a7b-b587-d4af0378bd6f', + name: 'the cool group', + uuid: 'eb0172cb-8d06-42d8-9e64-c6f6e4fa118e', + applies_to_all_contexts: false, + accepted_members_count: 1, + group_type: 'flex', + created: '2024-04-11T18:40:13.803371Z', + }, + { + enterprise_customer: '66b5922b-a22b-4a7b-b587-d4af0378bd6f', + name: 'the super cool group', + uuid: '0af1c58a-e7d1-493a-b31f-db819ba48687', + applies_to_all_contexts: false, + accepted_members_count: 0, + group_type: 'flex', + created: '2024-04-11T18:40:27.076211Z', + }, + { + enterprise_customer: '31885c12-f5ae-4b2c-b78d-460cb2e0972b', + name: 'Group ABC', + uuid: 'f9aa8f4e-2baa-45c0-aaed-633f7746319a', + applies_to_all_contexts: false, + accepted_members_count: 0, + group_type: 'budget', + created: '2024-05-31T02:23:33.311109Z', + }, + ], +}; + +const mockGroupUuid = 'test-group-uuid'; +const mockLearners = { + results: [ + { + enterprise_customer_user_id: 4967, + lms_user_id: 5272644, + pending_enterprise_customer_user_id: null, + enterprise_group_membership_uuid: '79e86b2a-97af-4136-a9d0-367fb555bc42', + member_details: { + user_email: 'bbeggs+alc@2u.com', + user_name: 'bbtestalc', + }, + recent_action: 'Accepted: October 16, 2024', + status: 'accepted', + activated_at: '2024-10-16T02:48:16Z', + }, + ], +}; + +describe('useEnterpriseFlexGroups', () => { + const enterpriseGroupListUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise_group/`; + beforeEach(() => { + jest.clearAllMocks(); + fetchPaginatedData.mockReturnValue( + { + results: camelCaseObject([...mockEnterpriseFlexGroupsResponse.results]), + response: camelCaseObject(mockEnterpriseFlexGroupsResponse), + }, + ); + axiosMock.reset(); + }); + + it('returns the api call with a 200', async () => { + axiosMock.onGet(enterpriseGroupListUrl).reply(200, mockEnterpriseFlexGroupsResponse); + const { results } = await fetchPaginatedData(mockEnterpriseId); + expect(results).toEqual(camelCaseObject(mockEnterpriseFlexGroupsResponse.results)); + }); +}); + +describe('getGroupMemberEmails', () => { + beforeEach(() => { + jest.clearAllMocks(); + fetchPaginatedData.mockReturnValue( + { + results: camelCaseObject([...mockLearners.results]), + response: camelCaseObject(mockLearners), + }, + ); + }); + + it('returns the member emails', async () => { + const groupMemberEmails = await getGroupMemberEmails(mockGroupUuid); + expect(groupMemberEmails).toEqual([camelCaseObject(mockLearners).results[0].memberDetails.userEmail]); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js new file mode 100644 index 0000000000..d7eceb7ae4 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +import { learnerCreditManagementQueryKeys } from '../constants'; +import { fetchPaginatedData } from '../../../../data/services/apiServiceUtils'; +import LmsApiService from '../../../../data/services/LmsApiService'; +import { GROUP_TYPE_FLEX } from '../../../PeopleManagement/constants'; + +export const getGroupMemberEmails = async (groupUUID) => { + const url = `${LmsApiService.enterpriseGroupUrl}${groupUUID}/learners`; + const { results } = await fetchPaginatedData(url); + const memberEmails = results.map(result => result?.memberDetails?.userEmail); + return memberEmails; +}; + +/** + * Hook to get a list of flex groups associated with an enterprise customer. + * + * @param enterpriseId The enterprise customer UUID. + * @returns A list of flex groups associated with an enterprise customer. + */ +export const getEnterpriseFlexGroups = async ({ enterpriseId }) => { + const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); + const flexGroups = results.filter(result => ( + result.enterpriseCustomer === enterpriseId && result.groupType === GROUP_TYPE_FLEX)); + return flexGroups; +}; + +const useEnterpriseFlexGroups = (enterpriseId, { queryOptions } = {}) => useQuery({ + queryKey: learnerCreditManagementQueryKeys.flexGroup(enterpriseId), + queryFn: () => getEnterpriseFlexGroups({ enterpriseId }), + ...queryOptions, +}); + +export default useEnterpriseFlexGroups; diff --git a/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js new file mode 100644 index 0000000000..d4fbd4ae76 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js @@ -0,0 +1,126 @@ +import { useCallback, useRef, useEffect } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import { getGroupMemberEmails } from './useEnterpriseFlexGroups'; +import { GROUP_DROPDOWN_TEXT } from '../../../PeopleManagement/constants'; + +const useGroupDropdownToggle = ({ + setCheckedGroups, + setGroupMemberEmails, + onGroupSelectionsChanged, + checkedGroups, + setDropdownToggleLabel, + dropdownToggleLabel, +}) => { + const handleCheckedGroupsChanged = async (e) => { + const { value, checked, id } = e.target; + if (checked) { + try { + const memberEmails = await getGroupMemberEmails(id); + setCheckedGroups((prev) => ({ + ...prev, + [id]: { + checked, + name: value, + memberEmails, + isApplied: false, + }, + })); + } catch (err) { + logError(err); + } + } else if (!checked) { + setCheckedGroups((prev) => ({ + ...prev, + [id]: { + ...prev[id], + checked: false, + isUnapplied: false, + }, + })); + } + }; + + const dropdownRef = useRef(null); + useEffect(() => { + // Handles user clicking outside of the dropdown menu. + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setDropdownToggleLabel(GROUP_DROPDOWN_TEXT); + Object.keys(checkedGroups).forEach(group => { + // If the user has checked the boxes but has not applied the selections, + // we clear the selection when the user closes the menu. + if (!checkedGroups[group].isApplied) { + setCheckedGroups((prev) => ({ + ...prev, + [group]: { + ...prev[group], + checked: false, + }, + })); + // If the user has unchecked the boxes but has not applied the selections, + // we revert back to the previously selected boxes when the user closes the menu. + } else if (!checkedGroups[group].isChecked && !checkedGroups[group]?.isUnapplied) { + setDropdownToggleLabel(dropdownToggleLabel); + setCheckedGroups((prev) => ({ + ...prev, + [group]: { + ...prev[group], + checked: true, + }, + })); + } + }); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [checkedGroups, setCheckedGroups, setDropdownToggleLabel, dropdownToggleLabel]); + + const handleGroupsChanged = useCallback(async (groups) => { + if (Object.keys(groups).length === 0) { + setGroupMemberEmails([]); + onGroupSelectionsChanged([]); + } + }, [onGroupSelectionsChanged, setGroupMemberEmails]); + + const handleSubmitGroup = () => { + const memberEmails = []; + Object.keys(checkedGroups).forEach(group => { + if (checkedGroups[group].checked) { + checkedGroups[group].memberEmails.forEach(email => { + if (!memberEmails.includes(email)) { + memberEmails.push(email); + } + }); + setCheckedGroups((prev) => ({ + ...prev, + [group]: { + ...prev[group], + isApplied: true, + }, + })); + } else if (!checkedGroups[group].checked && !checkedGroups[group].isUnapplied) { + setCheckedGroups((prev) => ({ + ...prev, + [group]: { + ...prev[group], + isUnapplied: true, + checked: false, + }, + })); + } + }); + setGroupMemberEmails(memberEmails); + }; + + return { + dropdownRef, + handleCheckedGroupsChanged, + handleGroupsChanged, + handleSubmitGroup, + }; +}; + +export default useGroupDropdownToggle; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx index 82759159ad..a8ab62bc4d 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx @@ -16,7 +16,7 @@ import InviteModalMembershipInfo from './InviteModalMembershipInfo'; import InviteModalBudgetCard from './InviteModalBudgetCard'; import InviteModalPermissions from './InviteModalPermissions'; import InviteSummaryCount from './InviteSummaryCount'; -import MAX_LENGTH_GROUP_NAME from '../../PeopleManagement/constants'; +import { MAX_LENGTH_GROUP_NAME } from '../../PeopleManagement/constants'; const InviteModalContent = ({ onEmailAddressesChange, diff --git a/src/components/learner-credit-management/styles/index.scss b/src/components/learner-credit-management/styles/index.scss index a3e5c15b52..2693e5aae3 100644 --- a/src/components/learner-credit-management/styles/index.scss +++ b/src/components/learner-credit-management/styles/index.scss @@ -23,6 +23,14 @@ } } +.group-dropdown { + width: inherit; + .btn, + .pgn__menu-select-popup { + width: inherit; + justify-content: space-between; + }; +} // Must be defined outside of `.learner-credit-management` to ensure the styles are applied to the contents of // the `FullscreenModal`, which renders in a React Portal. .assignment-modal-collapsible-trigger { diff --git a/src/data/services/apiServiceUtils.js b/src/data/services/apiServiceUtils.js index ef1e8ca588..f62f2b36aa 100644 --- a/src/data/services/apiServiceUtils.js +++ b/src/data/services/apiServiceUtils.js @@ -1,4 +1,6 @@ import _ from 'lodash'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; function generateFormattedStatusUrl(url, currentPage, options) { // pages index from 1 in backend, frontend components index from 0 @@ -14,4 +16,26 @@ function generateFormattedStatusUrl(url, currentPage, options) { return `${url}${paramString}`; } +/** + * Recursive function to fetch all results, traversing a paginated API response. The + * response and the list of results are already camelCased. + * + * @param {string} url Request URL + * @param {Array} [results] Array of results. + * @returns Array of all results for authenticated user. + */ +export async function fetchPaginatedData(url, results = []) { + const response = await getAuthenticatedHttpClient().get(url); + const responseData = camelCaseObject(response.data); + const resultsCopy = [...results]; + resultsCopy.push(...responseData.results); + if (responseData.next) { + return fetchPaginatedData(responseData.next, resultsCopy); + } + return { + results: resultsCopy, + response: responseData, + }; +} + export default generateFormattedStatusUrl; diff --git a/src/data/services/tests/apiServiceUtils.test.js b/src/data/services/tests/apiServiceUtils.test.js new file mode 100644 index 0000000000..e7acc3ec4c --- /dev/null +++ b/src/data/services/tests/apiServiceUtils.test.js @@ -0,0 +1,85 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 as uuidv4 } from 'uuid'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { fetchPaginatedData } from '../apiServiceUtils'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +describe('fetchPaginatedData', () => { + const EXAMPLE_ENDPOINT = 'http://example.com/api/v1/data'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty data results', async () => { + axiosMock.onGet(EXAMPLE_ENDPOINT).reply(200, { + count: 0, + prev: null, + next: null, + num_pages: 0, + results: [], + }); + const result = await fetchPaginatedData(EXAMPLE_ENDPOINT); + expect(result).toEqual({ + results: [], + response: { + count: 0, + prev: null, + next: null, + numPages: 0, + results: [], + }, + }); + }); + + it('traverses pagination', async () => { + const urlFirstPage = `${EXAMPLE_ENDPOINT}?page=1`; + const urlSecondPage = `${EXAMPLE_ENDPOINT}?page=2`; + const mockResult = { + uuid: uuidv4(), + }; + const mockSecondResult = { + uuid: uuidv4(), + }; + axiosMock.onGet(urlFirstPage).reply(200, { + count: 2, + prev: null, + next: urlSecondPage, + num_pages: 2, + results: [mockResult], + }); + axiosMock.onGet(urlSecondPage).reply(200, { + count: 2, + prev: null, + next: null, + num_pages: 2, + results: [mockSecondResult], + enterprise_features: { + feature_a: true, + }, + }); + const result = await fetchPaginatedData(urlFirstPage); + expect(result).toEqual({ + results: [mockResult, mockSecondResult], + response: { + count: 2, + prev: null, + next: null, + numPages: 2, + results: [mockSecondResult], + enterpriseFeatures: { + featureA: true, + }, + }, + }); + }); +});