From c80fc7b7038735f3867f11ad4cf7ca8cedc0778b Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Wed, 9 Oct 2024 19:42:03 +0000 Subject: [PATCH 1/6] feat: adds group selection to assignment modal --- .../AssignmentModalContent.jsx | 26 +++++++++++- .../data/hooks/useEnterpriseGroup.js | 40 +++++++++++++++++++ .../styles/index.scss | 8 ++++ src/data/services/apiServiceUtils.js | 24 +++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index 2acd9736be..3c17138784 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -4,7 +4,7 @@ import React, { import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { - Card, Col, Container, Form, Row, Stack, + Card, Col, Container, Form, Row, Stack, MenuItem, Menu, SelectMenu, DropdownButton, Dropdown } from '@openedx/paragon'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; @@ -12,6 +12,7 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { connect } from 'react-redux'; import BaseCourseCard from '../cards/BaseCourseCard'; import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from '../data'; +import { useEnterpriseCustomerFlexGroup } from '../data/hooks/useEnterpriseGroup'; import AssignmentModalSummary from './AssignmentModalSummary'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isAssignEmailAddressesInputValueValid } from '../cards/data'; import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpCollapsibles'; @@ -22,6 +23,8 @@ const AssignmentModalContent = ({ }) => { const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + const { enterpriseCustomerFlexGroup } = useEnterpriseCustomerFlexGroup(enterpriseId); + console.log(enterpriseCustomerFlexGroup) const spendAvailable = subsidyAccessPolicy.aggregates.spendAvailableUsd; const [learnerEmails, setLearnerEmails] = useState([]); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); @@ -97,6 +100,27 @@ const AssignmentModalContent = ({ description="Header for the section where we assign a course to learners" /> + + console.log(e.target.value)} + defaultValue={['green']} + className="group-dropdown" + > + Groups + + Blue red red red red + + + + + + + { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseCustomerFlexGroup, setEnterpriseCustomerFlexGroup] = useState([]); + + useEffect(() => { + const getRemovedMembers = async () => { + try { + const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); + console.log(results) + const removedMembers = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType==='flex'); + console.log(removedMembers) + setEnterpriseCustomerFlexGroup(removedMembers); + } catch (e) { + logError(e); + } finally { + setIsLoading(false); + } + }; + + if (enterpriseId) { + getRemovedMembers(); + } + }, [enterpriseId, fetchPaginatedData]); + + return { + isEnterpriseCustomerFlexGroup: isLoading, + enterpriseCustomerFlexGroup, + }; +}; + /** * Retrieves a enterprise group by UUID from the API. diff --git a/src/components/learner-credit-management/styles/index.scss b/src/components/learner-credit-management/styles/index.scss index a3e5c15b52..68507c4746 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; From 2f7c9b2552b55e19f463425915318efb0f668c77 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Tue, 15 Oct 2024 15:15:01 +0000 Subject: [PATCH 2/6] feat: populate menu selection --- .../AssignmentModalContent.jsx | 36 +++------- .../AssignmentModalFlexGroup.jsx | 68 +++++++++++++++++++ .../NewAssignmentModalButton.jsx | 4 +- .../data/constants.js | 1 + .../data/hooks/index.js | 1 + .../data/hooks/useEnterpriseFlexGroup.js | 27 ++++++++ .../data/hooks/useEnterpriseGroup.js | 40 ----------- 7 files changed, 110 insertions(+), 67 deletions(-) create mode 100644 src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroup.js diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index 3c17138784..174bbca740 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -11,20 +11,21 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { connect } from 'react-redux'; import BaseCourseCard from '../cards/BaseCourseCard'; -import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from '../data'; -import { useEnterpriseCustomerFlexGroup } from '../data/hooks/useEnterpriseGroup'; +import { formatPrice, useBudgetId, useSubsidyAccessPolicy, useEnterpriseFlexGroup } from '../data'; +// import { useEnterpriseCustomerFlexGroup } from '../data/hooks/useEnterpriseGroup'; 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'; const AssignmentModalContent = ({ - enterpriseId, course, courseRun, onEmailAddressesChange, + enterpriseId, course, courseRun, onEmailAddressesChange, enterpriseFlexGroup, }) => { + // const { data: enterpriseFlexGroup } = useEnterpriseFlexGroup(enterpriseId); + // console.log(enterpriseFlexGroup) const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); - const { enterpriseCustomerFlexGroup } = useEnterpriseCustomerFlexGroup(enterpriseId); - console.log(enterpriseCustomerFlexGroup) const spendAvailable = subsidyAccessPolicy.aggregates.spendAvailableUsd; const [learnerEmails, setLearnerEmails] = useState([]); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); @@ -76,6 +77,7 @@ const AssignmentModalContent = ({ } }, [onEmailAddressesChange, learnerEmails, contentPrice, spendAvailable, enterpriseId]); + return ( @@ -100,27 +102,9 @@ const AssignmentModalContent = ({ description="Header for the section where we assign a course to learners" /> - - console.log(e.target.value)} - defaultValue={['green']} - className="group-dropdown" - > - Groups - - Blue red red red red - - - - - - - + {enterpriseFlexGroup.length > 0 && ( + + )} { + const handleOnChange = () => { + + }; + + const renderFlexGroupSelection = enterpriseFlexGroup.map(flexGroup => { + return ( + {console.log('hello')}}className="group-dropdown mt-2 mb-2 ml-3" as={Form.Checkbox} value={flexGroup.name}>{flexGroup.name} ({flexGroup.acceptedMembersCount}) + ) + }) + // return ( + // + // + + // console.log(e.target.value)} + // defaultValue={['green']} + // className="group-dropdown" + // > + // Groups + // + // {renderFlexGroupSelection} + // + // + // + // {/* + // + // */} + + // + // + // + // + // ) + return ( + + + Groups + + Select group + + + {renderFlexGroupSelection} + + + + + + + ); +}; + +export default AssignmentModalFlexGroup; \ No newline at end of file diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index ee424aad6d..9e00a1dfbe 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, useEnterpriseFlexGroup, } from '../data'; import AssignmentModalContent from './AssignmentModalContent'; import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals'; @@ -45,6 +45,7 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { const [assignButtonState, setAssignButtonState] = useState('default'); const [createAssignmentsErrorReason, setCreateAssignmentsErrorReason] = useState(); const [assignmentRun, setAssignmentRun] = useState(); + const { data: enterpriseFlexGroup } = useEnterpriseFlexGroup(enterpriseId); const { successfulAssignmentToast: { displayToastForAssignmentAllocation }, } = useContext(BudgetDetailPageContext); @@ -316,6 +317,7 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { course={course} courseRun={assignmentRun} onEmailAddressesChange={handleEmailAddressesChanged} + enterpriseFlexGroup={enterpriseFlexGroup} /> [...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 2687a41a63..94a60fe4f4 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -19,3 +19,4 @@ export { default as useEnterpriseCustomer } from './useEnterpriseCustomer'; export { default as useEnterpriseGroup } from './useEnterpriseGroup'; export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; +export { default as useEnterpriseFlexGroup } from './useEnterpriseFlexGroup'; \ No newline at end of file diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroup.js b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroup.js new file mode 100644 index 0000000000..61e06291b7 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroup.js @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; + +import { learnerCreditManagementQueryKeys } from '../constants'; +import { fetchPaginatedData } from '../../../../data/services/apiServiceUtils'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +/** + * 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 getEnterpriseFlexGroup = async ({ queryKey }) => { + const enterpriseId = queryKey[2]; + console.log(queryKey) + const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); + const removedMembers = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType === 'flex'); + return removedMembers +}; + +const useEnterpriseFlexGroup = (enterpriseId, { queryOptions } = {}) => useQuery({ + queryKey: learnerCreditManagementQueryKeys.flexGroup(enterpriseId), + queryFn: getEnterpriseFlexGroup, + ...queryOptions, +}); + +export default useEnterpriseFlexGroup; diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js index 524ee20183..671df14892 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js @@ -1,49 +1,9 @@ -import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { logError } from '@edx/frontend-platform/logging'; import isEmpty from 'lodash/isEmpty'; import { learnerCreditManagementQueryKeys } from '../constants'; import LmsApiService from '../../../../data/services/LmsApiService'; -import { fetchPaginatedData } from '../../../../data/services/apiServiceUtils'; - -/** - * Hook to get a list of groups associated with an enterprise customer. - * - * @param enterpriseId The enterprise customer UUID. - * @returns An object with list of groups associated with an enterprise customer and loading state - */ -export const useEnterpriseCustomerFlexGroup = (enterpriseId) => { - const [isLoading, setIsLoading] = useState(true); - const [enterpriseCustomerFlexGroup, setEnterpriseCustomerFlexGroup] = useState([]); - - useEffect(() => { - const getRemovedMembers = async () => { - try { - const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); - console.log(results) - const removedMembers = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType==='flex'); - console.log(removedMembers) - setEnterpriseCustomerFlexGroup(removedMembers); - } catch (e) { - logError(e); - } finally { - setIsLoading(false); - } - }; - - if (enterpriseId) { - getRemovedMembers(); - } - }, [enterpriseId, fetchPaginatedData]); - - return { - isEnterpriseCustomerFlexGroup: isLoading, - enterpriseCustomerFlexGroup, - }; -}; - /** * Retrieves a enterprise group by UUID from the API. From 38463578d20a07153e101144a75679cc166323a1 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Thu, 17 Oct 2024 19:24:44 +0000 Subject: [PATCH 3/6] feat: adds selection button to dropdown --- .../AssignmentModalContent.jsx | 143 ++++++++++++++++-- .../AssignmentModalFlexGroup.jsx | 95 ++++++------ .../AssignmentModalSummaryErrorState.jsx | 2 +- .../NewAssignmentModalButton.jsx | 20 ++- .../data/hooks/index.js | 2 +- ...lexGroup.js => useEnterpriseFlexGroups.js} | 20 ++- .../data/hooks/useGroupDropdownToggle.js | 91 +++++++++++ .../styles/index.scss | 9 ++ 8 files changed, 310 insertions(+), 72 deletions(-) rename src/components/learner-credit-management/data/hooks/{useEnterpriseFlexGroup.js => useEnterpriseFlexGroups.js} (51%) create mode 100644 src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index 174bbca740..b3b9c794d9 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -4,26 +4,26 @@ import React, { import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { - Card, Col, Container, Form, Row, Stack, MenuItem, Menu, SelectMenu, DropdownButton, Dropdown + Card, Col, Container, Form, Row, Stack, } from '@openedx/paragon'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import { connect } from 'react-redux'; import BaseCourseCard from '../cards/BaseCourseCard'; -import { formatPrice, useBudgetId, useSubsidyAccessPolicy, useEnterpriseFlexGroup } from '../data'; -// import { useEnterpriseCustomerFlexGroup } from '../data/hooks/useEnterpriseGroup'; +import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from '../data'; 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 { getGroupMemberEmails } from '../data/hooks/useEnterpriseFlexGroups'; +import useGroupDropdownToggle from '../data/hooks/useGroupDropdownToggle'; const AssignmentModalContent = ({ - enterpriseId, course, courseRun, onEmailAddressesChange, enterpriseFlexGroup, + enterpriseId, course, courseRun, onEmailAddressesChange, enterpriseFlexGroups, onGroupSelectionsChanged, }) => { - // const { data: enterpriseFlexGroup } = useEnterpriseFlexGroup(enterpriseId); - // console.log(enterpriseFlexGroup) const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); const spendAvailable = subsidyAccessPolicy.aggregates.spendAvailableUsd; @@ -32,6 +32,92 @@ const AssignmentModalContent = ({ const [assignmentAllocationMetadata, setAssignmentAllocationMetadata] = useState({}); const intl = useIntl(); const { contentPrice } = courseRun; + const [groupMemberEmails, setGroupMemberEmails] = useState([]); + const [checkedGroups, setCheckedGroups] = useState({}); + const [dropdownToggleLabel, setDropdownToggleLabel] = useState('Select group'); + const {handleCheckedGroupsChanged, handleGroupsChanged, handleSubmitGroup} = useGroupDropdownToggle({ + setCheckedGroups, setGroupMemberEmails, onGroupSelectionsChanged, checkedGroups, groupMemberEmails, + }) + + + // 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, + // // } + // // })); + // // const newEmails = []; + // // const updatedMembers = memberEmails.filter(member => !groupMemberEmails.includes(member)); + // // setGroupMemberEmails(prev => [...prev, ...memberEmails]) + // // } catch (err) { + // // logError(err); + // // } + // // } else if (!checked) { + // // setCheckedGroups((prev) => ({ + // // ...prev, + // // [id]: { + // // ...prev[id], + // // checked: false, + // // } + // // })); + // // let membersToRemove = checkedGroups[id].memberEmails; + // // console.log(membersToRemove) + // // const updatedMembers = groupMemberEmails.filter(member => !membersToRemove.includes(member)); + // // setGroupMemberEmails(updatedMembers); + // // } + // if (checked) { + // try { + // const memberEmails = await getGroupMemberEmails(id); + // setCheckedGroups((prev) => ({ + // ...prev, + // [id]: { + // checked, + // name: value, + // memberEmails, + // }, + // })); + // } catch (err) { + // logError(err); + // } + // } else if (!checked) { + // setCheckedGroups((prev) => ({ + // ...prev, + // [id]: { + // ...prev[id], + // checked: false, + // }, + // })); + // } + // }; + + // const handleGroupsChanged = useCallback(async (groups) => { + // if (Object.keys(groups).length === 0) { + // setGroupMemberEmails([]); + // onGroupSelectionsChanged([]); + // } + // }, [onGroupSelectionsChanged]); + + // 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); + // } + // }); + // } + // }); + // setGroupMemberEmails(memberEmails); + // }; + const handleEmailAddressInputChange = (e) => { const inputValue = e.target.value; setEmailAddressesInputValue(inputValue); @@ -55,10 +141,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('Select group'); + } + }, [checkedGroups, handleGroupsChanged]); + // Validate the learner emails from user input whenever it changes useEffect(() => { const allocationMetadata = isAssignEmailAddressesInputValueValid({ - learnerEmails, + learnerEmails: [...learnerEmails, ...groupMemberEmails], remainingBalance: spendAvailable, contentPrice, }); @@ -72,11 +170,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 ( @@ -102,8 +209,14 @@ const AssignmentModalContent = ({ description="Header for the section where we assign a course to learners" /> - {enterpriseFlexGroup.length > 0 && ( - + {enterpriseFlexGroups.length > 0 && ( + )}
@@ -217,6 +330,12 @@ 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, + })), }; const mapStateToProps = state => ({ diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx index 534a4cacfc..a1582816be 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx @@ -1,57 +1,44 @@ import React from 'react'; import { - Card, Col, Container, Form, Row, Stack, MenuItem, Menu, SelectMenu, DropdownButton, Dropdown + Form, MenuItem, Dropdown, Button, } from '@openedx/paragon'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; -const AssignmentModalFlexGroup = ({ enterpriseFlexGroup }) => { - const handleOnChange = () => { +const AssignmentModalFlexGroup = ({ + enterpriseFlexGroups, + onCheckedGroupsChanged, + checkedGroups, + onHandleSubmitGroup, + dropdownToggleLabel, +}) => { + const renderFlexGroupSelection = enterpriseFlexGroups.map(flexGroup => ( + + {flexGroup.name} ({flexGroup.acceptedMembersCount}) + + )); - }; - - const renderFlexGroupSelection = enterpriseFlexGroup.map(flexGroup => { - return ( - {console.log('hello')}}className="group-dropdown mt-2 mb-2 ml-3" as={Form.Checkbox} value={flexGroup.name}>{flexGroup.name} ({flexGroup.acceptedMembersCount}) - ) - }) - // return ( - // - // - - // console.log(e.target.value)} - // defaultValue={['green']} - // className="group-dropdown" - // > - // Groups - // - // {renderFlexGroupSelection} - // - // - // - // {/* - // - // */} - - // - // - // - // - // ) return ( - - + + Groups - - Select group + console.log('hello')} id="group-select-toggle" className="group-dropdown mt-2"> + {dropdownToggleLabel} - + {renderFlexGroupSelection} + {/*
*/} +
+ +
@@ -65,4 +52,20 @@ const AssignmentModalFlexGroup = ({ enterpriseFlexGroup }) => { ); }; -export default AssignmentModalFlexGroup; \ No newline at end of file +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, + })), +}; + +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 9e00a1dfbe..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, useEnterpriseFlexGroup, + useSubsidyAccessPolicy, useEnterpriseFlexGroups, } from '../data'; import AssignmentModalContent from './AssignmentModalContent'; import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals'; @@ -41,11 +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: enterpriseFlexGroup } = useEnterpriseFlexGroup(enterpriseId); + const { data: enterpriseFlexGroups } = useEnterpriseFlexGroups(enterpriseId); const { successfulAssignmentToast: { displayToastForAssignmentAllocation }, } = useContext(BudgetDetailPageContext); @@ -120,6 +121,14 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { setCanAllocateAssignments(canAllocate); }, []); + const handleGroupSelectionsChanged = useCallback(( + value, + { canAllocate = false } = {}, + ) => { + setGroupLearnerEmails(value); + setCanAllocateAssignments(canAllocate); + }, []); + const onSuccessEnterpriseTrackEvents = ({ totalLearnersAllocated, totalLearnersAlreadyAllocated, @@ -143,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, @@ -199,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, @@ -317,7 +326,8 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { course={course} courseRun={assignmentRun} onEmailAddressesChange={handleEmailAddressesChanged} - enterpriseFlexGroup={enterpriseFlexGroup} + enterpriseFlexGroups={enterpriseFlexGroups} + onGroupSelectionsChanged={handleGroupSelectionsChanged} /> { + 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 getEnterpriseFlexGroup = async ({ queryKey }) => { +export const getEnterpriseFlexGroups = async ({ queryKey }) => { const enterpriseId = queryKey[2]; - console.log(queryKey) const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); - const removedMembers = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType === 'flex'); - return removedMembers + const flexGroups = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType === 'flex'); + return flexGroups; }; -const useEnterpriseFlexGroup = (enterpriseId, { queryOptions } = {}) => useQuery({ +const useEnterpriseFlexGroups = (enterpriseId, { queryOptions } = {}) => useQuery({ queryKey: learnerCreditManagementQueryKeys.flexGroup(enterpriseId), - queryFn: getEnterpriseFlexGroup, + queryFn: getEnterpriseFlexGroups, ...queryOptions, }); -export default useEnterpriseFlexGroup; +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..a90c2ec5af --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import { getGroupMemberEmails } from "./useEnterpriseFlexGroups"; +import { logError } from '@edx/frontend-platform/logging'; + +const useGroupDropdownToggle = ({ setCheckedGroups, setGroupMemberEmails, onGroupSelectionsChanged, checkedGroups, groupMemberEmails }) => { + 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, + // } + // })); + // const newEmails = []; + // const updatedMembers = memberEmails.filter(member => !groupMemberEmails.includes(member)); + // setGroupMemberEmails(prev => [...prev, ...updatedMembers]) + // } catch (err) { + // logError(err); + // } + // } else if (!checked) { + // setCheckedGroups((prev) => ({ + // ...prev, + // [id]: { + // ...prev[id], + // checked: false, + // } + // })); + // let membersToRemove = checkedGroups[id].memberEmails; + // console.log(membersToRemove) + // const updatedMembers = groupMemberEmails.filter(member => !membersToRemove.includes(member)); + // setGroupMemberEmails(updatedMembers); + // } + if (checked) { + try { + const memberEmails = await getGroupMemberEmails(id); + setCheckedGroups((prev) => ({ + ...prev, + [id]: { + checked, + name: value, + memberEmails, + }, + })); + } catch (err) { + logError(err); + } + } else if (!checked) { + setCheckedGroups((prev) => ({ + ...prev, + [id]: { + ...prev[id], + checked: false, + }, + })); + } + }; + + const handleGroupsChanged = useCallback(async (groups) => { + if (Object.keys(groups).length === 0) { + setGroupMemberEmails([]); + onGroupSelectionsChanged([]); + } + }, [onGroupSelectionsChanged]); + + 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); + } + }); + } + }); + setGroupMemberEmails(memberEmails); + }; + + return { + handleCheckedGroupsChanged, + handleGroupsChanged, + handleSubmitGroup, + } +}; + +export default useGroupDropdownToggle; \ No newline at end of file diff --git a/src/components/learner-credit-management/styles/index.scss b/src/components/learner-credit-management/styles/index.scss index 68507c4746..7557f17b12 100644 --- a/src/components/learner-credit-management/styles/index.scss +++ b/src/components/learner-credit-management/styles/index.scss @@ -29,6 +29,15 @@ .pgn__menu-select-popup { width: inherit; justify-content: space-between; + }; + + .btn-primary { + color: black !important; + background-color: white !important; + }; + .btn-primary:hover { + color: black !important; + background-color: white !important; } } // Must be defined outside of `.learner-credit-management` to ensure the styles are applied to the contents of From a8d18e717c589192d162a695431f946622eb23f5 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Tue, 22 Oct 2024 23:53:31 +0000 Subject: [PATCH 4/6] feat: adds selection button to dropdown --- .../AssignmentModalContent.jsx | 101 +++-------------- .../AssignmentModalFlexGroup.jsx | 23 ++-- .../cards/tests/CourseCard.test.jsx | 73 ++++++++++++ .../data/hooks/useEnterpriseFlexGroups.js | 4 +- .../data/hooks/useGroupDropdownToggle.js | 106 ++++++++++++------ .../styles/index.scss | 9 -- 6 files changed, 173 insertions(+), 143 deletions(-) diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index b3b9c794d9..68d0650ed5 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -8,7 +8,6 @@ import { } from '@openedx/paragon'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { logError } from '@edx/frontend-platform/logging'; import { connect } from 'react-redux'; import BaseCourseCard from '../cards/BaseCourseCard'; @@ -18,7 +17,6 @@ import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isAssignEmailAddressesInput import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpCollapsibles'; import EVENT_NAMES from '../../../eventTracking'; import AssignmentModalFlexGroup from './AssignmentModalFlexGroup'; -import { getGroupMemberEmails } from '../data/hooks/useEnterpriseFlexGroups'; import useGroupDropdownToggle from '../data/hooks/useGroupDropdownToggle'; const AssignmentModalContent = ({ @@ -35,89 +33,19 @@ const AssignmentModalContent = ({ const [groupMemberEmails, setGroupMemberEmails] = useState([]); const [checkedGroups, setCheckedGroups] = useState({}); const [dropdownToggleLabel, setDropdownToggleLabel] = useState('Select group'); - const {handleCheckedGroupsChanged, handleGroupsChanged, handleSubmitGroup} = useGroupDropdownToggle({ - setCheckedGroups, setGroupMemberEmails, onGroupSelectionsChanged, checkedGroups, groupMemberEmails, - }) - - - // 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, - // // } - // // })); - // // const newEmails = []; - // // const updatedMembers = memberEmails.filter(member => !groupMemberEmails.includes(member)); - // // setGroupMemberEmails(prev => [...prev, ...memberEmails]) - // // } catch (err) { - // // logError(err); - // // } - // // } else if (!checked) { - // // setCheckedGroups((prev) => ({ - // // ...prev, - // // [id]: { - // // ...prev[id], - // // checked: false, - // // } - // // })); - // // let membersToRemove = checkedGroups[id].memberEmails; - // // console.log(membersToRemove) - // // const updatedMembers = groupMemberEmails.filter(member => !membersToRemove.includes(member)); - // // setGroupMemberEmails(updatedMembers); - // // } - // if (checked) { - // try { - // const memberEmails = await getGroupMemberEmails(id); - // setCheckedGroups((prev) => ({ - // ...prev, - // [id]: { - // checked, - // name: value, - // memberEmails, - // }, - // })); - // } catch (err) { - // logError(err); - // } - // } else if (!checked) { - // setCheckedGroups((prev) => ({ - // ...prev, - // [id]: { - // ...prev[id], - // checked: false, - // }, - // })); - // } - // }; - - // const handleGroupsChanged = useCallback(async (groups) => { - // if (Object.keys(groups).length === 0) { - // setGroupMemberEmails([]); - // onGroupSelectionsChanged([]); - // } - // }, [onGroupSelectionsChanged]); - - // 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); - // } - // }); - // } - // }); - // setGroupMemberEmails(memberEmails); - // }; - + const { + dropdownRef, + handleCheckedGroupsChanged, + handleGroupsChanged, + handleSubmitGroup, + } = useGroupDropdownToggle({ + checkedGroups, + dropdownToggleLabel, + onGroupSelectionsChanged, + setCheckedGroups, + setDropdownToggleLabel, + setGroupMemberEmails, + }); const handleEmailAddressInputChange = (e) => { const inputValue = e.target.value; setEmailAddressesInputValue(inputValue); @@ -212,10 +140,11 @@ const AssignmentModalContent = ({ {enterpriseFlexGroups.length > 0 && ( )} diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx index a1582816be..177102d0c1 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalFlexGroup.jsx @@ -11,14 +11,15 @@ const AssignmentModalFlexGroup = ({ checkedGroups, onHandleSubmitGroup, dropdownToggleLabel, + dropdownRef, }) => { const renderFlexGroupSelection = enterpriseFlexGroups.map(flexGroup => ( @@ -27,18 +28,15 @@ const AssignmentModalFlexGroup = ({ )); return ( - - + + Groups - console.log('hello')} id="group-select-toggle" className="group-dropdown mt-2"> + {dropdownToggleLabel} - + {renderFlexGroupSelection} - {/*
*/} -
- -
+
@@ -66,6 +64,11 @@ AssignmentModalFlexGroup.propTypes = { 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/cards/tests/CourseCard.test.jsx b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx index 3dc62b092c..e0734d7fd3 100644 --- a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx +++ b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx @@ -19,12 +19,14 @@ import { SHORT_MONTH_DATE_FORMAT, useBudgetId, useSubsidyAccessPolicy, + useEnterpriseFlexGroups, } from '../../data'; import { getButtonElement, queryClient } from '../../../test/testUtils'; import EnterpriseAccessApiService from '../../../../data/services/EnterpriseAccessApiService'; import { BudgetDetailPageContext } from '../../BudgetDetailPageWrapper'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../data'; +import { getGroupMemberEmails } from '../../data/hooks/useEnterpriseFlexGroups'; jest.mock('@edx/frontend-enterprise-utils', () => ({ ...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(); @@ -185,6 +189,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 +280,9 @@ describe('Course card works as expected', () => { isLoading: false, isLateRedemptionAllowed: false, }); + useEnterpriseFlexGroups.mockReturnValue({ + data: mockEnterpriseFlexGroup, + }); }); afterEach(() => { @@ -785,4 +810,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/hooks/useEnterpriseFlexGroups.js b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js index ba5a3df22d..8a1efc7f72 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js @@ -4,12 +4,12 @@ import { learnerCreditManagementQueryKeys } from '../constants'; import { fetchPaginatedData } from '../../../../data/services/apiServiceUtils'; import LmsApiService from '../../../../data/services/LmsApiService'; -export const getGroupMemberEmails = async (groupUUID)=> { +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. diff --git a/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js index a90c2ec5af..b8a1258cfc 100644 --- a/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js +++ b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js @@ -1,40 +1,17 @@ -import { useCallback } from "react"; -import { getGroupMemberEmails } from "./useEnterpriseFlexGroups"; +import { useCallback, useRef, useEffect } from 'react'; import { logError } from '@edx/frontend-platform/logging'; +import { getGroupMemberEmails } from './useEnterpriseFlexGroups'; -const useGroupDropdownToggle = ({ setCheckedGroups, setGroupMemberEmails, onGroupSelectionsChanged, checkedGroups, groupMemberEmails }) => { +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, - // } - // })); - // const newEmails = []; - // const updatedMembers = memberEmails.filter(member => !groupMemberEmails.includes(member)); - // setGroupMemberEmails(prev => [...prev, ...updatedMembers]) - // } catch (err) { - // logError(err); - // } - // } else if (!checked) { - // setCheckedGroups((prev) => ({ - // ...prev, - // [id]: { - // ...prev[id], - // checked: false, - // } - // })); - // let membersToRemove = checkedGroups[id].memberEmails; - // console.log(membersToRemove) - // const updatedMembers = groupMemberEmails.filter(member => !membersToRemove.includes(member)); - // setGroupMemberEmails(updatedMembers); - // } if (checked) { try { const memberEmails = await getGroupMemberEmails(id); @@ -44,6 +21,7 @@ const useGroupDropdownToggle = ({ setCheckedGroups, setGroupMemberEmails, onGrou checked, name: value, memberEmails, + isApplied: false, }, })); } catch (err) { @@ -55,17 +33,56 @@ const useGroupDropdownToggle = ({ setCheckedGroups, setGroupMemberEmails, onGrou [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('Select group'); + 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]); + }, [onGroupSelectionsChanged, setGroupMemberEmails]); const handleSubmitGroup = () => { const memberEmails = []; @@ -76,16 +93,33 @@ const useGroupDropdownToggle = ({ setCheckedGroups, setGroupMemberEmails, onGrou 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; \ No newline at end of file +export default useGroupDropdownToggle; diff --git a/src/components/learner-credit-management/styles/index.scss b/src/components/learner-credit-management/styles/index.scss index 7557f17b12..2693e5aae3 100644 --- a/src/components/learner-credit-management/styles/index.scss +++ b/src/components/learner-credit-management/styles/index.scss @@ -30,15 +30,6 @@ width: inherit; justify-content: space-between; }; - - .btn-primary { - color: black !important; - background-color: white !important; - }; - .btn-primary:hover { - color: black !important; - background-color: white !important; - } } // 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. From 5bdb116bfe419049a249fa4fa59817b590b10c72 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Wed, 23 Oct 2024 16:43:34 +0000 Subject: [PATCH 5/6] chore: adds code coverage --- .../tests/useEnterpriseFlexGroups.test.jsx | 103 ++++++++++++++++++ .../services/tests/apiServiceUtils.test.js | 85 +++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx create mode 100644 src/data/services/tests/apiServiceUtils.test.js 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..4f04773bb3 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx @@ -0,0 +1,103 @@ +/* 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'; + +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', () => { + const enterpriseGroupLearnerUrl = `${LmsApiService.enterpriseGroupUrl}${mockGroupUuid}/learners`; + + beforeEach(() => { + jest.clearAllMocks(); + fetchPaginatedData.mockReturnValue( + { + results: camelCaseObject([...mockLearners.results]), + response: camelCaseObject(mockLearners), + }, + ); + axiosMock.reset(); + }); + + it('returns the api call with a 200', async () => { + axiosMock.onGet(enterpriseGroupLearnerUrl).reply(200, mockLearners); + const { results } = await fetchPaginatedData(mockGroupUuid); + expect(results).toEqual(camelCaseObject(mockLearners.results)); + }); +}); 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, + }, + }, + }); + }); +}); From f1f38ee161fd352a9e0461e1ce014419f9aa0d58 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Tue, 29 Oct 2024 18:23:45 +0000 Subject: [PATCH 6/6] feat: add coverage and fixed syntax --- src/components/PeopleManagement/constants.js | 1 + .../AssignmentModalContent.jsx | 20 +++++++++++++++---- .../cards/tests/CourseCard.test.jsx | 3 +++ .../tests/useEnterpriseFlexGroups.test.jsx | 11 ++++------ .../data/hooks/useEnterpriseFlexGroups.js | 9 +++++---- .../data/hooks/useGroupDropdownToggle.js | 3 ++- 6 files changed, 31 insertions(+), 16 deletions(-) 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 68d0650ed5..8b04b3a947 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -18,10 +18,18 @@ import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpColl 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, enterpriseFlexGroups, onGroupSelectionsChanged, + 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; @@ -32,7 +40,7 @@ const AssignmentModalContent = ({ const { contentPrice } = courseRun; const [groupMemberEmails, setGroupMemberEmails] = useState([]); const [checkedGroups, setCheckedGroups] = useState({}); - const [dropdownToggleLabel, setDropdownToggleLabel] = useState('Select group'); + const [dropdownToggleLabel, setDropdownToggleLabel] = useState(GROUP_DROPDOWN_TEXT); const { dropdownRef, handleCheckedGroupsChanged, @@ -77,7 +85,7 @@ const AssignmentModalContent = ({ } else if (selectedGroups.length > 1) { setDropdownToggleLabel(`${selectedGroups.length} groups selected`); } else { - setDropdownToggleLabel('Select group'); + setDropdownToggleLabel(GROUP_DROPDOWN_TEXT); } }, [checkedGroups, handleGroupsChanged]); @@ -137,7 +145,7 @@ const AssignmentModalContent = ({ description="Header for the section where we assign a course to learners" /> - {enterpriseFlexGroups.length > 0 && ( + {shouldShowGroupsDropdown && ( ({ enterpriseId: state.portalConfiguration.enterpriseId, + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, }); export default connect(mapStateToProps)(AssignmentModalContent); diff --git a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx index e0734d7fd3..d2fb046dc7 100644 --- a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx +++ b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx @@ -172,6 +172,9 @@ const initialStoreState = { portalConfiguration: { enterpriseId: enterpriseUUID, enterpriseSlug, + enterpriseFeatures: { + enterpriseGroupsV2: true, + }, }, }; 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 index 4f04773bb3..a8e279f9fc 100644 --- a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseFlexGroups.test.jsx @@ -5,6 +5,7 @@ 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'); @@ -82,8 +83,6 @@ describe('useEnterpriseFlexGroups', () => { }); describe('getGroupMemberEmails', () => { - const enterpriseGroupLearnerUrl = `${LmsApiService.enterpriseGroupUrl}${mockGroupUuid}/learners`; - beforeEach(() => { jest.clearAllMocks(); fetchPaginatedData.mockReturnValue( @@ -92,12 +91,10 @@ describe('getGroupMemberEmails', () => { response: camelCaseObject(mockLearners), }, ); - axiosMock.reset(); }); - it('returns the api call with a 200', async () => { - axiosMock.onGet(enterpriseGroupLearnerUrl).reply(200, mockLearners); - const { results } = await fetchPaginatedData(mockGroupUuid); - expect(results).toEqual(camelCaseObject(mockLearners.results)); + 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 index 8a1efc7f72..d7eceb7ae4 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseFlexGroups.js @@ -3,6 +3,7 @@ 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`; @@ -17,16 +18,16 @@ export const getGroupMemberEmails = async (groupUUID) => { * @param enterpriseId The enterprise customer UUID. * @returns A list of flex groups associated with an enterprise customer. */ -export const getEnterpriseFlexGroups = async ({ queryKey }) => { - const enterpriseId = queryKey[2]; +export const getEnterpriseFlexGroups = async ({ enterpriseId }) => { const { results } = await fetchPaginatedData(LmsApiService.enterpriseGroupListUrl); - const flexGroups = results.filter(result => result.enterpriseCustomer === enterpriseId && result.groupType === 'flex'); + 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, + queryFn: () => getEnterpriseFlexGroups({ enterpriseId }), ...queryOptions, }); diff --git a/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js index b8a1258cfc..d4fbd4ae76 100644 --- a/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js +++ b/src/components/learner-credit-management/data/hooks/useGroupDropdownToggle.js @@ -1,6 +1,7 @@ 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, @@ -44,7 +45,7 @@ const useGroupDropdownToggle = ({ // Handles user clicking outside of the dropdown menu. function handleClickOutside(event) { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { - setDropdownToggleLabel('Select group'); + 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.