diff --git a/src/components/EnterpriseApp/EnterpriseAppContent.jsx b/src/components/EnterpriseApp/EnterpriseAppContent.jsx index 8e57dd83dd..8c9fd069c4 100644 --- a/src/components/EnterpriseApp/EnterpriseAppContent.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppContent.jsx @@ -13,6 +13,7 @@ const EnterpriseAppContent = ({ enableReportingPage, enableSubscriptionManagementPage, enableAnalyticsPage, + enterpriseGroupsV2, }) => { const { FEATURE_CONTENT_HIGHLIGHTS } = getConfig(); const enterpriseAppContext = useContext(EnterpriseAppContext); @@ -32,6 +33,7 @@ const EnterpriseAppContent = ({ enableSubscriptionManagementPage={enableSubscriptionManagementPage} enableAnalyticsPage={enableAnalyticsPage} enableContentHighlightsPage={isContentHighlightsEnabled} + enterpriseGroupsV2={enterpriseGroupsV2} /> ); }; @@ -44,6 +46,7 @@ EnterpriseAppContent.propTypes = { enableReportingPage: PropTypes.bool.isRequired, enableSubscriptionManagementPage: PropTypes.bool.isRequired, enableAnalyticsPage: PropTypes.bool.isRequired, + enterpriseGroupsV2: PropTypes.bool.isRequired, }; export default EnterpriseAppContent; diff --git a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx index 12d466c89d..de31ee76f9 100644 --- a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx @@ -17,6 +17,7 @@ import BulkEnrollmentResultsDownloadPage from '../BulkEnrollmentResultsDownloadP import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import ContentHighlights from '../ContentHighlights'; import LearnerCreditManagementRoutes from '../learner-credit-management'; +import PeopleManagementPage from '../PeopleManagement'; const EnterpriseAppRoutes = ({ email, @@ -27,10 +28,11 @@ const EnterpriseAppRoutes = ({ enableSubscriptionManagementPage, enableAnalyticsPage, enableContentHighlightsPage, + enterpriseGroupsV2, }) => { const { canManageLearnerCredit } = useContext(EnterpriseSubsidiesContext); const { enterpriseAppPage } = useParams(); - + console.log(enterpriseGroupsV2) return ( {enterpriseAppPage === ROUTE_NAMES.learners && ( @@ -115,6 +117,13 @@ const EnterpriseAppRoutes = ({ /> )} + {enterpriseGroupsV2 && enterpriseAppPage === ROUTE_NAMES.peopleManagement && ( + } + /> + )} + {enableContentHighlightsPage && enterpriseAppPage === ROUTE_NAMES.contentHighlights && ( @@ -195,6 +196,7 @@ EnterpriseApp.propTypes = { enterpriseName: PropTypes.string, enterpriseFeatures: PropTypes.shape({ topDownAssignmentRealTimeLcm: PropTypes.bool, + enterpriseGroupsV2: PropTypes.bool, }), enterpriseBranding: PropTypes.shape({ primary_color: PropTypes.string, @@ -212,6 +214,7 @@ EnterpriseApp.propTypes = { enableAnalyticsScreen: PropTypes.bool, enableReportingConfigurationsScreen: PropTypes.bool, enablePortalLearnerCreditManagementScreen: PropTypes.bool, + enterpriseGroupsV2: PropTypes.bool, error: PropTypes.instanceOf(Error), loading: PropTypes.bool, }; diff --git a/src/components/PeopleManagement/CreateGroupModal.jsx b/src/components/PeopleManagement/CreateGroupModal.jsx new file mode 100644 index 0000000000..54a180c6f4 --- /dev/null +++ b/src/components/PeopleManagement/CreateGroupModal.jsx @@ -0,0 +1,111 @@ +import React, { + useCallback, useEffect, useMemo, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import debounce from 'lodash.debounce'; +import { + Col, Container, Form, Row, +} from '@openedx/paragon'; + +import InviteModalSummary from '../learner-credit-management/invite-modal/InviteModalSummary'; +import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, INPUT_TYPE, isInviteEmailAddressesInputValueValid } from '../learner-credit-management/cards/data'; +import FileUpload from '../learner-credit-management/invite-modal/FileUpload'; +import { MAX_LENGTH_GROUP_NAME } from './constants'; + +const CreateGroupModal = ({ onEmailAddressesChange }) => { + const [learnerEmails, setLearnerEmails] = useState([]); + const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); + const [memberInviteMetadata, setMemberInviteMetadata] = useState({ + isValidInput: null, + lowerCasedEmails: [], + duplicateEmails: null, + }); + const [groupNameLength, setGroupNameLength] = useState(0); + const [groupName, setGroupName] = useState(''); + + + const handleGroupNameChange = (e) => { + const { value } = e.target; + if (value.length > MAX_LENGTH_GROUP_NAME) { + return; + } + setGroupNameLength(value.length); + setGroupName(value); + }; + + const handleEmailAddressesChanged = useCallback((value) => { + if (!value) { + setLearnerEmails([]); + onEmailAddressesChange([]); + return; + } + const emails = value.split('\n').map((email) => email.trim()).filter((email) => email.length > 0); + console.log(emails) + setLearnerEmails(emails); + }, [onEmailAddressesChange]); + + const debouncedHandleEmailAddressesChanged = useMemo( + () => debounce(handleEmailAddressesChanged, EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY), + [handleEmailAddressesChanged], + ); + + useEffect(() => { + debouncedHandleEmailAddressesChanged(emailAddressesInputValue); + }, [emailAddressesInputValue, debouncedHandleEmailAddressesChanged]); + + useEffect(() => { + const inviteMetadata = isInviteEmailAddressesInputValueValid({ + learnerEmails, + }); + setMemberInviteMetadata(inviteMetadata); + if (inviteMetadata.canInvite) { + onEmailAddressesChange(learnerEmails, { canInvite: true }); + } else { + onEmailAddressesChange([]); + } + console.log(inviteMetadata) + }, [onEmailAddressesChange, learnerEmails]); + + return ( + +

Create a custom group of members

+ + +

Name your group

+ + + {groupNameLength} / {MAX_LENGTH_GROUP_NAME} + + + +
+ + +

Select group members

+

Upload a CSV or select members from the table below.

+ + + +

Details

+ +
+ +
+
+ ); +}; + +CreateGroupModal.propTypes = { + onEmailAddressesChange: PropTypes.func.isRequired, +}; + +export default CreateGroupModal; diff --git a/src/components/PeopleManagement/CreateGroupModalWrapper.jsx b/src/components/PeopleManagement/CreateGroupModalWrapper.jsx new file mode 100644 index 0000000000..ed4eef49ba --- /dev/null +++ b/src/components/PeopleManagement/CreateGroupModalWrapper.jsx @@ -0,0 +1,195 @@ +import React, { + useContext, useCallback, useEffect, useMemo, useState, +} from 'react'; +import debounce from 'lodash.debounce'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, useToggle, FullscreenModal, StatefulButton, Col, Container, Form, Row, +} from '@openedx/paragon'; +import LmsApiService from '../../data/services/LmsApiService'; +import CreateGroupModal from './CreateGroupModal'; + +import InviteModalSummary from '../learner-credit-management/invite-modal/InviteModalSummary'; +import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, INPUT_TYPE, isInviteEmailAddressesInputValueValid } from '../learner-credit-management/cards/data'; +import FileUpload from '../learner-credit-management/invite-modal/FileUpload'; +import { MAX_LENGTH_GROUP_NAME } from './constants'; + +const CreateGroupModalWrapper = ({ isModalOpen, openModal, closeModal, enterpriseUUID }) => { + const intl = useIntl(); + const [canInviteMembers, setCanInviteMembers] = useState(false); + const [isSystemErrorModalOpen, openSystemErrorModal, closeSystemErrorModal] = useToggle(false); + const [createButtonState, setCreateButtonState] = useState('default'); + + const [learnerEmails, setLearnerEmails] = useState([]); + const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); + const [memberInviteMetadata, setMemberInviteMetadata] = useState({ + // isValidInput: null, + // lowerCasedEmails: [], + // duplicateEmails: null, + }); + const [groupNameLength, setGroupNameLength] = useState(0); + const [groupName, setGroupName] = useState(''); + + const handleCreateGroup = async () => { + setCreateButtonState('pending'); + const options = { + enterpriseCustomer: enterpriseUUID, + budgetType: 'flex', + name: '' + }; + + try { + if (true > 0) { + const groupUuid = ''; + const response = await LmsApiService.createEnterpriseGroup(groupUuid, options); + console.log(response) + const totalLearnersInvited = ''; + setCreateButtonState('complete'); + handleCloseInviteModal(); + } else { + setCreateButtonState('error'); + openSystemErrorModal(); + } + } catch (err) { + setCreateButtonState('error'); + openSystemErrorModal(); + } + }; + + const onEmailAddressesChange = useCallback(( + value, + { canInvite = false } = {}, + ) => { + setLearnerEmails(value); + setCanInviteMembers(canInvite); + }, []); + + + const handleGroupNameChange = (e) => { + const { value } = e.target; + if (value.length > MAX_LENGTH_GROUP_NAME) { + return; + } + setGroupNameLength(value.length); + setGroupName(value); + }; + + const handleEmailAddressesChanged = useCallback((value) => { + if (!value) { + setLearnerEmails([]); + onEmailAddressesChange([]); + return; + } + const emails = value.split('\n').map((email) => email.trim()).filter((email) => email.length > 0); + console.log(emails) + setLearnerEmails(emails); + }, [onEmailAddressesChange]); + + const debouncedHandleEmailAddressesChanged = useMemo( + () => debounce(handleEmailAddressesChanged, EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY), + [handleEmailAddressesChanged], + ); + + useEffect(() => { + debouncedHandleEmailAddressesChanged(emailAddressesInputValue); + }, [emailAddressesInputValue, debouncedHandleEmailAddressesChanged]); + + // useEffect(() => { + // const inviteMetadata = isInviteEmailAddressesInputValueValid({ + // learnerEmails, + // }); + // setMemberInviteMetadata(inviteMetadata); + // if (inviteMetadata.canInvite) { + // onEmailAddressesChange(learnerEmails, { canInvite: true }); + // } else { + // onEmailAddressesChange([]); + // } + // console.log(inviteMetadata) + // }, [onEmailAddressesChange, learnerEmails]); + + + return ( + + + + + + )} + > + {/* */} + +

Create a custom group of members

+ + +

Name your group

+ + + {groupNameLength} / {MAX_LENGTH_GROUP_NAME} + + + +
+ + +

Select group members

+

Upload a CSV or select members from the table below.

+ + + +

Details

+ +
+ +
+
+
+ ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, +}); + +CreateGroupModalWrapper.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + openModal: PropTypes.func.isRequired, + closeModal: PropTypes.func.isRequired, +}; + + +export default connect(mapStateToProps)(CreateGroupModalWrapper); diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js new file mode 100644 index 0000000000..fb370408c2 --- /dev/null +++ b/src/components/PeopleManagement/constants.js @@ -0,0 +1 @@ +export const MAX_LENGTH_GROUP_NAME = 60 \ No newline at end of file diff --git a/src/components/PeopleManagement/images/ZeroStateImage.svg b/src/components/PeopleManagement/images/ZeroStateImage.svg new file mode 100644 index 0000000000..312c38bfa2 --- /dev/null +++ b/src/components/PeopleManagement/images/ZeroStateImage.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx new file mode 100644 index 0000000000..65b03fb08d --- /dev/null +++ b/src/components/PeopleManagement/index.jsx @@ -0,0 +1,116 @@ +import React, { useContext } from 'react'; +import { Helmet } from 'react-helmet'; +import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, Card, Icon, IconButtonWithTooltip, useToggle +} from '@openedx/paragon'; +import { Add, InfoOutline } from '@openedx/paragon/icons'; + +import cardImage from './images/ZeroStateImage.svg'; +import Hero from '../Hero'; +import { SUBSIDY_TYPES } from '../../data/constants/subsidyTypes'; +import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; +import CreateGroupModalWrapper from './CreateGroupModalWrapper'; + +const PeopleManagementPage = () => { + const intl = useIntl(); + const PAGE_TITLE = intl.formatMessage({ + id: 'admin.portal.people.management.page', + defaultMessage: 'People Management', + description: 'Title for the people management page.', + }); + + const { + enterpriseSubsidyTypes, + } = useContext(EnterpriseSubsidiesContext); + + const hasLearnerCredit = enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.budget); + const hasOtherSubsidyTypes = enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.license) + || enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.coupon); + + const [isModalOpen, openModal, closeModal] = useToggle(false); + + const tooltipContent = ( + + ); + + return ( + <> + + + {hasLearnerCredit && ( +
+ + + +

+ +

+ {hasLearnerCredit && hasOtherSubsidyTypes && ( + { }} + variant="primary" + className="ml-1" + /> + )} +
+ +
+ + + +
+ + + +

+ +

+

+ +

+
+
+
+ )} + + ); +}; + +export default PeopleManagementPage; diff --git a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx new file mode 100644 index 0000000000..f8a5d33a8b --- /dev/null +++ b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx @@ -0,0 +1,54 @@ +import { + render, screen, +} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { EnterpriseSubsidiesContext } from '../../EnterpriseSubsidiesContext'; +import PeopleManagementPage from '..'; + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseSlug = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStoreState = { + portalConfiguration: { + enterpriseId: enterpriseUUID, + enterpriseSlug, + enterpriseGroupsV2: true, + }, +}; + +const defaultEnterpriseSubsidiesContextValue = { + enterpriseSubsidyTypes: ['budget', 'license'], + isLoading: false, +}; + +const PeopleManagementPageWrapper = ({ + initialState = initialStoreState, + enterpriseSubsidiesContextValue = defaultEnterpriseSubsidiesContextValue, +}) => { + const store = getMockStore(initialState); + return ( + + + + + + + + ); +}; + +describe('', () => { + it('renders the PeopleManagementPage zero state', () => { + render(); + expect(document.querySelector('h3').textContent).toEqual( + 'Your Learner Credit groups', + ); + expect(screen.getByText('You don\'t have any groups yet.')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index cb6b46c4b1..110f1ffb34 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -6,7 +6,8 @@ import classNames from 'classnames'; import { Icon } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - BookOpen, CreditCard, Description, InsertChartOutlined, MoneyOutline, Settings, Support, Tag, TrendingUp, + BookOpen, CreditCard, Description, InsertChartOutlined, MoneyOutline, + Person, Settings, Support, Tag, TrendingUp, } from '@openedx/paragon/icons'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform/config'; @@ -35,6 +36,7 @@ const Sidebar = ({ onWidthChange, isMobile, enterpriseGroupsV1, + enterpriseGroupsV2, onMount, }) => { const sidebarRef = useRef(); @@ -149,6 +151,12 @@ const Sidebar = ({ icon: , hidden: !canManageLearnerCredit, }, + { + title: 'People Management', + to: `${baseUrl}/admin/${ROUTE_NAMES.peopleManagement}`, + icon: , + hidden: !enterpriseGroupsV2, + }, { title: intl.formatMessage({ id: 'sidebar.menu.item.highlights.title', @@ -258,6 +266,7 @@ Sidebar.propTypes = { onMount: PropTypes.func.isRequired, isMobile: PropTypes.bool, enterpriseGroupsV1: PropTypes.bool, + enterpriseGroupsV2: PropTypes.bool, }; export default Sidebar; diff --git a/src/components/learner-credit-management/invite-modal/FileUpload.jsx b/src/components/learner-credit-management/invite-modal/FileUpload.jsx index 46b81b49bc..dcdfebe2fc 100644 --- a/src/components/learner-credit-management/invite-modal/FileUpload.jsx +++ b/src/components/learner-credit-management/invite-modal/FileUpload.jsx @@ -8,7 +8,7 @@ import { InsertDriveFile } from '@openedx/paragon/icons'; import { formatBytes } from '../../MultipleFileInputField/utils'; import InviteModalInputFeedback from './InviteModalInputFeedback'; -const FileUpload = ({ memberInviteMetadata, setEmailAddressesInputValue }) => { +const FileUpload = ({ memberInviteMetadata, setEmailAddressesInputValue, isGroupsInvite }) => { const [uploadedFile, setUploadedFile] = useState(undefined); const UploadedFile = ( <> @@ -33,6 +33,7 @@ const FileUpload = ({ memberInviteMetadata, setEmailAddressesInputValue }) => { return ( { multipleDragged: 'Cannot upload more than one file.', }} /> - + ); }; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx index 4817e9b1f5..c07ffd8a9c 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx @@ -11,6 +11,7 @@ import InviteModalSummaryDuplicate from './InviteModalSummaryDuplicate'; const InviteModalSummary = ({ memberInviteMetadata, + isGroupsInvite, }) => { const { isValidInput, @@ -48,7 +49,7 @@ const InviteModalSummary = ({ if (isEmpty(cardSections)) { cardSections = cardSections.concat( - renderCard(), + renderCard(), ); } diff --git a/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx b/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx index f5af5723ac..6195062543 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx @@ -1,10 +1,20 @@ import React from 'react'; -const InviteModalSummaryEmptyState = () => ( - <> -
You haven't entered any members yet.
- Add member emails to get started. - -); +const InviteModalSummaryEmptyState = ({ isGroupsInvite }) => { + if (isGroupsInvite) { + return ( + <> +
You haven't uploaded any members yet.
+ Upload a CSV file or select members to get started. + + ) + } + return ( + <> +
You haven't entered any members yet.
+ Add member emails to get started. + + ) +}; export default InviteModalSummaryEmptyState; diff --git a/src/containers/EnterpriseApp/index.jsx b/src/containers/EnterpriseApp/index.jsx index cc5d3ffed6..afc4ff9ceb 100644 --- a/src/containers/EnterpriseApp/index.jsx +++ b/src/containers/EnterpriseApp/index.jsx @@ -19,6 +19,7 @@ const mapStateToProps = (state) => { enableLmsConfigurationsScreen: state.portalConfiguration.enableLmsConfigurationsScreen, enableReportingConfigurationsScreen: state.portalConfiguration.enableReportingConfigScreen, enablePortalLearnerCreditManagementScreen: state.portalConfiguration.enablePortalLearnerCreditManagementScreen, + enterpriseGroupsV2: state.portalConfiguration.enterpriseGroupsV2, enterpriseId: state.portalConfiguration.enterpriseId, enterpriseName: state.portalConfiguration.enterpriseName, enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, diff --git a/src/containers/Sidebar/index.jsx b/src/containers/Sidebar/index.jsx index c0cfa096f6..ff023c010e 100644 --- a/src/containers/Sidebar/index.jsx +++ b/src/containers/Sidebar/index.jsx @@ -18,6 +18,7 @@ const mapStateToProps = state => ({ enableLmsConfigurationsScreen: state.portalConfiguration.enableLmsConfigurationsScreen, enableAnalyticsScreen: state.portalConfiguration.enableAnalyticsScreen, enterpriseGroupsV1: state.portalConfiguration.enterpriseFeatures?.enterpriseGroupsV1, + enterpriseGroupsV2: state.portalConfiguration.enterpriseFeatures?.enterpriseGroupsV2, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index c0bd742b04..6833892d91 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -45,6 +45,16 @@ class LmsApiService { static enterpriseGroupListUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise_group/`; + static createEnterpriseGroup(options) { + const postParams = { + budget_type: options.budgetType, + name: options.name, + enterprise_customer: options.enterpriseCustomer, + }; + const createEnterpriseGroupUrl = `${LmsApiService.enterpriseGroupUrl}`; + return LmsApiService.apiClient().post(createEnterpriseGroupUrl, postParams); + } + static fetchEnterpriseSsoOrchestrationRecord(configurationUuid) { const enterpriseSsoOrchestrationFetchUrl = `${LmsApiService.enterpriseSsoOrchestrationUrl}${configurationUuid}`; return LmsApiService.apiClient().get(enterpriseSsoOrchestrationFetchUrl);