diff --git a/__mocks__/react-instantsearch-dom.jsx b/__mocks__/react-instantsearch-dom.jsx index dbb7fbaba4..fbb501dcf3 100644 --- a/__mocks__/react-instantsearch-dom.jsx +++ b/__mocks__/react-instantsearch-dom.jsx @@ -2,26 +2,135 @@ /* eslint-disable react/prop-types */ const React = require('react'); +const dayjs = require('dayjs'); const MockReactInstantSearch = jest.genMockFromModule( 'react-instantsearch-dom', ); -// eslint-disable-next-line @typescript-eslint/naming-convention -const advertised_course_run = { - start: '2020-09-09T04:00:00Z', - key: 'course-v1:edX+Bee101+3T2020', -}; +const mockCurrentStartDate = dayjs().add(3, 'months').toISOString(); +const mockEndDate = dayjs().add(2, 'years').toISOString(); +const mockEnrollByDate = dayjs().add(9, 'months').toISOString(); +const mockEnrollByTimestamp = dayjs(mockEnrollByDate).unix(); +const mockUpgradeDeadlineTimestamp = dayjs().add(3, 'months').unix(); + const mockNormalizedData = { - start_date: '2020-09-09T04:00:00Z', - end_date: '2021-09-09T04:00:00Z', - enroll_by_date: '2020-09-15T04:00:00Z', + start_date: mockCurrentStartDate, + end_date: mockEndDate, + enroll_by_date: mockEnrollByDate, }; /* eslint-disable camelcase */ const fakeHits = [ - { objectID: '1', aggregation_key: 'course:Bees101', title: 'bla', partners: [{ name: 'edX' }, { name: 'another_unused' }], advertised_course_run, key: 'Bees101', normalized_metadata: mockNormalizedData }, - { objectID: '2', aggregation_key: 'course:Wasps200', title: 'blp', partners: [{ name: 'edX' }, { name: 'another_unused' }], advertised_course_run, key: 'Wasps200', normalized_metadata: mockNormalizedData }, + { + objectID: '1', + aggregation_key: 'course:Bees101', + title: 'bla', + partners: [ + { name: 'edX' }, + { name: 'another_unused' }, + ], + advertised_course_run: { + key: 'course-v1:edx+Bees101+1010', + start: mockCurrentStartDate, + end: mockEndDate, + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + key: 'Bees101', + normalized_metadata: mockNormalizedData, + courseRuns: [ + { + key: 'course-v1:edx+Bees101+1010', + start: mockCurrentStartDate, + end: mockEndDate, + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + { + key: 'course-v1:edX+Bee101+3T2020', + start: '2020-09-09T04:00:00Z', + end: dayjs('2020-09-09T04:00:00Z').add(1, 'year').toISOString(), + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + ], + }, + { objectID: '2', + aggregation_key: 'course:Wasps200', + title: 'blp', + partners: [ + { name: 'edX' }, + { name: 'another_unused' }, + ], + advertised_course_run: { + key: 'course-v1:edx+Wasps200+1010T2024', + start: mockCurrentStartDate, + end: mockEndDate, + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + key: 'Wasps200', + normalized_metadata: mockNormalizedData, + courseRuns: [ + { + key: 'course-v1:edx+Wasps200+1010T2024', + start: '2022-10-09T04:00:00Z', + end: dayjs('2022-10-09T04:00:00Z').add(1, 'year').toISOString(), + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + { + key: 'course-v1:edX+Wasps200+3T2020', + start: '2020-09-09T04:00:00Z', + end: dayjs('2020-09-09T04:00:00Z').add(1, 'year').toISOString(), + enroll_by: mockEnrollByTimestamp, + has_enroll_by: true, + is_active: true, + max_effort: 5, + min_effort: 1, + pacing_type: 'self_paced', + weeks_to_complete: 8, + upgrade_deadline: mockUpgradeDeadlineTimestamp, + content_price: 100, + }, + ], + }, ]; /* eslint-enable camelcase */ diff --git a/package.json b/package.json index 0c5fb38a52..398475adeb 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "i18n_extract": "fedx-scripts formatjs extract --throws", "build:with-theme": "THEME=npm:@edx/brand-edx.org@latest npm run install-theme && fedx-scripts webpack", "check-types": "tsc --noemit", - "lint": "fedx-scripts eslint --ext .js --ext .jsx .; npm run check-types", - "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .tsx --ext .ts .", + "eslint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .", + "lint": "npm run eslint && npm run check-types", + "lint:fix": "npm run eslint -- --fix", "precommit": "npm run lint", "prepublishOnly": "npm run build", "postinstall": "patch-package", diff --git a/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx index ea753ed2a4..4979239a15 100644 --- a/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx @@ -1,9 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { - Spinner, -} from '@openedx/paragon'; +import { Spinner } from '@openedx/paragon'; import ScatterChart from './ScatterChart'; import LineChart from './LineChart'; import BarChart from './BarChart'; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx index e920b824a8..8cbf6037e7 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx @@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import '@testing-library/jest-dom'; import MockAdapter from 'axios-mock-adapter'; +// eslint-disable-next-line import/no-extraneous-dependencies import axios from 'axios'; import { BrowserRouter as Router } from 'react-router-dom'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx index 9b4b196892..42327c1e38 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx @@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import '@testing-library/jest-dom'; import MockAdapter from 'axios-mock-adapter'; +// eslint-disable-next-line import/no-extraneous-dependencies import axios from 'axios'; import { BrowserRouter as Router } from 'react-router-dom'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx index b129721370..b37aecce61 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx @@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import '@testing-library/jest-dom'; import MockAdapter from 'axios-mock-adapter'; +// eslint-disable-next-line import/no-extraneous-dependencies import axios from 'axios'; import { BrowserRouter as Router } from 'react-router-dom'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx index 1c09351b0a..93520b6031 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx @@ -5,6 +5,7 @@ import '@testing-library/jest-dom'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; +// eslint-disable-next-line import/no-extraneous-dependencies import axios from 'axios'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { BrowserRouter as Router } from 'react-router-dom'; diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts index 273fb2310e..8cb92fdd17 100644 --- a/src/components/forms/data/reducer.ts +++ b/src/components/forms/data/reducer.ts @@ -2,7 +2,11 @@ import groupBy from 'lodash/groupBy'; import isEmpty from 'lodash/isEmpty'; import keys from 'lodash/keys'; import { - SetShowErrorsArguments, SET_FORM_FIELD, SET_SHOW_ERRORS, SET_STEP, SET_WORKFLOW_STATE, UPDATE_FORM_FIELDS, RESET_EDIT_STATE, + SetShowErrorsArguments, + SET_FORM_FIELD, SET_SHOW_ERRORS, + SET_STEP, SET_WORKFLOW_STATE, + UPDATE_FORM_FIELDS, + RESET_EDIT_STATE, } from './actions'; import type { FormActionArguments, SetFormFieldArguments, SetStepArguments, SetWorkflowStateArguments, UpdateFormFieldArguments, diff --git a/src/components/learner-credit-management/AssignmentDetailsTableCell.jsx b/src/components/learner-credit-management/AssignmentDetailsTableCell.jsx index 0e9da02c8f..1acb43d8da 100644 --- a/src/components/learner-credit-management/AssignmentDetailsTableCell.jsx +++ b/src/components/learner-credit-management/AssignmentDetailsTableCell.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { Stack, Hyperlink } from '@openedx/paragon'; +import { Hyperlink, Stack } from '@openedx/paragon'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { configuration } from '../../config'; @@ -21,6 +21,7 @@ const AssignmentDetailsTableCell = ({ row, enterpriseSlug, enterpriseId }) => { state: row.original.state, }, ); + const courseKey = row.original.isAssignedCourseRun ? row.original.parentContentKey : row.original.contentKey; return ( {
( - - - - - )} - defaultOpen - onToggle={(open) => { - sendEnterpriseTrackEvent( - enterpriseId, - EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_NEXT_STEPS, - { isOpen: open }, - ); - }} - > -
-
    -
  • - -
  • -
  • +const AssignmentAllocationHelpCollapsibles = ({ enterpriseId, courseRun }) => { + const normalizedEnrollByDate = getNormalizedEnrollByDate(courseRun); + const enrollByDate = normalizedEnrollByDate + ? dayjs(normalizedEnrollByDate).format(DATETIME_FORMAT) + : null; + return ( + + -
  • -
-
-
- - - + )} - onToggle={(open) => { - sendEnterpriseTrackEvent( - enterpriseId, - EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_IMPACT_ON_YOUR_LEARNERS, - { isOpen: open }, - ); - }} - > -
-
    -
  • - -
  • -
  • + defaultOpen + onToggle={(open) => { + sendEnterpriseTrackEvent( + enterpriseId, + EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_NEXT_STEPS, + { isOpen: open }, + ); + }} + > +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    + + -
  • -
-
-
- - - + )} - onToggle={(open) => { - sendEnterpriseTrackEvent( - enterpriseId, - EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_MANAGING_THIS_ASSIGNMENT, - { isOpen: open }, - ); - }} - > -
-
    -
  • - -
  • -
  • + onToggle={(open) => { + sendEnterpriseTrackEvent( + enterpriseId, + EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_IMPACT_ON_YOUR_LEARNERS, + { isOpen: open }, + ); + }} + > +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    + + -
  • -
-
-
-
-); + + )} + onToggle={(open) => { + sendEnterpriseTrackEvent( + enterpriseId, + EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.TOGGLE_MANAGING_THIS_ASSIGNMENT, + { isOpen: open }, + ); + }} + > +
+
    +
  • + +
  • +
  • + +
  • +
+
+ + + ); +}; AssignmentAllocationHelpCollapsibles.propTypes = { enterpriseId: PropTypes.string.isRequired, - course: PropTypes.shape({ - enrollmentDeadline: PropTypes.string.isRequired, + courseRun: PropTypes.shape({ + enrollBy: PropTypes.string, }).isRequired, }; diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx index cd18c5d5f4..0c0df99fa5 100644 --- a/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalContent.jsx @@ -17,7 +17,9 @@ import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isAssignEmailAddressesInput import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpCollapsibles'; import EVENT_NAMES from '../../../eventTracking'; -const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange }) => { +const AssignmentModalContent = ({ + enterpriseId, course, courseRun, onEmailAddressesChange, +}) => { const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); const spendAvailable = subsidyAccessPolicy.aggregates.spendAvailableUsd; @@ -25,13 +27,12 @@ const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange } const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); const [assignmentAllocationMetadata, setAssignmentAllocationMetadata] = useState({}); const intl = useIntl(); + // TODO: as part of fixed price, this would need to extract the contentPrice from courseRun, ENT-9394 const { contentPrice } = course.normalizedMetadata; - const handleEmailAddressInputChange = (e) => { const inputValue = e.target.value; setEmailAddressesInputValue(inputValue); }; - const handleEmailAddressesChanged = useCallback((value) => { if (!value) { setLearnerEmails([]); @@ -85,7 +86,7 @@ const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange } description="Header for the section to assign a course to learners using learner credit." /> - + @@ -131,7 +132,7 @@ const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange } description="Header for the section that explains how assigning a course works" /> - +

@@ -203,6 +204,10 @@ const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange } AssignmentModalContent.propTypes = { enterpriseId: PropTypes.string.isRequired, course: PropTypes.shape().isRequired, // Pass-thru prop to `BaseCourseCard` + courseRun: PropTypes.shape({ + enrollBy: PropTypes.string, + start: PropTypes.string, + }).isRequired, onEmailAddressesChange: PropTypes.func.isRequired, }; diff --git a/src/components/learner-credit-management/assignment-modal/AssignmentModalmportantDates.jsx b/src/components/learner-credit-management/assignment-modal/AssignmentModalmportantDates.jsx new file mode 100644 index 0000000000..858b176d34 --- /dev/null +++ b/src/components/learner-credit-management/assignment-modal/AssignmentModalmportantDates.jsx @@ -0,0 +1,87 @@ +import { Icon, Stack } from '@openedx/paragon'; +import { Calendar } from '@openedx/paragon/icons'; +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; + +import dayjs from 'dayjs'; +import { DATETIME_FORMAT, isDateBeforeToday, SHORT_MONTH_DATE_FORMAT } from '../data'; + +const messages = defineMessages({ + importantDates: { + id: 'lcm.budget.detail.page.catalog.search.allocation.modal.important-dates.title', + defaultMessage: 'Important dates', + description: 'Title for the important dates section on the assignment modal', + }, + enrollByDate: { + id: 'lcm.budget.detail.page.catalog.search.allocation.modal.important-dates.enroll-by-date', + defaultMessage: 'Enroll-by date', + description: 'Enroll-by date for the important dates section on the assignment modal', + }, + courseStarts: { + id: 'lcm.budget.detail.page.catalog.search.allocation.modal.important-dates.course-starts', + defaultMessage: 'Course starts', + description: 'Course starts for the important dates section on the assignment modal in future tense', + }, + courseStarted: { + id: 'lcm.budget.detail.page.catalog.search.allocation.modal.important-dates.course-started', + defaultMessage: 'Course started', + description: 'Course started the important dates section on the assignment modal in past tense', + }, +}); + +const AssignmentModalImportantDate = ({ + label, + children, +}) => ( + + + {label}: + {children} + +); + +AssignmentModalImportantDate.propTypes = { + label: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +const AssignmentModalImportantDates = ({ courseRun }) => { + const intl = useIntl(); + const enrollByDate = dayjs(courseRun.enrollBy).format(DATETIME_FORMAT); + const courseStartDate = courseRun.start; + + // This is an edge case that the user should never enter but covered nonetheless + if (!enrollByDate && !courseStartDate) { + return null; + } + + const courseHasStartedLabel = isDateBeforeToday(courseStartDate) + ? intl.formatMessage(messages.courseStarted) + : intl.formatMessage(messages.courseStarts); + + return ( +
+ + {enrollByDate && ( + + {enrollByDate} + + )} + {courseStartDate && ( + + {dayjs(courseStartDate).format(SHORT_MONTH_DATE_FORMAT)} + + )} + +
+ ); +}; + +AssignmentModalImportantDates.propTypes = { + courseRun: PropTypes.shape({ + enrollBy: PropTypes.string, + start: PropTypes.string, + }).isRequired, +}; + +export default AssignmentModalImportantDates; diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index 65d9880e9f..2e365037c5 100644 --- a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx +++ b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx @@ -1,13 +1,8 @@ import React, { useCallback, useContext, useState } from 'react'; import PropTypes from 'prop-types'; -import { useParams, useNavigate, generatePath } from 'react-router-dom'; +import { generatePath, useNavigate, useParams } from 'react-router-dom'; import { - FullscreenModal, - ActionRow, - Button, - useToggle, - Hyperlink, - StatefulButton, + ActionRow, Button, FullscreenModal, Hyperlink, StatefulButton, useToggle, } from '@openedx/paragon'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -16,13 +11,20 @@ import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform/config'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import AssignmentModalContent from './AssignmentModalContent'; import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService'; -import { learnerCreditManagementQueryKeys, useBudgetId, useSubsidyAccessPolicy } from '../data'; +import { + getAssignableCourseRuns, + LEARNER_CREDIT_ROUTE, + learnerCreditManagementQueryKeys, + useBudgetId, + useSubsidyAccessPolicy, +} from '../data'; import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals'; import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper'; import EVENT_NAMES from '../../../eventTracking'; -import { LEARNER_CREDIT_ROUTE } from '../constants'; +import NewAssignmentModalDropdown from './NewAssignmentModalDropdown'; const useAllocateContentAssignments = () => useMutation({ mutationFn: async ({ @@ -45,12 +47,19 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { const [canAllocateAssignments, setCanAllocateAssignments] = useState(false); const [assignButtonState, setAssignButtonState] = useState('default'); const [createAssignmentsErrorReason, setCreateAssignmentsErrorReason] = useState(); + const [assignmentRun, setAssignmentRun] = useState(); const { successfulAssignmentToast: { displayToastForAssignmentAllocation }, } = useContext(BudgetDetailPageContext); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); const { - subsidyUuid, assignmentConfiguration, isSubsidyActive, isAssignable, catalogUuid, aggregates, + subsidyUuid, + assignmentConfiguration, + isSubsidyActive, + isAssignable, + catalogUuid, + aggregates, + isLateRedemptionAllowed, } = subsidyAccessPolicy; const sharedEnterpriseTrackEventMetadata = { subsidyAccessPolicyId, @@ -59,24 +68,40 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { isSubsidyActive, isAssignable, aggregates, - contentPriceCents: course.normalizedMetadata.contentPrice * 100, + contentPriceCents: assignmentRun?.contentPrice ? assignmentRun.contentPrice * 100 : 0, + parentContentKey: null, contentKey: course.key, courseUuid: course.uuid, assignmentConfiguration, + isLateRedemptionAllowed, }; - + const assignableCourseRuns = getAssignableCourseRuns({ + courseRuns: course.courseRuns, + subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, + isLateRedemptionAllowed, + }); const { mutate } = useAllocateContentAssignments(); const pathToActivityTab = generatePath(LEARNER_CREDIT_ROUTE, { enterpriseSlug, enterpriseAppPage, budgetId: subsidyAccessPolicyId, activeTabKey: 'activity', }); - - const handleOpenAssignmentModal = () => { + const handleOpenAssignmentModal = (selectedCourseRun) => { + setAssignmentRun(selectedCourseRun); + if (!selectedCourseRun) { + logError(`[handleOpenAssignmentModal]: Unable to open learner credit management allocation modal, + selectedCourseRun: ${selectedCourseRun}, + parentContentKey: ${course.key}, + contentKey: ${selectedCourseRun.key}, + enterpriseUuid: ${enterpriseId}, + policyUuid: ${subsidyAccessPolicyId}`); + } open(); sendEnterpriseTrackEvent( enterpriseId, EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.ASSIGN_COURSE, { ...sharedEnterpriseTrackEventMetadata, + parentContentKey: course.key, + contentKey: selectedCourseRun.key, isOpen: !isOpen, }, ); @@ -104,6 +129,8 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { }) => { const trackEventMetadata = { ...sharedEnterpriseTrackEventMetadata, + parentContentKey: course.key, + contentKey: assignmentRun.key, totalLearnersAllocated, totalLearnersAlreadyAllocated, response, @@ -114,11 +141,10 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { trackEventMetadata, ); }; - const handleAllocateContentAssignments = () => { const payload = snakeCaseObject({ - contentPriceCents: course.normalizedMetadata.contentPrice * 100, // Convert to USD cents - contentKey: course.key, + contentPriceCents: assignmentRun.contentPrice * 100, // Convert to USD cents + contentKey: assignmentRun.key, learnerEmails, }); const mutationArgs = { @@ -173,6 +199,8 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.ASSIGNMENT_ALLOCATION_ERROR, { ...sharedEnterpriseTrackEventMetadata, + contentKey: assignmentRun.key, + parentContentKey: course.key, totalAllocatedLearners: learnerEmails.length, errorStatus: httpErrorStatus, errorReason, @@ -182,10 +210,11 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { }, }); }; - return ( <> - + + {children} + { EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.ASSIGNMENT_MODAL_EXIT, { ...sharedEnterpriseTrackEventMetadata, + contentKey: assignmentRun.key, + parentContentKey: course.key, assignButtonState, }, ); @@ -286,6 +317,7 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { > diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalDropdown.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalDropdown.jsx new file mode 100644 index 0000000000..f468b43b75 --- /dev/null +++ b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalDropdown.jsx @@ -0,0 +1,94 @@ +import { Dropdown, Stack } from '@openedx/paragon'; +import dayjs from 'dayjs'; +import PropTypes from 'prop-types'; +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import { useState } from 'react'; +import { SHORT_MONTH_DATE_FORMAT } from '../data'; + +const messages = defineMessages({ + byDate: { + id: 'lcm.budget.detail.page.catalog.search.results.assign.dropdown.header.by-date', + defaultMessage: 'By date', + description: 'Dropdown header for the catalog search results section on the lcm budget detail page with available dates', + }, + noAvailableDates: { + id: 'lcm.budget.detail.page.catalog.search.results.assign.dropdown.header.no-available-dates', + defaultMessage: 'No available dates', + description: 'Dropdown header for the catalog search results section on the lcm budget detail page with no available dates', + }, + enrollBy: { + id: 'lcm.budget.detail.page.catalog.search.results.assign.dropdown.enroll-by-date-item', + defaultMessage: 'Enroll by {enrollByDate}', + description: 'Dropdown item for the catalog search results section on the lcm budget detail enroll-by date', + }, + startDate: { + id: 'lcm.budget.detail.page.catalog.search.results.assign.dropdown.starts-date-item', + defaultMessage: '{startLabel} {startDate}', + description: 'Dropdown item for the catalog search results section on the lcm budget detail start date', + }, +}); + +const NewAssignmentModalDropdown = ({ + id: courseKey, onClick: openAssignmentModal, courseRuns, children, +}) => { + const intl = useIntl(); + const [clickedDropdownItem, setClickedDropdownItem] = useState(null); + const getDropdownItemClassName = (courseRun) => { + if (clickedDropdownItem && clickedDropdownItem.key === courseRun.key) { + return null; + } + return 'text-muted'; + }; + const startLabel = ({ start }) => (dayjs(start).isBefore(dayjs()) ? 'Started' : 'Starts'); + return ( + { + const courseRunKey = event.target.closest('[data-courserunkey]').getAttribute('data-courserunkey'); + const selectedCourseRun = courseRuns.find(({ key }) => key === courseRunKey); + openAssignmentModal(selectedCourseRun); + }} + > + + {children} + + + + {courseRuns.length > 0 ? intl.formatMessage(messages.byDate) : intl.formatMessage(messages.noAvailableDates) } + + {courseRuns.length > 0 && courseRuns.map(courseRun => ( + setClickedDropdownItem(courseRun)} + onMouseUp={() => setClickedDropdownItem(null)} + > + + {intl.formatMessage(messages.enrollBy, { + enrollByDate: dayjs(courseRun.enrollBy).format(SHORT_MONTH_DATE_FORMAT), + })} + + {intl.formatMessage(messages.startDate, { + startLabel: startLabel(courseRun), + startDate: dayjs(courseRun.start).format(SHORT_MONTH_DATE_FORMAT), + })} + + + + ))} + + + ); +}; + +NewAssignmentModalDropdown.propTypes = { + id: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + courseRuns: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string.isRequired, + enrollBy: PropTypes.string, + start: PropTypes.string, + })).isRequired, + children: PropTypes.node.isRequired, +}; + +export default NewAssignmentModalDropdown; diff --git a/src/components/learner-credit-management/cards/BaseCourseCard.jsx b/src/components/learner-credit-management/cards/BaseCourseCard.jsx index 7aae47da29..a9545d8e82 100644 --- a/src/components/learner-credit-management/cards/BaseCourseCard.jsx +++ b/src/components/learner-credit-management/cards/BaseCourseCard.jsx @@ -2,27 +2,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { - useMediaQuery, - breakpoints, - Card, - Stack, - Badge, + Badge, breakpoints, Card, Stack, useMediaQuery, } from '@openedx/paragon'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { useCourseCardMetadata } from './data'; +import AssignmentModalImportantDates from '../assignment-modal/AssignmentModalmportantDates'; +import { formatPrice } from '../data'; const BaseCourseCard = ({ original, footerActions: CardFooterActions, enterpriseSlug, cardClassName, + courseRun, }) => { const isSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth }); const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); const courseCardMetadata = useCourseCardMetadata({ course: camelCaseObject(original), + courseRun, enterpriseSlug, }); const { @@ -34,10 +34,8 @@ const BaseCourseCard = ({ subtitle, formattedPrice, isExecEdCourseType, - courseEnrollmentInfo, - execEdEnrollmentInfo, + footerText, } = courseCardMetadata; - return ( -
{formattedPrice}
+
{courseRun ? formatPrice(courseRun.contentPrice) : formattedPrice}
- - {isExecEdCourseType - ? ( - - ) - : ( - - )} - + +
+ + {isExecEdCourseType + ? ( + + ) + : ( + + )} + +
+ {courseRun && } +
+ {CardFooterActions && ( - {CardFooterActions && } + + )}
); @@ -113,8 +118,17 @@ BaseCourseCard.propTypes = { ), title: PropTypes.string, }).isRequired, + courseRun: PropTypes.shape({ + enrollBy: PropTypes.string, + contentPrice: PropTypes.number, + start: PropTypes.string, + }), footerActions: PropTypes.elementType, cardClassName: PropTypes.string, }; +BaseCourseCard.defaultProps = { + courseRun: null, +}; + export default connect(mapStateToProps)(BaseCourseCard); diff --git a/src/components/learner-credit-management/cards/CourseCardFooterActions.jsx b/src/components/learner-credit-management/cards/CourseCardFooterActions.jsx index 8717b48cf4..9250ed6e22 100644 --- a/src/components/learner-credit-management/cards/CourseCardFooterActions.jsx +++ b/src/components/learner-credit-management/cards/CourseCardFooterActions.jsx @@ -18,7 +18,6 @@ const CourseCardFooterActions = ({ enterpriseId, course }) => { const catalogGroupView = subsidyAccessPolicy?.groupAssociations?.length > 0 && !data.appliesToAllContexts; - const { linkToCourse, uuid } = course; const handleViewCourse = () => { sendEnterpriseTrackEvent( @@ -27,32 +26,33 @@ const CourseCardFooterActions = ({ enterpriseId, course }) => { { courseUuid: uuid }, ); }; - return [ - , - (!catalogGroupView ? ( - + return ( + <> + + {!catalogGroupView && ( + + + + )} + + ); }; CourseCardFooterActions.propTypes = { diff --git a/src/components/learner-credit-management/cards/data/useCourseCardMetadata.js b/src/components/learner-credit-management/cards/data/useCourseCardMetadata.js deleted file mode 100644 index b49ece6cf4..0000000000 --- a/src/components/learner-credit-management/cards/data/useCourseCardMetadata.js +++ /dev/null @@ -1,73 +0,0 @@ -import { useContext } from 'react'; -import { AppContext } from '@edx/frontend-platform/react'; -import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png'; - -import CARD_TEXT from '../../constants'; -import { - EXEC_ED_COURSE_TYPE, - formatDate, - formatPrice, - getEnrollmentDeadline, -} from '../../data'; - -const { ENROLLMENT } = CARD_TEXT; - -const useCourseCardMetadata = ({ - course, - enterpriseSlug, -}) => { - const { config: { ENTERPRISE_LEARNER_PORTAL_URL } } = useContext(AppContext); - const { - availability, - cardImageUrl, - courseType, - key, - normalizedMetadata, - partners, - title, - } = course; - const formattedPrice = (normalizedMetadata.contentPrice || normalizedMetadata.contentPrice === 0) ? formatPrice(normalizedMetadata.contentPrice) : 'N/A'; - const imageSrc = cardImageUrl || cardFallbackImg; - - let logoSrc; - let logoAlt; - if (partners.length === 1) { - logoSrc = partners[0]?.logoImageUrl; - logoAlt = `${partners[0]?.name}'s logo`; - } - - const altText = `${title} course image`; - const formattedAvailability = availability?.length ? availability.join(', ') : null; - const enrollmentDeadline = getEnrollmentDeadline(normalizedMetadata.enrollByDate); - - let courseEnrollmentInfo = ''; - if (formattedAvailability) { - courseEnrollmentInfo = `${formattedAvailability} • `; - } - courseEnrollmentInfo += `${ENROLLMENT.text} ${enrollmentDeadline}`; - const execEdEnrollmentInfo = `Starts ${formatDate(normalizedMetadata.startDate)} • ${ENROLLMENT.text} ${enrollmentDeadline}`; - - const isExecEdCourseType = courseType === EXEC_ED_COURSE_TYPE; - - let linkToCourse = `${ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}/course/${key}`; - if (isExecEdCourseType) { - linkToCourse = `${ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}/executive-education-2u/course/${key}`; - } - - return { - ...course, - subtitle: partners.map(partner => partner.name).join(', '), - formattedPrice, - imageSrc, - altText, - logoSrc, - logoAlt, - enrollmentDeadline, - courseEnrollmentInfo, - execEdEnrollmentInfo, - linkToCourse, - isExecEdCourseType, - }; -}; - -export default useCourseCardMetadata; diff --git a/src/components/learner-credit-management/cards/data/useCourseCardMetadata.jsx b/src/components/learner-credit-management/cards/data/useCourseCardMetadata.jsx new file mode 100644 index 0000000000..e5abef6d00 --- /dev/null +++ b/src/components/learner-credit-management/cards/data/useCourseCardMetadata.jsx @@ -0,0 +1,104 @@ +import { useContext } from 'react'; +import { AppContext } from '@edx/frontend-platform/react'; +import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png'; + +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import { + EMPTY_CONTENT_PRICE_VALUE, + EXEC_ED_COURSE_TYPE, + formatPrice, + getAssignableCourseRuns, + getEnrollmentDeadline, + useBudgetId, + useSubsidyAccessPolicy, +} from '../../data'; +import { pluralText } from '../../../../utils'; + +const messages = defineMessages({ + courseFooterMessage: { + id: 'lcm.budget.detail.page.catalog.tab.course.card.footer-text', + defaultMessage: '({courseRuns}) available {pluralText}', + description: 'Footer text for a course card result for learner credit management', + }, +}); + +const getContentPriceDisplay = ({ courseRuns }) => { + const flatContentPrice = courseRuns.flatMap(run => run.contentPrice || EMPTY_CONTENT_PRICE_VALUE); + // Find the max and min prices + if (!flatContentPrice.length) { + return formatPrice(EMPTY_CONTENT_PRICE_VALUE); + } + const maxPrice = Math.max(...flatContentPrice); + const minPrice = Math.min(...flatContentPrice); + // Heuristic for displaying the price as a range or a singular price based on runs + if (maxPrice !== minPrice) { + return `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`; + } + return formatPrice(flatContentPrice[0]); +}; + +const useCourseCardMetadata = ({ + course, + enterpriseSlug, +}) => { + const intl = useIntl(); + const { config: { ENTERPRISE_LEARNER_PORTAL_URL } } = useContext(AppContext); + const { subsidyAccessPolicyId } = useBudgetId(); + const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + const { + cardImageUrl, + courseType, + key, + normalizedMetadata, + partners, + title, + courseRuns, + } = course; + const assignableCourseRuns = getAssignableCourseRuns({ + courseRuns, + subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, + isLateRedemptionAllowed: subsidyAccessPolicy.isLateRedemptionAllowed, + }); + + // Extracts the content price from assignable course runs + const formattedPrice = getContentPriceDisplay({ courseRuns: assignableCourseRuns }); + const imageSrc = cardImageUrl || cardFallbackImg; + + let logoSrc; + let logoAlt; + if (partners.length === 1) { + logoSrc = partners[0]?.logoImageUrl; + logoAlt = `${partners[0]?.name}'s logo`; + } + + const altText = `${title} course image`; + const enrollmentDeadline = getEnrollmentDeadline(normalizedMetadata.enrollByDate); + + const isExecEdCourseType = courseType === EXEC_ED_COURSE_TYPE; + + let linkToCourse = `${ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}/course/${key}`; + if (isExecEdCourseType) { + linkToCourse = `${ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}/executive-education-2u/course/${key}`; + } + + const footerText = intl.formatMessage(messages.courseFooterMessage, { + courseRuns: assignableCourseRuns.length, + pluralText: pluralText('date', assignableCourseRuns.length), + }); + + return { + ...course, + subtitle: partners.map(partner => partner.name).join(', '), + formattedPrice, + imageSrc, + altText, + logoSrc, + logoAlt, + enrollmentDeadline, + linkToCourse, + isExecEdCourseType, + footerText, + }; +}; + +export default useCourseCardMetadata; 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 7088eca53f..92641be830 100644 --- a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx +++ b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx @@ -9,10 +9,14 @@ import { QueryClientProvider, useQueryClient } from '@tanstack/react-query'; import { AppContext } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import dayjs from 'dayjs'; import CourseCard from '../CourseCard'; import { + DATETIME_FORMAT, formatPrice, + getNormalizedEnrollByDate, learnerCreditManagementQueryKeys, + SHORT_MONTH_DATE_FORMAT, useBudgetId, useSubsidyAccessPolicy, } from '../../data'; @@ -47,19 +51,50 @@ jest.mock('../../data', () => ({ })); jest.mock('../../../../data/services/EnterpriseAccessApiService'); +const futureStartDate = dayjs().add(5, 'days').toISOString(); +const pastStartDate = dayjs().subtract(5, 'days').toISOString(); +const enrollByTimestamp = dayjs().subtract(10, 'days').unix(); +const enrollByDropdownText = `Enroll by ${dayjs.unix(enrollByTimestamp).format(SHORT_MONTH_DATE_FORMAT)}`; + const originalData = { availability: ['Upcoming'], card_image_url: undefined, course_type: 'course', key: 'course-123x', normalized_metadata: { - enroll_by_date: '2016-02-18T04:00:00Z', - start_date: '2016-04-18T04:00:00Z', + enroll_by_date: dayjs.unix(1892678399).toISOString(), + start_date: futureStartDate, content_price: 100, }, original_image_url: '', partners: [{ logo_image_url: '', name: 'Course Provider' }], title: 'Course Title', + courseRuns: [ + { + key: 'course-v1:edX+course-123x+3T2020', + start: futureStartDate, + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + enroll_by: enrollByTimestamp, + has_enroll_by: true, + is_active: true, + weeks_to_complete: 60, + end: dayjs().add(1, 'years').toISOString(), + content_price: '100', + }, + ], + advertised_course_run: { + key: 'course-v1:edX+course-123x+3T2020', + start: futureStartDate, + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + enroll_by: enrollByTimestamp, + has_enroll_by: true, + is_active: true, + weeks_to_complete: 60, + end: dayjs().add(1, 'year').toISOString(), + content_price: '100', + }, }; const imageAltText = `${originalData.title} course image`; @@ -76,8 +111,34 @@ const execEdData = { key: 'exec-ed-course-123x', entitlements: [{ price: '999.00' }], normalized_metadata: { - enroll_by_date: '2016-02-18T04:00:00Z', - start_date: '2016-04-18T04:00:00Z', + enroll_by_date: dayjs.unix(1892678399).toISOString(), + start_date: futureStartDate, + content_price: 999, + }, + courseRuns: [ + { + key: 'course-v1:edX+course-123x+3T2020', + start: futureStartDate, + upgrade_deadline: 1892678399, + pacing_type: 'instructor_paced', + enroll_by: enrollByTimestamp, + has_enroll_by: true, + is_active: true, + weeks_to_complete: 60, + end: dayjs().add(1, 'year').toISOString(), + content_price: 999, + }, + ], + advertised_course_run: { + key: 'course-v1:edX+course-123x+3T2020', + start: futureStartDate, + upgrade_deadline: 1892678399, + pacing_type: 'instructor_paced', + enroll_by: enrollByTimestamp, + has_enroll_by: true, + is_active: true, + weeks_to_complete: 60, + end: dayjs().add(1, 'year').toISOString(), content_price: 999, }, original_image_url: '', @@ -109,6 +170,8 @@ const mockSubsidyAccessPolicy = { aggregates: { spendAvailableUsd: 50000, }, + subsidyExpirationDatetime: '2100-02-18T04:00:00Z', + isLateRedemptionAllowed: false, }; const mockLearnerEmails = ['hello@example.com', 'world@example.com', 'dinesh@example.com']; @@ -182,6 +245,7 @@ describe('Course card works as expected', () => { useSubsidyAccessPolicy.mockReturnValue({ data: mockSubsidyAccessPolicy, isLoading: false, + isLateRedemptionAllowed: false, }); }); @@ -194,8 +258,9 @@ describe('Course card works as expected', () => { expect(screen.getByText(defaultProps.original.title)).toBeInTheDocument(); expect(screen.getByText(defaultProps.original.partners[0].name)).toBeInTheDocument(); expect(screen.getByText('$100')).toBeInTheDocument(); + userEvent.click(screen.getByText('Assign')); expect(screen.getByText('Per learner price')).toBeInTheDocument(); - expect(screen.getByText('Upcoming • Learner must enroll by Feb 18, 2016')).toBeInTheDocument(); + expect(screen.getByText(enrollByDropdownText)).toBeInTheDocument(); expect(screen.getByText('Course')).toBeInTheDocument(); // Has card image defined even though the course metadata does not contain an image URL const cardImage = screen.getByAltText(imageAltText); @@ -226,9 +291,12 @@ describe('Course card works as expected', () => { }); test('executive education card renders', () => { + const enrollByDate = getNormalizedEnrollByDate(dayjs.unix(enrollByTimestamp).toISOString()); + const formattedEnrollBy = dayjs(enrollByDate).format(SHORT_MONTH_DATE_FORMAT); renderWithRouter(); expect(screen.queryByText('$999')).toBeInTheDocument(); - expect(screen.queryByText('Starts Apr 18, 2016 • Learner must enroll by Feb 18, 2016')).toBeInTheDocument(); + userEvent.click(screen.getByText('Assign')); + expect(screen.getByText(`Enroll by ${formattedEnrollBy}`)).toBeInTheDocument(); expect(screen.queryByText('Executive Education')).toBeInTheDocument(); const viewCourseCTA = screen.getByText('View course', { selector: 'a' }); expect(viewCourseCTA.href).toContain('https://enterprise.stage.edx.org/test-enterprise-slug/executive-education-2u/course/exec-ed-course-123x'); @@ -247,6 +315,8 @@ describe('Course card works as expected', () => { const assignCourseCTA = getButtonElement('Assign'); expect(assignCourseCTA).toBeInTheDocument(); userEvent.click(assignCourseCTA); + expect(screen.getByText(enrollByDropdownText)).toBeInTheDocument(); + userEvent.click(screen.getByText(enrollByDropdownText)); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); const assignmentModal = within(screen.getByRole('dialog')); @@ -264,6 +334,8 @@ describe('Course card works as expected', () => { const assignCourseCTA = getButtonElement('Assign'); expect(assignCourseCTA).toBeInTheDocument(); userEvent.click(assignCourseCTA); + expect(screen.getByText(enrollByDropdownText)).toBeInTheDocument(); + userEvent.click(screen.getByText(enrollByDropdownText)); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); const helpCenterButton = screen.getByText('Help Center: Course Assignments'); @@ -279,50 +351,93 @@ describe('Course card works as expected', () => { hasAllocationException: true, allocationExceptionReason: 'content_not_in_catalog', shouldRetryAllocationAfterException: false, // no ability to retry after this error + courseImportantDates: { + courseStartDate: futureStartDate, + expectedCourseStartText: 'Course starts:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: 'not_enough_value_in_subsidy', shouldRetryAllocationAfterException: false, + courseImportantDates: { + courseStartDate: pastStartDate, + expectedCourseStartText: 'Course started:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: 'not_enough_value_in_subsidy', shouldRetryAllocationAfterException: true, + courseImportantDates: { + courseStartDate: futureStartDate, + expectedCourseStartText: 'Course starts:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: 'policy_spend_limit_reached', shouldRetryAllocationAfterException: false, + courseImportantDates: { + courseStartDate: pastStartDate, + expectedCourseStartText: 'Course started:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: 'policy_spend_limit_reached', shouldRetryAllocationAfterException: true, + courseImportantDates: { + courseStartDate: futureStartDate, + expectedCourseStartText: 'Course starts:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: null, shouldRetryAllocationAfterException: false, + courseImportantDates: { + courseStartDate: pastStartDate, + expectedCourseStartText: 'Course started:', + }, }, { shouldSubmitAssignments: true, hasAllocationException: true, allocationExceptionReason: null, shouldRetryAllocationAfterException: true, + courseImportantDates: { + courseStartDate: futureStartDate, + expectedCourseStartText: 'Course starts:', + }, + }, + { + shouldSubmitAssignments: true, + hasAllocationException: false, + courseImportantDates: { + courseStartDate: null, + expectedCourseStartText: '', + }, + }, + { + shouldSubmitAssignments: false, + hasAllocationException: false, + courseImportantDates: { + courseStartDate: null, + expectedCourseStartText: '', + }, }, - { shouldSubmitAssignments: true, hasAllocationException: false }, - { shouldSubmitAssignments: false, hasAllocationException: false }, ])('opens assignment modal, fills out information, and submits assignments accordingly - with success or with an exception (%s)', async ({ shouldSubmitAssignments, hasAllocationException, allocationExceptionReason, shouldRetryAllocationAfterException, + courseImportantDates, }) => { const mockUpdatedLearnerAssignments = [mockLearnerEmails[0]]; const mockNoChangeLearnerAssignments = [mockLearnerEmails[1]]; @@ -361,10 +476,34 @@ describe('Course card works as expected', () => { useQueryClient.mockReturnValue({ invalidateQueries: mockInvalidateQueries, }); - renderWithRouter(); + const { + courseStartDate, expectedCourseStartText, + } = courseImportantDates; + const props = { + original: { + ...defaultProps.original, + normalized_metadata: { + ...defaultProps.original.normalized_metadata, + start_date: courseStartDate, + }, + courseRuns: [{ + ...defaultProps.original.courseRuns[0], + start: courseStartDate, + }, + ], + advertised_course_run: { + ...defaultProps.original.advertised_course_run, + start: courseStartDate, + }, + }, + }; + 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')); @@ -377,8 +516,7 @@ describe('Course card works as expected', () => { expect(modalCourseCard.getByText(defaultProps.original.partners[0].name)).toBeInTheDocument(); expect(modalCourseCard.getByText('$100')).toBeInTheDocument(); expect(modalCourseCard.getByText('Per learner price')).toBeInTheDocument(); - expect(modalCourseCard.getByText('Upcoming • Learner must enroll by Feb 18, 2016')).toBeInTheDocument(); - expect(modalCourseCard.getByText('Course')).toBeInTheDocument(); + expect(screen.getByText(enrollByDropdownText)).toBeInTheDocument(); const cardImage = modalCourseCard.getByAltText(imageAltText); expect(cardImage).toBeInTheDocument(); expect(cardImage.src).toBeDefined(); @@ -401,7 +539,19 @@ describe('Course card works as expected', () => { const expectedAvailableBalance = formatPrice(mockSubsidyAccessPolicy.aggregates.spendAvailableUsd); expect(assignmentModal.getByText(expectedAvailableBalance)).toBeInTheDocument(); - // Verify collapsibles + // Verify important dates + expect(assignmentModal.getByText('Enroll-by date:')).toBeInTheDocument(); + expect(assignmentModal.getByText( + dayjs.unix(enrollByTimestamp).format(DATETIME_FORMAT), + )).toBeInTheDocument(); + if (courseStartDate) { + expect(assignmentModal.getByText(expectedCourseStartText)).toBeInTheDocument(); + expect(assignmentModal.getByText( + dayjs(courseStartDate).format(SHORT_MONTH_DATE_FORMAT), + )).toBeInTheDocument(); + } + + // Verify collapsible expect(assignmentModal.getByText('How assigning this course works')).toBeInTheDocument(); expect(assignmentModal.getByText('Next steps for assigned learners')).toBeInTheDocument(); expect(assignmentModal.getByText('Learners will be notified of this course assignment by email.')).toBeInTheDocument(); @@ -459,7 +609,7 @@ describe('Course card works as expected', () => { mockSubsidyAccessPolicy.uuid, expect.objectContaining({ content_price_cents: 10000, - content_key: 'course-123x', + content_key: 'course-v1:edX+course-123x+3T2020', learner_emails: mockLearnerEmails, }), ); @@ -579,7 +729,8 @@ describe('Course card works as expected', () => { 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 diff --git a/src/components/learner-credit-management/constants.js b/src/components/learner-credit-management/constants.js deleted file mode 100644 index a344b21fff..0000000000 --- a/src/components/learner-credit-management/constants.js +++ /dev/null @@ -1,20 +0,0 @@ -const CARD_TEXT = { - BADGE: { - course: 'Course', - execEd: 'Executive Education', - }, - BUTTON_ACTION: { - viewCourse: 'View course', - assign: 'Assign', - }, - ENROLLMENT: { - text: 'Learner must enroll by', - }, - PRICE: { - subText: 'Per learner price', - }, -}; - -export const LEARNER_CREDIT_ROUTE = '/:enterpriseSlug/admin/:enterpriseAppPage/:budgetId/:activeTabKey'; - -export default CARD_TEXT; diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index e7d6b7756f..299f0dc7a9 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -22,6 +22,12 @@ export const API_FIELDS_BY_TABLE_COLUMN_ACCESSOR = { courseListPrice: 'course_list_price', }; +// Course pace text +export const COURSE_PACING_MAP = { + SELF_PACED: 'self_paced', + INSTRUCTOR_PACED: 'instructor_paced', +}; + // Percentage where messaging (e.g., Alert) on low remaining balance will begin appearing export const LOW_REMAINING_BALANCE_PERCENT_THRESHOLD = 0.75; @@ -32,6 +38,9 @@ export const NO_BALANCE_REMAINING_DOLLAR_THRESHOLD = 100; export const DATE_FORMAT = 'MMMM DD, YYYY'; +export const SHORT_MONTH_DATE_FORMAT = 'MMM DD, YYYY'; +export const DATETIME_FORMAT = 'MMM D, YYYY h:mma'; + export const EXEC_ED_OFFER_TYPE = 'learner_credit'; // Budget Detail Page Tabs @@ -44,6 +53,25 @@ export const BUDGET_DETAIL_TAB_LABELS = { [BUDGET_DETAIL_MEMBERS_TAB]: 'Members', }; +// TODO: i18n'tify this +// Card text for used in useCourseCardMetadata +export const CARD_TEXT = { + BADGE: { + course: 'Course', + execEd: 'Executive Education', + }, + BUTTON_ACTION: { + viewCourse: 'View course', + assign: 'Assign', + }, + ENROLLMENT: { + text: 'Learner must enroll by', + }, + PRICE: { + subText: 'Per learner price', + }, +}; + // Facet filters export const LEARNING_TYPE_REFINEMENT = 'learning_type'; export const LANGUAGE_REFINEMENT = 'language'; @@ -71,6 +99,21 @@ export const MEMBERS_TABLE_PAGE_SIZE = 10; // Enroll-by date warning message threshold by days export const ENROLL_BY_DATE_DAYS_THRESHOLD = 10; +// Allocation assignment expiration dropoff threshold +export const DAYS_UNTIL_ASSIGNMENT_ALLOCATION_EXPIRATION = 90; + +// Maximum days allowed from enrollment for a refund on assignments related to policies +export const MAX_ALLOWABLE_REFUND_THRESHOLD_DAYS = 14; + +// When the start date is before this number of days before today, display the alternate start date (fixed to today). +export const START_DATE_DEFAULT_TO_TODAY_THRESHOLD_DAYS = 14; + +// Default empty content_price value +export const EMPTY_CONTENT_PRICE_VALUE = 0; + +// Late enrollments feature +export const LATE_ENROLLMENTS_BUFFER_DAYS = 30; + // Query Key factory for the learner credit management module, intended to be used with `@tanstack/react-query`. // Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. export const learnerCreditManagementQueryKeys = { @@ -85,3 +128,6 @@ export const learnerCreditManagementQueryKeys = { budgetGroupLearners: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'group learners'], enterpriseCustomer: (enterpriseId) => [...learnerCreditManagementQueryKeys.all, 'enterpriseCustomer', enterpriseId], }; + +// Route to learner credit +export const LEARNER_CREDIT_ROUTE = '/:enterpriseSlug/admin/:enterpriseAppPage/:budgetId/:activeTabKey?'; diff --git a/src/components/learner-credit-management/data/hooks/usePathToCatalogTab.js b/src/components/learner-credit-management/data/hooks/usePathToCatalogTab.js index cc98165e99..c9a709729b 100644 --- a/src/components/learner-credit-management/data/hooks/usePathToCatalogTab.js +++ b/src/components/learner-credit-management/data/hooks/usePathToCatalogTab.js @@ -1,7 +1,8 @@ import { useParams, generatePath } from 'react-router-dom'; import useBudgetId from './useBudgetId'; -import { LEARNER_CREDIT_ROUTE } from '../../constants'; + +import { LEARNER_CREDIT_ROUTE } from '../constants'; const usePathToCatalogTab = () => { const { budgetId } = useBudgetId(); diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index a737f751a4..e30fc4a9b5 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -4,9 +4,14 @@ import { camelCaseObject } from '@edx/frontend-platform/utils'; import { logInfo } from '@edx/frontend-platform/logging'; import { + ASSIGNMENT_ENROLLMENT_DEADLINE, + COURSE_PACING_MAP, + DAYS_UNTIL_ASSIGNMENT_ALLOCATION_EXPIRATION, + LATE_ENROLLMENTS_BUFFER_DAYS, LOW_REMAINING_BALANCE_PERCENT_THRESHOLD, + MAX_ALLOWABLE_REFUND_THRESHOLD_DAYS, NO_BALANCE_REMAINING_DOLLAR_THRESHOLD, - ASSIGNMENT_ENROLLMENT_DEADLINE, + START_DATE_DEFAULT_TO_TODAY_THRESHOLD_DAYS, } from './constants'; import { BUDGET_STATUSES } from '../../EnterpriseApp/data/constants'; import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService'; @@ -551,3 +556,146 @@ export const isLmsBudget = ( activeIntegrationsLength, isUniversalGroup, ) => activeIntegrationsLength > 0 && isUniversalGroup; + +/** + * Determines if the course has already started. Mostly used around text formatting for tense + * + * @param date + * @returns {boolean} + */ +export const isDateBeforeToday = date => dayjs(date).isBefore(dayjs()); + +export const minimumEnrollByDateFromToday = ({ subsidyExpirationDatetime }) => Math.min( + dayjs(subsidyExpirationDatetime).subtract(MAX_ALLOWABLE_REFUND_THRESHOLD_DAYS, 'days').toDate(), +); + +export const isCourseSelfPaced = ({ pacingType }) => pacingType === COURSE_PACING_MAP.SELF_PACED; + +export const hasTimeToComplete = ({ end, weeksToComplete }) => { + if (!weeksToComplete || !end) { + return true; + } + const today = dayjs(); + const differenceInWeeks = dayjs(end).diff(today, 'week'); + return weeksToComplete <= differenceInWeeks; +}; + +const isWithinMinimumStartDateThreshold = ({ start }) => dayjs(start).isBefore(dayjs().subtract(START_DATE_DEFAULT_TO_TODAY_THRESHOLD_DAYS, 'days')); + +/** + * If the start date of the course is before today offset by the START_DATE_DEFAULT_TO_TODAY_THRESHOLD_DAYS + * then return today's formatted date. Otherwise, pass-through the start date in ISO format. + * + * For cases where a start date does not exist, just return today's date. + * + * @param {string} - start + * @param {string} - pacingType + * @param {string} - end + * @param {number} - weeksToComplete + * @returns {string} + */ +export const getNormalizedStartDate = ({ + start, pacingType, end, weeksToComplete, +}) => { + const todayToIso = dayjs().toISOString(); + if (!start) { + return todayToIso; + } + const startDateIso = dayjs(start).toISOString(); + if (isCourseSelfPaced({ pacingType })) { + if (hasTimeToComplete({ end, weeksToComplete }) || isWithinMinimumStartDateThreshold({ start })) { + // always today's date (incentives enrollment) + return todayToIso; + } + } + return startDateIso; +}; + +export const getNormalizedEnrollByDate = (enrollBy) => { + if (!enrollBy) { + return null; + } + const ninetyDaysFromNow = dayjs().add(DAYS_UNTIL_ASSIGNMENT_ALLOCATION_EXPIRATION, 'days'); + if (dayjs(enrollBy).isAfter(ninetyDaysFromNow)) { + return ninetyDaysFromNow.toISOString(); + } + return enrollBy; +}; + +/** + * Filters assignable course runs based on the following criteria: + * - If hasEnrollBy, we return assignments with enroll before the soonest date: The subsidy expiration + * date - refund threshold + * - If isLateRedemptionAllowed, we consider only the isLateEnrollmentEligible field returned by Algolia for + * each run. + * + * Based on the above criteria, if isLateRedemptionAllowed is false, filter on if the course run isActive AND + * isEligibleForEnrollment + * + * The main purpose of the filter is to ensure that course runs for a + * course are within the enterprises LC subsidy duration + * The inclusion of the increased sensitivity reduces the chance of a specific run + * (which may be allocated but not accepted) falling outside the date range of the subsidy expiration date + * refund threshold. + * + * We transform the assignedCourseRuns data to normalize the start and enrollBy dates based on the functions + * + * Furthermore, we return assignable course runs sorted by the enrollBy date (soonest to latest). If the enrollBy dates + * are equivalent, sort by the start date. + * + * @param courseRuns + * @param subsidyExpirationDatetime + * @param isLateRedemptionAllowed + * @returns {*} + */ +export const getAssignableCourseRuns = ({ courseRuns, subsidyExpirationDatetime, isLateRedemptionAllowed }) => { + const clonedCourseRuns = courseRuns.map(courseRun => ({ + ...courseRun, + enrollBy: courseRun.hasEnrollBy ? dayjs.unix(courseRun.enrollBy).toISOString() : null, + upgradeDeadline: dayjs.unix(courseRun.upgradeDeadline).toISOString(), + })); + const assignableCourseRunsFilter = ({ + enrollBy, isActive, hasEnrollBy = false, isLateEnrollmentEligible = false, + }) => { + let isEligibleForEnrollment = true; + if (hasEnrollBy) { + isEligibleForEnrollment = dayjs(enrollBy).isBefore( + minimumEnrollByDateFromToday({ subsidyExpirationDatetime }), + ); + } + // Late redemption filter + if (isDateBeforeToday(enrollBy) && isLateRedemptionAllowed) { + const lateEnrollmentCutoff = dayjs().subtract(LATE_ENROLLMENTS_BUFFER_DAYS, 'days'); + isEligibleForEnrollment = dayjs(enrollBy).isAfter(lateEnrollmentCutoff); + return isLateEnrollmentEligible && isEligibleForEnrollment; + } + // General courseware filter + return isActive && isEligibleForEnrollment; + }; + // Main function that transforms the cloned course runs to the normalizedStart and normalizedEnrollBy dates + const assignableCourseRuns = clonedCourseRuns.filter(assignableCourseRunsFilter).map(courseRun => { + if (!courseRun.hasEnrollBy) { + return { + ...courseRun, + start: getNormalizedStartDate(courseRun), + enrollBy: getNormalizedEnrollByDate( + minimumEnrollByDateFromToday({ subsidyExpirationDatetime }), + ), + hasEnrollBy: true, + }; + } + return { + ...courseRun, + start: getNormalizedStartDate(courseRun), + enrollBy: getNormalizedEnrollByDate(courseRun.enrollBy), + }; + }); + // Sorts by the enrollBy date. If enrollBy is equivalent, sort by start. + const sortedAssignableCourseRuns = assignableCourseRuns.sort((a, b) => { + if (a.enrollBy === b.enrollBy) { + return dayjs(a.start).unix() - dayjs(b.start).unix(); + } + return a.enrollBy - b.enrollBy; + }); + return sortedAssignableCourseRuns; +}; diff --git a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx index efb3c305ee..3da52a3062 100644 --- a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx +++ b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx @@ -61,6 +61,7 @@ const mockSubsidyAccessPolicy = { spendAvailableUsd: 50000, }, groupAssociations: ['test-group-uuid'], + policyType: 'AssignedLearnerCreditAccessPolicy', }; const mockDisplaySuccessfulInvitationToast = jest.fn(); diff --git a/src/components/learner-credit-management/search/CatalogSearchResults.jsx b/src/components/learner-credit-management/search/CatalogSearchResults.jsx index 45d1e60bab..a8d366faf2 100644 --- a/src/components/learner-credit-management/search/CatalogSearchResults.jsx +++ b/src/components/learner-credit-management/search/CatalogSearchResults.jsx @@ -9,7 +9,10 @@ import { } from '@openedx/paragon'; import CourseCard from '../cards/CourseCard'; -import { DEFAULT_PAGE, SEARCH_RESULT_PAGE_SIZE } from '../data'; +import { + DEFAULT_PAGE, + SEARCH_RESULT_PAGE_SIZE, +} from '../data'; export const ERROR_MESSAGE = 'An error occurred while retrieving data'; @@ -54,7 +57,6 @@ export const BaseCatalogSearchResults = ({ ], [], ); - const tableData = useMemo( () => searchResults?.hits || [], [searchResults?.hits], @@ -65,7 +67,6 @@ export const BaseCatalogSearchResults = ({ useEffect(() => { setNoContent(searchResults === null || searchResults?.nbHits === 0); }, [searchResults, setNoContent]); - if (error) { return ( diff --git a/src/components/learner-credit-management/search/tests/CatalogSearchResults.test.jsx b/src/components/learner-credit-management/search/tests/CatalogSearchResults.test.jsx index 09ca3596d6..48ab49e26f 100644 --- a/src/components/learner-credit-management/search/tests/CatalogSearchResults.test.jsx +++ b/src/components/learner-credit-management/search/tests/CatalogSearchResults.test.jsx @@ -104,6 +104,7 @@ const searchResults = { nbPages: 6, hits: [ { + key: 'Bees101', title: TEST_COURSE_NAME, partners: [{ name: TEST_PARTNER, logo_image_url: '' }], enterprise_catalog_query_titles: TEST_CATALOGS, @@ -113,10 +114,12 @@ const searchResults = { availability: ['Available Now'], content_type: CONTENT_TYPE_COURSE, advertised_course_run: { + key: 'course-v1:edx+Bees101+1010', start: '2020-01-24T05:00:00Z', end: '2080-01-01T17:00:00Z', upgrade_deadline: 1892678399, pacing_type: 'self_paced', + enroll_by: 1892678399, }, normalized_metadata: { start_date: '2020-09-09T04:00:00Z', @@ -124,8 +127,19 @@ const searchResults = { enroll_by_date: '2020-09-15T04:00:00Z', content_price: 199, }, + courseRuns: [ + { + key: 'course-v1:edx+Bees101+1010', + start: '2020-01-24T05:00:00Z', + end: '2080-01-01T17:00:00Z', + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + enroll_by: 1892678399, + }, + ], }, { + key: 'Wasps200', title: TEST_COURSE_NAME_2, partners: [{ name: TEST_PARTNER_2, logo_image_url: '' }], enterprise_catalog_query_titles: TEST_CATALOGS_2, @@ -135,10 +149,12 @@ const searchResults = { availability: ['Available Now'], content_type: CONTENT_TYPE_COURSE, advertised_course_run: { + key: 'course-v1:edX+Wasps200+3T2020', start: '2020-01-24T05:00:00Z', end: '2080-01-01T17:00:00Z', upgrade_deadline: 1892678399, pacing_type: 'self_paced', + enroll_by: 1892678399, }, normalized_metadata: { start_date: '2020-09-09T04:00:00Z', @@ -146,6 +162,16 @@ const searchResults = { enroll_by_date: '2020-09-15T04:00:00Z', content_price: 199, }, + courseRuns: [ + { + key: 'course-v1:edX+Wasps200+3T2020', + start: '2020-01-24T05:00:00Z', + end: '2080-01-01T17:00:00Z', + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + enroll_by: 1892678399, + }, + ], }, ], page: 1, diff --git a/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx b/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx index 63c907edc1..d5418ff3ce 100644 --- a/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx +++ b/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx @@ -1,8 +1,11 @@ import omit from 'lodash/omit'; +// eslint-disable-next-line import/no-extraneous-dependencies,@typescript-eslint/no-unused-vars import { AxiosError } from 'axios'; import type { FormWorkflowHandlerArgs, FormWorkflowStep } from '../../forms/FormWorkflow'; import SSOConfigConnectStep, { getValidations as getSSOConfigConnectStepValidations } from './steps/NewSSOConfigConnectStep'; +// TODO: Resolve dependency issue +// eslint-disable-next-line import/no-cycle import SSOConfigConfigureStep, { getValidations as getSSOConfigConfigureStepValidations } from './steps/NewSSOConfigConfigureStep'; import SSOConfigAuthorizeStep, { getValidations as getSSOConfigAuthorizeStepValidations } from './steps/NewSSOConfigAuthorizeStep'; import SSOConfigConfirmStep from './steps/NewSSOConfigConfirmStep'; @@ -104,15 +107,12 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError, intl }) formFieldsChanged, dispatch, }: FormWorkflowHandlerArgs) => { - let err = null; - // Accurately detect if form fields have changed or there's and error in existing record let isErrored; if (formFields?.uuid) { - isErrored = - formFields.erroredAt && - formFields.submittedAt && - formFields.submittedAt < formFields.erroredAt; + isErrored = formFields.erroredAt + && formFields.submittedAt + && formFields.submittedAt < formFields.erroredAt; } if (!isErrored && !formFieldsChanged) { return formFields; @@ -131,7 +131,7 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError, intl }) updatedFormFields = updateResponse.data; dispatch?.(resetFormEditState()); } catch (error: AxiosError | any) { - err = handleErrors(error); + handleErrors(error); if (error.message?.includes('Must provide valid IDP metadata url')) { errHandler?.(INVALID_IDP_METADATA_ERROR); } else if (error.message?.includes('Record has already been submitted for configuration.')) { @@ -146,7 +146,7 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError, intl }) updatedFormFields.uuid = createResponse.data.record; updatedFormFields.spMetadataUrl = createResponse.data.sp_metadata_url; } catch (error: AxiosError | any) { - err = handleErrors(error); + handleErrors(error); if (error.message?.includes('Must provide valid IDP metadata url')) { errHandler?.(INVALID_IDP_METADATA_ERROR); } else { diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx index 6f8f300cc9..34aef3e42a 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx @@ -58,15 +58,14 @@ const SSOConfigAuthorizeStep = () => { const { testLink } = createSAMLURLs({ configuration, idpSlug, enterpriseSlug, learnerPortalEnabled, }); - + /** - * Contains link to download service metadata XML which looks like: + * Contains link to download service metadata XML which looks like: * https://access.edx.org/samlp/metadata?connection=rwth-aachen-7cef5c13-7d67-460e-9962-3ec31f91ff20 * Built using enterprise slug and uuid */ - const linkToDownloadMetadataXML = - formFields?.spMetadataUrl || - `${configuration.EDX_ACCESS_URL}/samlp/metadata?connection=${enterpriseSlug}-${formFields?.uuid}`; + const linkToDownloadMetadataXML = formFields?.spMetadataUrl + || `${configuration.EDX_ACCESS_URL}/samlp/metadata?connection=${enterpriseSlug}-${formFields?.uuid}`; return ( <> diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx index 4e1806a9ac..905aa57085 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx @@ -11,6 +11,8 @@ import { urlValidation } from '../../../../utils'; import { FormWorkflowStep } from '../../../forms/FormWorkflow'; import { FORM_ERROR_MESSAGE, setStepAction } from '../../../forms/data/actions'; import { INVALID_IDP_METADATA_ERROR, RECORD_UNDER_CONFIGURATIONS_ERROR } from '../../data/constants'; +// TODO: Resolve dependency issue +// eslint-disable-next-line import/no-cycle import { SSOConfigCamelCase } from '../SSOFormWorkflowConfig'; const messages = defineMessages({ diff --git a/src/index.scss b/src/index.scss index 2b587d05aa..c6dc37a128 100644 --- a/src/index.scss +++ b/src/index.scss @@ -84,3 +84,9 @@ form { .font-size-base { font-size: $font-size-base !important; } + +// TODO: Class override can be removed once Paragon is upgraded to version v22.8.1 +.pgn__card-logo-cap { + object-fit: scale-down !important; + object-position: center center !important; +} diff --git a/src/utils.js b/src/utils.js index 9c30c76f2b..e2f712da3e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -546,6 +546,20 @@ function makePlural(num, string) { return `${num} ${string}`; } +/** + * Pluralizes a word that typically ends with s based on the benchmark passed + * + * @param textToPlural + * @param pluralBenchmark + * @param punctuation + * @returns {string} + */ +const pluralText = ( + textToPlural, + pluralBenchmark, + punctuation = '', +) => (pluralBenchmark > 1 || pluralBenchmark === 0 ? `${textToPlural}s${punctuation}` : `${textToPlural}${punctuation}`); + /** * Helper function to determine if a content is archived. * @@ -631,6 +645,7 @@ export { getActiveTableColumnFilters, queryCacheOnErrorHandler, makePlural, + pluralText, isArchivedContent, i18nFormatTimestamp, i18nFormatPassedTimestamp,