From b82c0f20f714648e3493216976634d37f54b9d11 Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Wed, 16 Oct 2024 13:48:27 +0000 Subject: [PATCH] feat: adding flex group card grid --- .../PeopleManagement/GroupCardGrid.jsx | 59 ++++++++++++++ .../PeopleManagement/GroupDetailCard.jsx | 34 ++++++++ src/components/PeopleManagement/ZeroState.jsx | 54 +++++++++++++ .../PeopleManagement/_PeopleManagement.scss | 13 ++++ src/components/PeopleManagement/index.jsx | 68 +++++++--------- .../tests/PeopleManagementPage.test.jsx | 77 ++++++++++++++++++- .../data/hooks/index.js | 1 + .../data/hooks/useAllEnterpriseGroups.js | 30 ++++++++ .../data/hooks/useEnterpriseGroupUuid.js | 2 +- src/data/services/LmsApiService.js | 5 ++ src/index.scss | 1 + 11 files changed, 302 insertions(+), 42 deletions(-) create mode 100644 src/components/PeopleManagement/GroupCardGrid.jsx create mode 100644 src/components/PeopleManagement/GroupDetailCard.jsx create mode 100644 src/components/PeopleManagement/ZeroState.jsx create mode 100644 src/components/PeopleManagement/_PeopleManagement.scss create mode 100644 src/components/learner-credit-management/data/hooks/useAllEnterpriseGroups.js diff --git a/src/components/PeopleManagement/GroupCardGrid.jsx b/src/components/PeopleManagement/GroupCardGrid.jsx new file mode 100644 index 0000000000..fd65adf21a --- /dev/null +++ b/src/components/PeopleManagement/GroupCardGrid.jsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { CardGrid, Collapsible } from '@openedx/paragon'; + +import GroupDetailCard from './GroupDetailCard'; + +const GroupCardGrid = ({ groups }) => { + const [previewGroups, setPreviewGroups] = useState(); + const [overflowGroups, setOverflowGroups] = useState(); + useEffect(() => { + if (groups.length > 3) { + setPreviewGroups(groups.slice(0, 3)); + setOverflowGroups(groups.slice(3)); + } else { + setPreviewGroups(groups); + } + }, [groups]); + return ( + <> + + {previewGroups?.map((group) => ( + + ))} + + {overflowGroups && ( + + + {overflowGroups.map((group) => ( + + ))} + + + )} + + ); +}; + +GroupCardGrid.propTypes = { + groups: PropTypes.shape.isRequired, +}; + +export default GroupCardGrid; diff --git a/src/components/PeopleManagement/GroupDetailCard.jsx b/src/components/PeopleManagement/GroupDetailCard.jsx new file mode 100644 index 0000000000..df8eb802a9 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailCard.jsx @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import { useParams } from 'react-router'; +import { Card, Hyperlink } from '@openedx/paragon'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; + +const GroupDetailCard = ({ group }) => { + const { enterpriseSlug } = useParams(); + return ( + + + + {group.acceptedMembersCount} members + + + + View group + + + + ); +}; + +GroupDetailCard.propTypes = { + group: PropTypes.shape({ + acceptedMembersCount: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + uuid: PropTypes.string.isRequired, + }).isRequired, +}; + +export default GroupDetailCard; diff --git a/src/components/PeopleManagement/ZeroState.jsx b/src/components/PeopleManagement/ZeroState.jsx new file mode 100644 index 0000000000..88973473cb --- /dev/null +++ b/src/components/PeopleManagement/ZeroState.jsx @@ -0,0 +1,54 @@ +import React, { useContext } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card } from '@openedx/paragon'; + +import cardImage from './images/ZeroStateImage.svg'; +import { SUBSIDY_TYPES } from '../../data/constants/subsidyTypes'; +import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; + +const ZeroState = () => { + const { enterpriseSubsidyTypes } = useContext(EnterpriseSubsidiesContext); + + const hasLearnerCredit = enterpriseSubsidyTypes.includes( + SUBSIDY_TYPES.budget, + ); + const hasOtherSubsidyTypes = enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.license) + || enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.coupon); + + return ( + + + +

+ +

+

+ {hasLearnerCredit && ( + + )} + {!hasLearnerCredit && hasOtherSubsidyTypes && ( + + )} +

+
+
+ ); +}; + +export default ZeroState; diff --git a/src/components/PeopleManagement/_PeopleManagement.scss b/src/components/PeopleManagement/_PeopleManagement.scss new file mode 100644 index 0000000000..f5a8caa1a0 --- /dev/null +++ b/src/components/PeopleManagement/_PeopleManagement.scss @@ -0,0 +1,13 @@ +.group-detail-card { + background-color: $light-200; + .card-button { + justify-content: flex-start !important; + } + .pgn__card-section { + padding: .25rem 1.25rem 1.25rem 1.25rem; + } +} + +.collapsible-basic .collapsible-trigger { + justify-content: right; +} \ No newline at end of file diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index baaa087604..27e8c88589 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -1,18 +1,20 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { - ActionRow, Button, Card, useToggle, -} from '@openedx/paragon'; +import { ActionRow, Button, useToggle } from '@openedx/paragon'; import { Add } 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 CreateGroupModal from './CreateGroupModal'; +import { useAllEnterpriseGroups } from '../learner-credit-management/data'; +import ZeroState from './ZeroState'; +import GroupCardGrid from './GroupCardGrid'; -const PeopleManagementPage = () => { +const PeopleManagementPage = ({ enterpriseId }) => { const intl = useIntl(); const PAGE_TITLE = intl.formatMessage({ id: 'admin.portal.people.management.page', @@ -21,6 +23,7 @@ const PeopleManagementPage = () => { }); const { enterpriseSubsidyTypes } = useContext(EnterpriseSubsidiesContext); + const { data } = useAllEnterpriseGroups(enterpriseId); const hasLearnerCredit = enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.budget); const hasOtherSubsidyTypes = enterpriseSubsidyTypes.includes(SUBSIDY_TYPES.license) @@ -28,6 +31,13 @@ const PeopleManagementPage = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isModalOpen, openModal, closeModal] = useToggle(false); + const [groups, setGroups] = useState(); + + useEffect(() => { + if (data !== undefined) { + setGroups(data.results); + } + }, [data]); return ( <> @@ -41,7 +51,7 @@ const PeopleManagementPage = () => { @@ -70,41 +80,19 @@ const PeopleManagementPage = () => { - - - -

- -

-

- {hasLearnerCredit && ( - - )} - {!hasLearnerCredit && hasOtherSubsidyTypes && ( - - )} -

-
-
+ {groups && groups.length > 0 ? ( + ) : } ); }; -export default PeopleManagementPage; +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +PeopleManagementPage.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +export default connect(mapStateToProps)(PeopleManagementPage); diff --git a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx index 0da02bee92..03f15c5f2a 100644 --- a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx +++ b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx @@ -1,10 +1,13 @@ -import { render, screen } from '@testing-library/react'; +import { + render, screen, waitFor, +} 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 { useAllEnterpriseGroups } from '../../learner-credit-management/data'; import { EnterpriseSubsidiesContext } from '../../EnterpriseSubsidiesContext'; import PeopleManagementPage from '..'; @@ -30,6 +33,39 @@ const subsEnterpriseSubsidiesContextValue = { isLoading: false, }; +jest.mock('../../learner-credit-management/data', () => ({ + ...jest.requireActual('../../learner-credit-management/data'), + useAllEnterpriseGroups: jest.fn(), +})); + +const mockGroupsResponse = [{ + enterpriseCustomer: enterpriseUUID, + name: 'only cool people', + uuid: '12345', + acceptedMembersCount: 4, + groupType: 'flex', + created: '2024-10-31T03:33:33.292361Z', +}]; + +const mockMultipleGroupsResponse = [ + { + name: 'captain crunch', + acceptedMembersCount: 4, + }, + { + name: 'cinnamon toast crunch', + acceptedMembersCount: 5, + }, + { + name: 'cocoa puffs', + acceptedMembersCount: 10, + }, + { + name: 'fruity pebbles', + acceptedMembersCount: 5, + }, +]; + const PeopleManagementPageWrapper = ({ initialState = initialStoreState, enterpriseSubsidiesContextValue = defaultEnterpriseSubsidiesContextValue, @@ -48,6 +84,7 @@ const PeopleManagementPageWrapper = ({ describe('', () => { it('renders the PeopleManagementPage zero state', () => { + useAllEnterpriseGroups.mockReturnValue({ data: { results: {} } }); render(); expect(document.querySelector('h3').textContent).toEqual("Your organization's groups"); expect(screen.getByText("You don't have any groups yet.")).toBeInTheDocument(); @@ -56,6 +93,7 @@ describe('', () => { )).toBeInTheDocument(); }); it('renders the PeopleManagementPage zero state without LC', () => { + useAllEnterpriseGroups.mockReturnValue({ data: { results: [] } }); const store = getMockStore(initialStoreState); render( @@ -72,4 +110,41 @@ describe('', () => { expect(screen.getByText("You don't have any groups yet.")).toBeInTheDocument(); expect(screen.getByText("Once a group is created, you can track members' progress.")).toBeInTheDocument(); }); + it('renders the PeopleManagementPage group card grid', () => { + useAllEnterpriseGroups.mockReturnValue({ data: { results: mockGroupsResponse } }); + const store = getMockStore(initialStoreState); + render( + + + + + + + , + ); + expect(screen.getByText('only cool people')).toBeInTheDocument(); + expect(screen.getByText('4 members')).toBeInTheDocument(); + }); + it('renders the PeopleManagementPage group card grid with collapsible', async () => { + useAllEnterpriseGroups.mockReturnValue({ data: { results: mockMultipleGroupsResponse } }); + const store = getMockStore(initialStoreState); + render( + + + + + + + , + ); + expect(screen.getByText('captain crunch')).toBeInTheDocument(); + expect(screen.getByText('Show all 4 groups')).toBeInTheDocument(); + expect(screen.queryByText('fruity pebbles')).not.toBeInTheDocument(); + + const collapsible = screen.getByText('Show all 4 groups'); + collapsible.click(); + await waitFor(() => { + expect(screen.getByText('fruity pebbles')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index b954369c84..b9922cf563 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -18,5 +18,6 @@ export { default as useEnterpriseGroupMembersTableData } from './useEnterpriseGr export { default as useEnterpriseCustomer } from './useEnterpriseCustomer'; export { default as useEnterpriseGroup } from './useEnterpriseGroup'; export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; +export { default as useAllEnterpriseGroups } from './useAllEnterpriseGroups'; export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; diff --git a/src/components/learner-credit-management/data/hooks/useAllEnterpriseGroups.js b/src/components/learner-credit-management/data/hooks/useAllEnterpriseGroups.js new file mode 100644 index 0000000000..9c154532c3 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useAllEnterpriseGroups.js @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +import { learnerCreditManagementQueryKeys } from '../constants'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +/** + * Retrieves all enterprise groups associated with the organization + * + * @param {*} queryKey The queryKey from the associated `useQuery` call. + * @returns The enterprise group object + */ +const getAllEnterpriseGroups = async ({ enterpriseId }) => { + const params = new URLSearchParams({ + enterprise_uuids: enterpriseId, + group_type: 'flex', + page: 1, + }); + const response = await LmsApiService.fetchAllEnterpriseGroups(params); + const enterpriseGroups = camelCaseObject(response.data); + return enterpriseGroups; +}; + +const useAllEnterpriseGroups = (enterpriseId, { queryOptions } = {}) => useQuery({ + queryKey: learnerCreditManagementQueryKeys.group(enterpriseId), + queryFn: () => getAllEnterpriseGroups({ enterpriseId }), + ...queryOptions, +}); + +export default useAllEnterpriseGroups; diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js index 86f620ef4e..c1e9cf1faf 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js @@ -5,7 +5,7 @@ import { learnerCreditManagementQueryKeys } from '../constants'; import LmsApiService from '../../../../data/services/LmsApiService'; /** - * Retrieves an enterprise group by group UUID from the API. + * Retrieves a enterprise group by the group UUID from the API. * * @param {*} queryKey The queryKey from the associated `useQuery` call. * @returns The enterprise group object diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index e64b0fea18..0fb9831dd5 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -432,6 +432,11 @@ class LmsApiService { return response; }; + static fetchAllEnterpriseGroups = async (options) => { + const groupsEndpoint = `${LmsApiService.enterpriseGroupListUrl}?${options.toString()}`; + return LmsApiService.apiClient().get(groupsEndpoint); + }; + static fetchEnterpriseGroup = async (groupUuid) => { const groupEndpoint = `${LmsApiService.enterpriseGroupListUrl}${groupUuid}/`; return LmsApiService.apiClient().get(groupEndpoint); diff --git a/src/index.scss b/src/index.scss index c6dc37a128..bd9d28fd84 100644 --- a/src/index.scss +++ b/src/index.scss @@ -27,6 +27,7 @@ $modal-max-width: 650px; @import "./components/settings/settings"; @import "./components/learner-credit-management/styles"; @import "./components/AdvanceAnalyticsV2/styles"; +@import "./components/PeopleManagement/PeopleManagement"; // Global body {