diff --git a/Makefile b/Makefile index 25782ea852..3bb81d73fe 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ intl_imports = ./node_modules/.bin/intl-imports.js i18n = ./src/i18n transifex_input = $(i18n)/transifex_input.json # This directory must match .babelrc . -transifex_temp = ./temp/babel-plugin-react-intl +transifex_temp = ./temp/babel-plugin-formatjs shell: ## run a shell on the cookie-cutter container docker exec -it /bin/bash diff --git a/package.json b/package.json index 0a8b756b70..c550d5aa0d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "scripts": { "build": "fedx-scripts webpack", - "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", + "i18n_extract": "fedx-scripts formatjs extract", "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", diff --git a/src/components/ConfirmationModal/ConfirmationModal.test.jsx b/src/components/ConfirmationModal/ConfirmationModal.test.jsx index aa7afb08f8..3c69d3100a 100644 --- a/src/components/ConfirmationModal/ConfirmationModal.test.jsx +++ b/src/components/ConfirmationModal/ConfirmationModal.test.jsx @@ -1,10 +1,9 @@ -import { - render, screen, -} from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import ConfirmationModal from './index'; +import { renderWithI18nProvider } from '../test/testUtils'; describe('', () => { const basicProps = { @@ -16,20 +15,20 @@ describe('', () => { it('should call onConfirm when confirm button is clicked', () => { const mockHandleConfirm = jest.fn(); - render(); + renderWithI18nProvider(); userEvent.click(screen.getByText('Confirm')); expect(mockHandleConfirm).toHaveBeenCalledTimes(1); }); it('should call onClose when modal is closed', () => { const mockHandleClose = jest.fn(); - render(); + renderWithI18nProvider(); userEvent.click(screen.getByText('Cancel')); expect(mockHandleClose).toHaveBeenCalledTimes(1); }); it('should show error alert if confirmButtonState = error', () => { - render(); + renderWithI18nProvider(); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); }); }); diff --git a/src/components/ConfirmationModal/index.jsx b/src/components/ConfirmationModal/index.jsx index d61e1eb13b..6661d2bfbf 100644 --- a/src/components/ConfirmationModal/index.jsx +++ b/src/components/ConfirmationModal/index.jsx @@ -4,14 +4,49 @@ import { ModalDialog, ActionRow, Button, StatefulButton, Alert, } from '@openedx/paragon'; import { Info } from '@openedx/paragon/icons'; +import { defineMessages, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; export const DEFAULT_TITLE = 'Are you sure?'; + +const CONFIRM_TEXT = 'Confirm'; +const CANCEL_TEXT = 'Cancel'; +const LOADING_TEXT = 'Loading...'; +const TRY_AGAIN_TEXT = 'Try again'; + export const CONFIRM_BUTTON_STATES = { default: 'default', pending: 'pending', errored: 'errored', }; +const messages = defineMessages({ + [DEFAULT_TITLE]: { + id: 'adminPortal.confirmationModal.defaultTitle', + defaultMessage: 'Are you sure?', + description: 'Default title for the confirmation modal', + }, + [CONFIRM_TEXT]: { + id: 'adminPortal.confirmationModal.confirm', + defaultMessage: 'Confirm', + description: 'Confirm button text for the confirmation modal', + }, + [CANCEL_TEXT]: { + id: 'adminPortal.confirmationModal.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button text for the confirmation modal', + }, + [LOADING_TEXT]: { + id: 'adminPortal.confirmationModal.loading', + defaultMessage: 'Loading...', + description: 'Loading state text for the confirmation modal', + }, + [TRY_AGAIN_TEXT]: { + id: 'adminPortal.confirmationModal.tryAgain', + defaultMessage: 'Try again', + description: 'Try again button text for the confirmation modal', + }, +}); + const ConfirmationModal = ({ isOpen, disabled, @@ -24,49 +59,76 @@ const ConfirmationModal = ({ confirmText, cancelText, ...rest -}) => ( - - - - {title} - - {confirmButtonState === CONFIRM_BUTTON_STATES.errored && ( - - - Something went wrong - - Please try again. - - )} - - - {body} - - - - - - - - -); +}) => { + const intl = useIntl(); + const defaultMessage = confirmButtonLabels[CONFIRM_BUTTON_STATES.default]; + const pendingMessage = confirmButtonLabels[CONFIRM_BUTTON_STATES.pending]; + const erroredMessage = confirmButtonLabels[CONFIRM_BUTTON_STATES.errored]; + + // This code snippet first checks if the message exists in the messages object, and if it does, + // it formats the message using the intl.formatMessage function. If the message does not exist, + // it uses the defaultMessage value. + const translatedConfirmButtonLabels = { + [CONFIRM_BUTTON_STATES.default]: + messages[defaultMessage] ? intl.formatMessage(messages[defaultMessage]) : defaultMessage, + [CONFIRM_BUTTON_STATES.pending]: + messages[pendingMessage] ? intl.formatMessage(messages[pendingMessage]) : pendingMessage, + [CONFIRM_BUTTON_STATES.errored]: + messages[erroredMessage] ? intl.formatMessage(messages[erroredMessage]) : erroredMessage, + }; + + return ( + + + + {messages[title] ? intl.formatMessage(messages[title]) : title} + + {confirmButtonState === CONFIRM_BUTTON_STATES.errored && ( + + + + + + + )} + + + {body} + + + + + + + + + ); +}; ConfirmationModal.propTypes = { isOpen: PropTypes.bool.isRequired, @@ -88,14 +150,14 @@ ConfirmationModal.propTypes = { ConfirmationModal.defaultProps = { disabled: false, confirmButtonLabels: { - [CONFIRM_BUTTON_STATES.default]: 'Confirm', - [CONFIRM_BUTTON_STATES.pending]: 'Loading...', - [CONFIRM_BUTTON_STATES.errored]: 'Try again', + [CONFIRM_BUTTON_STATES.default]: CONFIRM_TEXT, + [CONFIRM_BUTTON_STATES.pending]: LOADING_TEXT, + [CONFIRM_BUTTON_STATES.errored]: TRY_AGAIN_TEXT, }, confirmButtonState: CONFIRM_BUTTON_STATES.default, title: DEFAULT_TITLE, - confirmText: 'Confirm', - cancelText: 'Cancel', + confirmText: CONFIRM_TEXT, + cancelText: CANCEL_TEXT, }; export default ConfirmationModal; diff --git a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx index 45e488ef70..2291b06188 100644 --- a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx @@ -92,7 +92,7 @@ const CurrentContentHighlightHeader = ({ enterpriseId }) => {

({ useNavigate: () => mockNavigate, })); -const SETTINGS_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.settings}/${SETTINGS_TABS_VALUES.access}`; +const SETTINGS_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.settings}/${ACCESS_TAB}`; const NewFeatureAlertBrowseAndRequestWrapper = () => ( diff --git a/src/components/NewFeatureAlertBrowseAndRequest/index.jsx b/src/components/NewFeatureAlertBrowseAndRequest/index.jsx index 4ca2b322f5..5e463f12d1 100644 --- a/src/components/NewFeatureAlertBrowseAndRequest/index.jsx +++ b/src/components/NewFeatureAlertBrowseAndRequest/index.jsx @@ -9,7 +9,7 @@ import { BROWSE_AND_REQUEST_ALERT_COOKIE_PREFIX, } from '../subscriptions/data/constants'; import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; -import { SETTINGS_TABS_VALUES } from '../settings/data/constants'; +import { ACCESS_TAB } from '../settings/data/constants'; /** * Generates string use to identify cookie @@ -37,7 +37,7 @@ const NewFeatureAlertBrowseAndRequest = ({ enterpriseId, enterpriseSlug, intl }) * Redirects user to settings page, access tab */ const handleGoToSettings = () => { - navigate(`/${enterpriseSlug}/admin/${ROUTE_NAMES.settings}/${SETTINGS_TABS_VALUES.access}`); + navigate(`/${enterpriseSlug}/admin/${ROUTE_NAMES.settings}/${ACCESS_TAB}`); }; return ( diff --git a/src/components/ProductTours/tests/ProductTours.test.jsx b/src/components/ProductTours/tests/ProductTours.test.jsx index 6ecf6bb795..1f5f7ad1a8 100644 --- a/src/components/ProductTours/tests/ProductTours.test.jsx +++ b/src/components/ProductTours/tests/ProductTours.test.jsx @@ -20,7 +20,7 @@ import { TOUR_TARGETS, } from '../constants'; import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; -import { SETTINGS_TABS_VALUES } from '../../settings/data/constants'; +import { ACCESS_TAB } from '../../settings/data/constants'; import { SubsidyRequestsContext } from '../../subsidy-requests'; import { EnterpriseSubsidiesContext } from '../../EnterpriseSubsidiesContext'; import { SUPPORTED_SUBSIDY_TYPES } from '../../../data/constants/subsidyRequests'; @@ -31,7 +31,7 @@ const mockStore = configureMockStore([thunk]); const ENTERPRISE_SLUG = 'sluggy'; const SUBSCRIPTION_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.subscriptionManagement}`; -const SETTINGS_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.settings}/${SETTINGS_TABS_VALUES.access}`; +const SETTINGS_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.settings}/${ACCESS_TAB}`; const LEARNER_CREDIT_PAGE_LOCATION = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.learnerCredit}`; const ToursWithContext = ({ diff --git a/src/components/forms/ValidatedFormCheckbox.tsx b/src/components/forms/ValidatedFormCheckbox.tsx index fbd97f31de..589bcf4c0f 100644 --- a/src/components/forms/ValidatedFormCheckbox.tsx +++ b/src/components/forms/ValidatedFormCheckbox.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import omit from 'lodash/omit'; import isString from 'lodash/isString'; @@ -9,7 +9,7 @@ import { useFormContext } from './FormContext'; type InheritedParagonCheckboxProps = { className?: string; - children: string; + children: ReactNode; }; export type ValidatedFormCheckboxProps = { diff --git a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx index fd2e9f94de..67a9439f58 100644 --- a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx @@ -140,7 +140,7 @@ const BudgetActions = ({

(

  • {
    {hasClipboard && ( - + )} - +
    { + const intl = useIntl(); const modalDisableButtonState = isLoading ? 'pending' : 'default'; const disableButtonProps = { labels: { - default: 'Disable', - pending: 'Disabling...', + default: intl.formatMessage({ + id: 'adminPortal.settings.access.disableLinkButton', + defaultMessage: 'Disable', + description: 'Label for the disable link button.', + }), + pending: intl.formatMessage({ + id: 'adminPortal.settings.access.disableLinkButton.pending', + defaultMessage: 'Disabling...', + description: 'Label for the disable link button in pending state.', + }), }, state: modalDisableButtonState, variant: 'primary', @@ -30,25 +40,54 @@ const DisableLinkManagementAlertModal = ({ return ( - - Disable + + + + )} > {error && ( - Something went wrong - There was an issue with your request, please try again. + + + + )}

    - If you disable access via link, all links will be deactivated and your - learners will no longer have access. Links cannot be reactivated. +

    ); diff --git a/src/components/settings/SettingsAccessTab/LinkCopiedToast.jsx b/src/components/settings/SettingsAccessTab/LinkCopiedToast.jsx index 4c9b324f1d..f488169f64 100644 --- a/src/components/settings/SettingsAccessTab/LinkCopiedToast.jsx +++ b/src/components/settings/SettingsAccessTab/LinkCopiedToast.jsx @@ -1,9 +1,14 @@ import React from 'react'; import { Toast } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; const LinkCopiedToast = (props) => ( - Link copied to clipboard + ); diff --git a/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx b/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx index cbf19075ad..2f7b9d44d0 100644 --- a/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx +++ b/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx @@ -7,6 +7,7 @@ import { StatefulButton, } from '@openedx/paragon'; import { logError } from '@edx/frontend-platform/logging'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import LmsApiService from '../../../data/services/LmsApiService'; const LinkDeactivationAlertModal = ({ @@ -16,6 +17,7 @@ const LinkDeactivationAlertModal = ({ inviteKeyUUID, }) => { const [deactivationState, setDeactivationState] = useState('default'); + const intl = useIntl(); const handleClose = () => { if (onClose) { @@ -40,8 +42,16 @@ const LinkDeactivationAlertModal = ({ const deactivateBtnProps = { labels: { - default: 'Deactivate', - pending: 'Deactivating...', + default: intl.formatMessage({ + id: 'adminPortal.settings.access.deactivateLinkButton', + defaultMessage: 'Deactivate', + description: 'Label for the deactivate link button.', + }), + pending: intl.formatMessage({ + id: 'adminPortal.settings.access.deactivateLinkButton.pending', + defaultMessage: 'Deactivating...', + description: 'Label for the deactivate link button in pending state.', + }), }, variant: 'primary', state: deactivationState, @@ -50,22 +60,42 @@ const LinkDeactivationAlertModal = ({ return ( - + - Deactivate + )} > -

    If you disable a link, it cannot be reactivated.

    +

    + +

    ); }; diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessConfiguredSubsidyType.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessConfiguredSubsidyType.jsx index 86ad9ee8c2..e193c5188a 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessConfiguredSubsidyType.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessConfiguredSubsidyType.jsx @@ -5,26 +5,37 @@ import { Tooltip, } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { SUBSIDY_TYPE_LABELS } from '../data/constants'; const SettingsAccessConfiguredSubsidyType = ({ subsidyType, }) => ( <> -

    Learners will browse and request courses from the associated catalog.

    +

    + +

    - Contact support to change your selection + )} >
    - {SUBSIDY_TYPE_LABELS[subsidyType]} +
    diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx index 8f9cfc1a7e..a23e67f5d3 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx @@ -6,20 +6,11 @@ import { import { logError } from '@edx/frontend-platform/logging'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { useIntl } from '@edx/frontend-platform/i18n'; import LmsApiService from '../../../data/services/LmsApiService'; import { SETTINGS_ACCESS_EVENTS } from '../../../eventTracking'; import { MAX_UNIVERSAL_LINKS } from '../data/constants'; -const BUTTON_PROPS = { - labels: { - default: 'Generate link', - pending: 'Generating link', - loading: 'Readying link generation', - }, - disabledStates: ['pending', 'loading'], - variant: 'primary', -}; - const SettingsAccessGenerateLinkButton = ({ enterpriseUUID, onSuccess, @@ -27,9 +18,32 @@ const SettingsAccessGenerateLinkButton = ({ disabled, }) => { const [loadingLinkCreation, setLoadingLinkCreation] = useState(false); + const intl = useIntl(); const buttonState = loadingLinkCreation ? 'loading' : 'default'; + const BUTTON_PROPS = { + labels: { + default: intl.formatMessage({ + id: 'adminPortal.settings.access.generateLinkButton', + defaultMessage: 'Generate link', + description: 'Label for the generate link button.', + }), + pending: intl.formatMessage({ + id: 'adminPortal.settings.access.generateLinkButton.pending', + defaultMessage: 'Generating link', + description: 'Label for the generate link button in pending state.', + }), + loading: intl.formatMessage({ + id: 'adminPortal.settings.access.generateLinkButton.loading', + defaultMessage: 'Readying link generation', + description: 'Label for the generate link button in loading state.', + }), + }, + disabledStates: ['pending', 'loading'], + variant: 'primary', + }; + const handleGenerateLink = async () => { setLoadingLinkCreation(true); try { diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx index 4b063fcc08..ab3eea3990 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx @@ -9,6 +9,7 @@ import { Info } from '@openedx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { useLinkManagement } from '../data/hooks'; import SettingsAccessTabSection from './SettingsAccessTabSection'; import SettingsAccessGenerateLinkButton from './SettingsAccessGenerateLinkButton'; @@ -37,6 +38,7 @@ const SettingsAccessLinkManagement = ({ const [isLinkManagementAlertModalOpen, setIsLinkManagementAlertModalOpen] = useState(false); const [isLoadingLinkManagementEnabledChange, setIsLoadingLinkManagementEnabledChange] = useState(false); const [hasLinkManagementEnabledChangeError, setHasLinkManagementEnabledChangeError] = useState(false); + const intl = useIntl(); const toggleUniversalLink = async (newEnableUniversalLink) => { setIsLoadingLinkManagementEnabledChange(true); @@ -91,22 +93,48 @@ const SettingsAccessLinkManagement = ({ <> {hasLinkManagementEnabledChangeError && !isLinkManagementAlertModalOpen && ( - Something went wrong - There was an issue with your request, please try again. + + + + )} -

    Generate a link to share with your learners (up to a maximum of {MAX_UNIVERSAL_LINKS} links).

    +

    + +

    {links.length >= MAX_UNIVERSAL_LINKS && ( - You generated the maximum of {MAX_UNIVERSAL_LINKS} links. No additional links may be generated. + )} - {!loadingLinks && } + {!loadingLinks && ( + + )}
    { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + const intl = useIntl(); const handleFormSwitchChange = useCallback(async (e) => { const formSwitchValue = e.target.checked; @@ -39,18 +41,38 @@ const SettingsAccessSSOManagement = ({ <> {error && ( - Something went wrong - There was an issue with your request, please try again. + + + + )} -

    Give learners with Single Sign-On access to the catalog.

    +

    + +

    ); diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyRequestManagement.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyRequestManagement.jsx index 7ce57e6ac9..10d5170b16 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyRequestManagement.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyRequestManagement.jsx @@ -4,6 +4,7 @@ import { Info } from '@openedx/paragon/icons'; import { Alert, } from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { SUPPORTED_SUBSIDY_TYPES } from '../../../data/constants/subsidyRequests'; import SettingsAccessTabSection from './SettingsAccessTabSection'; @@ -15,6 +16,7 @@ const SettingsAccessSubsidyRequestManagement = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const [isInitiallyDisabled, setIsInitiallyDisabled] = useState(disabled); + const intl = useIntl(); const subsidyRequestsEnabled = subsidyRequestConfiguration?.subsidyRequestsEnabled; @@ -54,16 +56,34 @@ const SettingsAccessSubsidyRequestManagement = ({ <> {error && ( - Something went wrong - There was an issue with your request, please try again. + + + + )} diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyTypeSelection.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyTypeSelection.jsx index 9799b0f3b6..862b3f6d66 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyTypeSelection.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessSubsidyTypeSelection.jsx @@ -3,6 +3,7 @@ import { Form, } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { SUPPORTED_SUBSIDY_TYPES } from '../../../data/constants/subsidyRequests'; import ConfirmationModal, { CONFIRM_BUTTON_STATES } from '../../ConfirmationModal'; import { SUBSIDY_TYPE_LABELS } from '../data/constants'; @@ -16,6 +17,7 @@ const SettingsAccessSubsidyTypeSelection = ({ const [isModalOpen, setIsModalOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + const intl = useIntl(); const confirmModalButtonState = useMemo(() => { if (error) { @@ -49,11 +51,18 @@ const SettingsAccessSubsidyTypeSelection = ({ } }, [selectedSubsidyType, updateSubsidyRequestConfiguration]); + const selectedSubsidyTypeLabel = SUBSIDY_TYPE_LABELS[selectedSubsidyType] + && intl.formatMessage(SUBSIDY_TYPE_LABELS[selectedSubsidyType]); + return ( <>

    - Select a subsidy type to distribute. - Learners will browse and request courses from the associated catalog. + +

    handleSelection(ev.target.value)} > - {SUBSIDY_TYPE_LABELS[subsidyType]} + ), )} @@ -78,9 +87,21 @@ const SettingsAccessSubsidyTypeSelection = ({ isOpen={isModalOpen} confirmButtonLabels={ { - [CONFIRM_BUTTON_STATES.default]: 'Confirm selection', - [CONFIRM_BUTTON_STATES.pending]: 'Updating subsidy type...', - [CONFIRM_BUTTON_STATES.errored]: 'Try again', + [CONFIRM_BUTTON_STATES.default]: intl.formatMessage({ + id: 'adminPortal.settings.access.subsidyTypeSelection.confirm', + defaultMessage: 'Confirm selection', + description: 'Confirm button label for selecting a subsidy type.', + }), + [CONFIRM_BUTTON_STATES.pending]: intl.formatMessage({ + id: 'adminPortal.settings.access.subsidyTypeSelection.updating', + defaultMessage: 'Updating subsidy type...', + description: 'Updating subsidy type confirmation message.', + }), + [CONFIRM_BUTTON_STATES.errored]: intl.formatMessage({ + id: 'adminPortal.settings.access.subsidyTypeSelection.tryAgain', + defaultMessage: 'Try again', + description: 'Try again button label for selecting a subsidy type.', + }), } } confirmButtonState={confirmModalButtonState} @@ -89,10 +110,12 @@ const SettingsAccessSubsidyTypeSelection = ({ setSelectedSubsidyType(subsidyRequestConfiguration?.subsidyType); setIsModalOpen(false); }} - body={ - `Setting your selection to "${SUBSIDY_TYPE_LABELS[selectedSubsidyType]}" is permanent, - and can only be changed through customer support.` - } + body={intl.formatMessage({ + id: 'adminPortal.settings.access.subsidyTypeSelection.confirmation', + defaultMessage: `Setting your selection to "{selectedSubsidyType}" is permanent, + and can only be changed through customer support.`, + description: 'Confirmation message for selecting a subsidy type.', + }, { selectedSubsidyType: selectedSubsidyTypeLabel })} /> ); diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx index b840404091..482f4c140a 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx @@ -5,6 +5,7 @@ import { Form, Spinner, } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; const SettingsAccessTabSection = ({ title, @@ -44,7 +45,11 @@ const SettingsAccessTabSection = ({ helperText={formSwitchHelperText} className="justify-content-end" > - Enable + {children && ( diff --git a/src/components/settings/SettingsAccessTab/StatusTableCell.jsx b/src/components/settings/SettingsAccessTab/StatusTableCell.jsx index 6f23a2d709..7cc25aaaff 100644 --- a/src/components/settings/SettingsAccessTab/StatusTableCell.jsx +++ b/src/components/settings/SettingsAccessTab/StatusTableCell.jsx @@ -1,12 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Badge } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; const StatusTableCell = ({ row }) => { const { isValid } = row.original; return ( - {isValid ? 'Active' : 'Inactive'} + {isValid ? ( + + ) : ( + + )} ); }; diff --git a/src/components/settings/SettingsAccessTab/index.jsx b/src/components/settings/SettingsAccessTab/index.jsx index 7ec0285a40..da43d5e71e 100644 --- a/src/components/settings/SettingsAccessTab/index.jsx +++ b/src/components/settings/SettingsAccessTab/index.jsx @@ -3,6 +3,7 @@ import { Col, Row } from '@openedx/paragon'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ContactCustomerSupportButton from '../../ContactCustomerSupportButton'; import { NoAvailableCodesBanner, NoAvailableLicensesBanner } from '../../subsidy-request-management-alerts'; import SettingsAccessLinkManagement from './SettingsAccessLinkManagement'; @@ -60,24 +61,44 @@ const SettingsAccessTab = ({ {isNoAvailableLicensesBannerVisible && } -

    Enable browsing on-demand

    +

    + +

    - Allow learners without a subsidy to browse the catalog and request enrollment to courses. +

    - Contact support +
    {enterpriseSubsidyTypesForRequests.length > 1 && (
    -

    Subsidy type

    +

    + +

    {hasConfiguredSubsidyType ? ( ) : ( @@ -91,10 +112,19 @@ const SettingsAccessTab = ({
    )}
    -

    Select access channel

    +

    + +

    - Channels determine how learners access the catalog(s). - You can select one or both and change your selection at any time. +

    {isUniversalLinkEnabled && (
    diff --git a/src/components/settings/SettingsAccessTab/tests/DisableLinkManagementAlertModal.test.jsx b/src/components/settings/SettingsAccessTab/tests/DisableLinkManagementAlertModal.test.jsx index ef70f8d886..cef100d6c7 100644 --- a/src/components/settings/SettingsAccessTab/tests/DisableLinkManagementAlertModal.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/DisableLinkManagementAlertModal.test.jsx @@ -9,6 +9,7 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import DisableLinkManagementAlertModal from '../DisableLinkManagementAlertModal'; +import { renderWithI18nProvider } from '../../../test/testUtils'; const DisableLinkManagementAlertModalWrapper = (props) => ( @@ -66,7 +67,7 @@ describe('', () => { }); test('`Go back` button calls `onClose`', async () => { const onCloseMock = jest.fn(); - render( {}} diff --git a/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx b/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx index 9da8043bfd..574e939ddd 100644 --- a/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { screen, - render, cleanup, act, } from '@testing-library/react'; @@ -9,6 +8,7 @@ import userEvent from '@testing-library/user-event'; import LmsApiService from '../../../../data/services/LmsApiService'; import LinkDeactivationAlertModal from '../LinkDeactivationAlertModal'; +import { renderWithI18nProvider } from '../../../test/testUtils'; jest.mock('../../../../data/services/LmsApiService', () => ({ __esModule: true, @@ -29,7 +29,7 @@ describe('', () => { const onDeactivateLinkMock = jest.fn(); const mockPromiseResolve = Promise.resolve({ data: {} }); LmsApiService.disableEnterpriseCustomerLink.mockReturnValue(mockPromiseResolve); - render(', () => { }); test('`Go back` calls `onClose`', async () => { const onCloseMock = jest.fn(); - render(', () => { it('renders correctly', () => { - const subsidyType = SUPPORTED_SUBSIDY_TYPES.license; - render(); - expect(screen.getByText(SUBSIDY_TYPE_LABELS[subsidyType])).toBeInTheDocument(); + renderWithI18nProvider(); + expect(screen.getByText('Licenses')).toBeInTheDocument(); }); }); diff --git a/src/components/settings/SettingsAccessTab/tests/SettingsAccessGenerateLinkButton.test.jsx b/src/components/settings/SettingsAccessTab/tests/SettingsAccessGenerateLinkButton.test.jsx index 868af5e0a8..cbe770d366 100644 --- a/src/components/settings/SettingsAccessTab/tests/SettingsAccessGenerateLinkButton.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/SettingsAccessGenerateLinkButton.test.jsx @@ -1,13 +1,13 @@ import React from 'react'; import { screen, - render, act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import SettingsAccessGenerateLinkButton from '../SettingsAccessGenerateLinkButton'; import LmsApiService from '../../../../data/services/LmsApiService'; +import { renderWithI18nProvider } from '../../../test/testUtils'; jest.mock('../../../../data/services/LmsApiService', () => ({ __esModule: true, @@ -30,12 +30,12 @@ describe('', () => { }); it('displays default state', () => { - render(); + renderWithI18nProvider(); expect(screen.queryByText('Generate link')).toBeTruthy(); }); it('is disabled if disabled = true', () => { - render(); + renderWithI18nProvider(); const button = screen.queryByText('Generate link').closest('button'); expect(button).toBeTruthy(); expect(button).toHaveProperty('disabled', true); @@ -43,7 +43,7 @@ describe('', () => { it('clicking button calls api', async () => { const mockHandleSuccess = jest.fn(); - render(); + renderWithI18nProvider(); const mockPromiseResolve = Promise.resolve({ data: {} }); LmsApiService.createEnterpriseCustomerLink.mockReturnValue(mockPromiseResolve); const button = screen.getByText('Generate link'); diff --git a/src/components/settings/SettingsAccessTab/tests/SettingsAccessSubsidyTypeSelection.test.jsx b/src/components/settings/SettingsAccessTab/tests/SettingsAccessSubsidyTypeSelection.test.jsx index d6ea043a93..2fe8451a9f 100644 --- a/src/components/settings/SettingsAccessTab/tests/SettingsAccessSubsidyTypeSelection.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/SettingsAccessSubsidyTypeSelection.test.jsx @@ -1,6 +1,5 @@ import { screen, - render, waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,7 +7,7 @@ import '@testing-library/jest-dom'; import { SUPPORTED_SUBSIDY_TYPES } from '../../../../data/constants/subsidyRequests'; import SettingsAccessSubsidyTypeSelection from '../SettingsAccessSubsidyTypeSelection'; -import { SUBSIDY_TYPE_LABELS } from '../../data/constants'; +import { renderWithI18nProvider } from '../../../test/testUtils'; describe('', () => { const basicProps = { @@ -20,14 +19,14 @@ describe('', () => { }; it('should open confirmation modal when subsidy type is selected', () => { - render(); - userEvent.click(screen.getByLabelText(SUBSIDY_TYPE_LABELS[SUPPORTED_SUBSIDY_TYPES.license])); + renderWithI18nProvider(); + userEvent.click(screen.getByLabelText('Licenses')); expect(screen.getByText('Confirm selection')).toBeInTheDocument(); }); it('should close confirmation modal when cancel is clicked', () => { - render(); - userEvent.click(screen.getByLabelText(SUBSIDY_TYPE_LABELS[SUPPORTED_SUBSIDY_TYPES.license])); + renderWithI18nProvider(); + userEvent.click(screen.getByLabelText('Licenses')); expect(screen.getByText('Confirm selection')).toBeInTheDocument(); userEvent.click(screen.getByText('Cancel')); expect(screen.queryByText('Confirm selection')).not.toBeInTheDocument(); @@ -35,13 +34,13 @@ describe('', () => { it('should call updateSubsidyRequestConfiguration when selection is confirmed', async () => { const mockUpdateSubsidyRequestConfiguration = jest.fn(); - render( + renderWithI18nProvider( , ); - userEvent.click(screen.getByLabelText(SUBSIDY_TYPE_LABELS[SUPPORTED_SUBSIDY_TYPES.license])); + userEvent.click(screen.getByLabelText('Licenses')); userEvent.click(screen.getByText('Confirm selection')); await waitFor(() => { expect(mockUpdateSubsidyRequestConfiguration).toHaveBeenCalledWith({ @@ -54,14 +53,14 @@ describe('', () => { const mockUpdateSubsidyRequestConfiguration = jest.fn(); mockUpdateSubsidyRequestConfiguration.mockRejectedValue('error'); - render( + renderWithI18nProvider( , ); - userEvent.click(screen.getByLabelText(SUBSIDY_TYPE_LABELS[SUPPORTED_SUBSIDY_TYPES.license])); + userEvent.click(screen.getByLabelText('Licenses')); userEvent.click(screen.getByText('Confirm selection')); await waitFor(() => { expect(mockUpdateSubsidyRequestConfiguration).toHaveBeenCalledWith({ diff --git a/src/components/settings/SettingsAccessTab/tests/SettingsAccessTab.test.jsx b/src/components/settings/SettingsAccessTab/tests/SettingsAccessTab.test.jsx index 9ca54f52ba..73058546af 100644 --- a/src/components/settings/SettingsAccessTab/tests/SettingsAccessTab.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/SettingsAccessTab.test.jsx @@ -2,6 +2,7 @@ import { screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import SettingsAccessTab from '../index'; import { SubsidyRequestsContext } from '../../../subsidy-requests'; import { SUPPORTED_SUBSIDY_TYPES } from '../../../../data/constants/subsidyRequests'; @@ -76,11 +77,13 @@ const SettingsAccessTabWrapper = ({ }, props = {}, }) => ( - - - - - + + + + + + + ); /* eslint-enable react/prop-types */ diff --git a/src/components/settings/SettingsAccessTab/tests/SettingsAccessTabSection.test.jsx b/src/components/settings/SettingsAccessTab/tests/SettingsAccessTabSection.test.jsx index 8c6a1839c6..c12ccf1974 100644 --- a/src/components/settings/SettingsAccessTab/tests/SettingsAccessTabSection.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/SettingsAccessTabSection.test.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { screen, - render, cleanup, act, waitForElementToBeRemoved, @@ -9,6 +8,7 @@ import { import userEvent from '@testing-library/user-event'; import SettingsAccessTabSection from '../SettingsAccessTabSection'; +import { renderWithI18nProvider } from '../../../test/testUtils'; const generateProps = ({ checked, @@ -35,7 +35,7 @@ describe('', () => { onFormSwitchChange: changeSpy, onCollapsibleToggle: () => {}, }); - render(); + renderWithI18nProvider(); expect(screen.queryByText(props.children)).toBeTruthy(); // click on form.switch const enableSwitch = screen.getByText('Enable', { exact: false }); @@ -52,7 +52,7 @@ describe('', () => { onCollapsibleToggle: toggleSpy, }); // is open by default - render(); + renderWithI18nProvider(); // click on collapsible title const titleArea = screen.getByText(props.title); await act(async () => { userEvent.click(titleArea); }); @@ -69,7 +69,7 @@ describe('', () => { onFormSwitchChange: toggleSpy, }); // is open by default - render(); + renderWithI18nProvider(); // click on collapsible title const titleArea = screen.getByText(props.title); await act(async () => { userEvent.click(titleArea); }); diff --git a/src/components/settings/SettingsAccessTab/tests/StatusTableCell.test.jsx b/src/components/settings/SettingsAccessTab/tests/StatusTableCell.test.jsx index dbab548538..4c98c4b82b 100644 --- a/src/components/settings/SettingsAccessTab/tests/StatusTableCell.test.jsx +++ b/src/components/settings/SettingsAccessTab/tests/StatusTableCell.test.jsx @@ -1,8 +1,15 @@ import React from 'react'; import renderer from 'react-test-renderer'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import StatusTableCell from '../StatusTableCell'; +const StatusTableCellWrapper = (props) => ( + + + +); + describe('StatusTableCell', () => { it('renders valid status correctly', () => { const props = { @@ -13,7 +20,7 @@ describe('StatusTableCell', () => { }, }; const tree = renderer - .create() + .create() .toJSON(); expect(tree).toMatchSnapshot(); }); @@ -27,7 +34,7 @@ describe('StatusTableCell', () => { }, }; const tree = renderer - .create() + .create() .toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx index 79ea22446c..18e28b208d 100644 --- a/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx +++ b/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx @@ -10,6 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import PropTypes from 'prop-types'; import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import LmsApiService from '../../../data/services/LmsApiService'; import NewSSOConfigAlerts from './NewSSOConfigAlerts'; import NewSSOConfigCard from './NewSSOConfigCard'; @@ -37,6 +38,7 @@ const NewExistingSSOConfigs = ({ const [updateError, setUpdateError] = useState(null); const queryClient = useQueryClient(); + const intl = useIntl(); const renderCards = (gridTitle, configList) => { if (configList.length > 0) { @@ -72,11 +74,20 @@ const NewExistingSSOConfigs = ({ icon={Info} onClose={() => (setUpdateError(null))} > - Something went wrong behind the scenes + + +

    - We were unable to {updateError?.action} your SSO configuration due to an internal error. Please - {' '}try again in a couple of minutes. If the problem persists, contact enterprise customer - {' '}support. +

    @@ -211,8 +222,16 @@ const NewExistingSSOConfigs = ({ setIsStepperOpen={setIsStepperOpen} /> )} - {renderCards('Active', activeConfigs)} - {renderCards('Inactive', inactiveConfigs)} + {renderCards(intl.formatMessage({ + id: 'adminPortal.settings.sso.active', + defaultMessage: 'Active', + description: 'Title for the active SSO configurations grid', + }), activeConfigs)} + {renderCards(intl.formatMessage({ + id: 'adminPortal.settings.sso.inactive', + defaultMessage: 'Inactive', + description: 'Title for the inactive SSO configurations grid', + }), inactiveConfigs)} )} {loading && ( diff --git a/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx b/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx index f4a92c9038..3b78857ca4 100644 --- a/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx @@ -6,6 +6,7 @@ import { } from '@openedx/paragon/icons'; import { Alert, Button } from '@openedx/paragon'; import Cookies from 'universal-cookie'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { SSOConfigContext } from './SSOConfigContext'; export const SSO_SETUP_COMPLETION_COOKIE_NAME = 'dismissed-sso-completion-alert'; @@ -56,17 +57,32 @@ const NewSSOConfigAlerts = ({ icon={Info} className="sso-alert-width" actions={[ - , + , ]} dismissible show={showTimeoutAlert} onClose={() => { setShowTimeoutAlert(false); }} stacked > - SSO Configuration timed out + + +

    - Your SSO configuration failed due to an internal error. Please try again by selecting “Configure” below and - {' '}verifying your integration details. Then reconfigure, reauthorize, and test your connection. +

    ); @@ -80,16 +96,31 @@ const NewSSOConfigAlerts = ({ className="sso-alert-width" show={showErrorAlert} actions={[ - , + , ]} dismissible onClose={() => { setShowErrorAlert(false); }} stacked > - SSO Configuration failed + + +

    - Please verify integration details have been entered correctly. Select “Configure” below and verify your - {' '}integration details. Then reconfigure, reauthorize, and test your connection. +

    ); @@ -105,10 +136,30 @@ const NewSSOConfigAlerts = ({ dismissible onClose={closeAlerts} > - Your SSO Integration is in progress + + +

    - edX is configuring your SSO. This step takes approximately{' '} - {notConfigured.length > 0 ? `five minutes. You will receive an email at ${contactEmail} when the configuration is complete` : 'fifteen seconds'}. + {notConfigured.length > 0 ? ( + + ) : ( + + )}

    )} @@ -120,22 +171,50 @@ const NewSSOConfigAlerts = ({ onClose={closeAlerts} dismissible > - You need to test your SSO connection + + +

    - Your SSO configuration has been completed, - and you should have received an email with the following instructions:
    + +
    +
    +
    - 1: Copy the URL for your Learner Portal dashboard below:

      http://courses.edx.org/dashboard?tpa_hint={enterpriseSlug}

    - 2: Launch a new incognito or private window and paste the copied URL into the URL bar to load your - Learner Portal dashboard.
    + +
    +
    + +

    - 3: When prompted, enter login credentials supported by your IDP to test your connection to edX.
    +
    - Return to this window after completing the testing instructions. - This window will automatically update when a successful test is detected.

    )} @@ -150,9 +229,19 @@ const NewSSOConfigAlerts = ({ onClose={dismissSetupCompleteAlert} dismissible > - Your SSO integration is live! + + +

    - Great news! Your test was successful and your new SSO integration is live and ready to use. +

    )} diff --git a/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx b/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx index e423a17759..d93075ff28 100644 --- a/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { Key, KeyOff, MoreVert, } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { SSOConfigContext } from './SSOConfigContext'; import LmsApiService from '../../../data/services/LmsApiService'; @@ -29,6 +30,7 @@ const NewSSOConfigCard = ({ const TIMED_OUT = SUBMITTED && !CONFIGURED && !config.is_pending_configuration; const { setProviderConfig } = useContext(SSOConfigContext); + const intl = useIntl(); const onConfigureClick = (selectedConfig) => { setProviderConfig(selectedConfig); @@ -84,7 +86,15 @@ const NewSSOConfigCard = ({ The integration is verified and working} + overlay={( + + + + )} > This integration has not been validated. Please follow the testing instructions to validate your integration. - } + overlay={( + + + + )} > {renderKeyOffIcon('existing-sso-config-card-off-not-validated-icon')} @@ -119,7 +135,11 @@ const NewSSOConfigCard = ({ variant="light" data-testid="existing-sso-config-card-badge-in-progress" > - In-progress + )} {VALIDATED && CONFIGURED && !ENABLED && ( @@ -128,7 +148,11 @@ const NewSSOConfigCard = ({ variant="light" data-testid="existing-sso-config-card-badge-disabled" > - Disabled + )} @@ -143,7 +167,11 @@ const NewSSOConfigCard = ({ variant="outline-primary" data-testid="existing-sso-config-card-configure-button" > - Configure + )} {VALIDATED && CONFIGURED && !ENABLED && ( @@ -153,7 +181,11 @@ const NewSSOConfigCard = ({ variant="outline-primary" data-testid="existing-sso-config-card-enable-button" > - Enable + )} @@ -175,7 +207,12 @@ const NewSSOConfigCard = ({ )} subtitle={(
    - Last modified {convertToReadableDate(config.modified)} +
    )} actions={((!SUBMITTED || CONFIGURED) || (ERRORED || TIMED_OUT)) && ( @@ -187,7 +224,12 @@ const NewSSOConfigCard = ({ src={MoreVert} iconAs={Icon} variant="primary" - alt="Actions dropdown" + alt={intl.formatMessage({ + id: 'adminPortal.settings.sso.actionsDropdown', + defaultMessage: 'Actions dropdown', + description: 'Alt text for actions dropdown', + + })} /> {VALIDATED && ( @@ -195,7 +237,11 @@ const NewSSOConfigCard = ({ data-testid="existing-sso-config-configure-dropdown" onClick={() => onConfigureClick(config)} > - Configure + )} {((!ENABLED || !VALIDATED) || (ERRORED || TIMED_OUT)) && ( @@ -203,7 +249,11 @@ const NewSSOConfigCard = ({ data-testid="existing-sso-config-delete-dropdown" onClick={() => onDeleteClick(config)} > - Delete + )} {ENABLED && VALIDATED && ( @@ -211,7 +261,11 @@ const NewSSOConfigCard = ({ data-testid="existing-sso-config-disable-dropdown" onClick={() => onDisableClick(config)} > - Disable + )} diff --git a/src/components/settings/SettingsSSOTab/NewSSOConfigForm.jsx b/src/components/settings/SettingsSSOTab/NewSSOConfigForm.jsx index 86a6c4193f..3b91160e24 100644 --- a/src/components/settings/SettingsSSOTab/NewSSOConfigForm.jsx +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigForm.jsx @@ -2,6 +2,7 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { Alert, Hyperlink } from '@openedx/paragon'; import { WarningFilled } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { SSOConfigContext } from './SSOConfigContext'; import SSOStepper from './SSOStepper'; import { HELP_CENTER_SAML_LINK } from '../data/constants'; @@ -16,8 +17,11 @@ const NewSSOConfigForm = ({ setIsStepperOpen, isStepperOpen }) => {
    {!AUTH0_SELF_SERVICE_INTEGRATION && ( - Connect to a SAML identity provider for single sign-on - to allow quick access to your organization's learning catalog. + )} {AUTH0_SELF_SERVICE_INTEGRATION ? ( diff --git a/src/components/settings/SettingsSSOTab/NewSSOStepper.jsx b/src/components/settings/SettingsSSOTab/NewSSOStepper.jsx index dabed9ccff..88ee5bad51 100644 --- a/src/components/settings/SettingsSSOTab/NewSSOStepper.jsx +++ b/src/components/settings/SettingsSSOTab/NewSSOStepper.jsx @@ -3,6 +3,7 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; import FormContextWrapper from '../../forms/FormContextWrapper'; import { SSOConfigContext } from './SSOConfigContext'; import SSOFormWorkflowConfig from './SSOFormWorkflowConfig'; @@ -17,6 +18,8 @@ const NewSSOStepper = ({ enterpriseId, isStepperOpen, setIsStepperOpen }) => { } = useContext(SSOConfigContext); const providerConfigCamelCase = camelCaseDict(providerConfig || {}); const [configureError, setConfigureError] = useState(null); + const intl = useIntl(); + const handleCloseWorkflow = () => { setProviderConfig?.(null); setIsStepperOpen(false); @@ -33,8 +36,12 @@ const NewSSOStepper = ({ enterpriseId, isStepperOpen, setIsStepperOpen }) => { {isStepperOpen && !configureError && (
    { + const intl = useIntl(); const onClick = () => { setShowNoSSOCard(false); setIsStepperOpen(true); @@ -19,18 +21,36 @@ const NoSSOCard = ({ -

    You don't have any SSOs integrated yet.

    -

    SSO enables learners who are signed in to their enterprise LMS - or other system to easily register and enroll in courses on edX without needing to - sign in again. edX for Business uses SAML 2.0 to implement SSO between an enterprise - system and edX.org. +

    + +

    +

    +

    - + ); diff --git a/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx b/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx index 6f2bde79b1..63c907edc1 100644 --- a/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx +++ b/src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx @@ -2,9 +2,9 @@ import omit from 'lodash/omit'; import { AxiosError } from 'axios'; import type { FormWorkflowHandlerArgs, FormWorkflowStep } from '../../forms/FormWorkflow'; -import SSOConfigConnectStep, { validations as SSOConfigConnectStepValidations } from './steps/NewSSOConfigConnectStep'; -import SSOConfigConfigureStep, { validations as SSOConfigConfigureStepValidations } from './steps/NewSSOConfigConfigureStep'; -import SSOConfigAuthorizeStep, { validations as SSOConfigAuthorizeStepValidations } from './steps/NewSSOConfigAuthorizeStep'; +import SSOConfigConnectStep, { getValidations as getSSOConfigConnectStepValidations } from './steps/NewSSOConfigConnectStep'; +import SSOConfigConfigureStep, { getValidations as getSSOConfigConfigureStepValidations } from './steps/NewSSOConfigConfigureStep'; +import SSOConfigAuthorizeStep, { getValidations as getSSOConfigAuthorizeStepValidations } from './steps/NewSSOConfigAuthorizeStep'; import SSOConfigConfirmStep from './steps/NewSSOConfigConfirmStep'; import LmsApiService from '../../../data/services/LmsApiService'; import handleErrors from '../utils'; @@ -84,7 +84,7 @@ type SSOConfigFormControlVariables = { type SSOConfigFormContextData = SSOConfigCamelCase & SSOConfigFormControlVariables; -export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError }) => { +export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError, intl }) => { const advanceConnectStep = async ({ formFields, errHandler, @@ -163,10 +163,18 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError }) => { { index: 0, formComponent: SSOConfigConnectStep, - validations: SSOConfigConnectStepValidations, - stepName: 'Connect', + validations: getSSOConfigConnectStepValidations(intl), + stepName: intl.formatMessage({ + id: 'adminPortal.settings.sso.connect', + defaultMessage: 'Connect', + description: 'Step name for connecting to an identity provider', + }), nextButtonConfig: () => ({ - buttonText: 'Next', + buttonText: intl.formatMessage({ + id: 'adminPortal.settings.sso.next', + defaultMessage: 'Next', + description: 'Button text for moving to the next step', + }), opensNewWindow: false, onClick: advanceConnectStep, preventDefaultErrorModal: true, @@ -174,10 +182,18 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError }) => { }, { index: 1, formComponent: SSOConfigConfigureStep, - validations: SSOConfigConfigureStepValidations, - stepName: 'Configure', + validations: getSSOConfigConfigureStepValidations(intl), + stepName: intl.formatMessage({ + id: 'adminPortal.settings.sso.configure.stepName', + defaultMessage: 'Configure', + description: 'Step name for configuring an identity provider', + }), nextButtonConfig: () => ({ - buttonText: 'Configure', + buttonText: intl.formatMessage({ + id: 'adminPortal.settings.sso.configure.buttonText', + defaultMessage: 'Configure', + description: 'Button text for configuring an identity provider', + }), opensNewWindow: false, onClick: saveChanges, preventDefaultErrorModal: true, @@ -187,10 +203,18 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError }) => { }, { index: 2, formComponent: SSOConfigAuthorizeStep, - validations: SSOConfigAuthorizeStepValidations, - stepName: 'Authorize', + validations: getSSOConfigAuthorizeStepValidations(intl), + stepName: intl.formatMessage({ + id: 'adminPortal.settings.sso.authorize', + defaultMessage: 'Authorize', + description: 'Step name for authorizing an identity provider', + }), nextButtonConfig: () => ({ - buttonText: 'Next', + buttonText: intl.formatMessage({ + id: 'adminPortal.settings.sso.next', + defaultMessage: 'Next', + description: 'Button text for moving to the next step', + }), opensNewWindow: false, onClick: saveChanges, preventDefaultErrorModal: false, @@ -201,9 +225,17 @@ export const SSOFormWorkflowConfig = ({ enterpriseId, setConfigureError }) => { index: 3, formComponent: SSOConfigConfirmStep, validations: [], - stepName: 'Confirm and Test', + stepName: intl.formatMessage({ + id: 'adminPortal.settings.sso.confirmAndTest', + defaultMessage: 'Confirm and Test', + description: 'Step name for confirming and testing an identity provider', + }), nextButtonConfig: () => ({ - buttonText: 'Finish', + buttonText: intl.formatMessage({ + id: 'adminPortal.settings.sso.finish', + defaultMessage: 'Finish', + description: 'Button text for finishing the configuration', + }), opensNewWindow: false, onClick: () => {}, preventDefaultErrorModal: false, diff --git a/src/components/settings/SettingsSSOTab/SsoErrorPage.jsx b/src/components/settings/SettingsSSOTab/SsoErrorPage.jsx index bd80ab2c81..b079fad9a1 100644 --- a/src/components/settings/SettingsSSOTab/SsoErrorPage.jsx +++ b/src/components/settings/SettingsSSOTab/SsoErrorPage.jsx @@ -7,15 +7,17 @@ import { Image, Stack, } from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { configuration } from '../../../config'; -import { ssoStepperNetworkErrorText, ssoLPNetworkErrorText, HELP_CENTER_SAML_LINK } from '../data/constants'; +import { HELP_CENTER_SAML_LINK } from '../data/constants'; import cardImage from '../../../data/images/SomethingWentWrong.svg'; const SsoErrorPage = ({ isOpen, stepperError, }) => { - const stepperText = stepperError ? ssoStepperNetworkErrorText : ssoLPNetworkErrorText; + const intl = useIntl(); + return ( )} @@ -36,22 +42,65 @@ const SsoErrorPage = ({ src={cardImage} className="ml-auto mr-auto mt-4 d-flex" fluid - alt="Something went wrong" + alt={intl.formatMessage({ + id: 'sso.error.image.alt', + defaultMessage: 'Something went wrong', + description: 'Alt text for the image displayed when an error occurs', + })} />

    - We're sorry.{' '} -  Something went wrong. + + + +   +

    - {stepperText}{' '} - Please close this window and try again in a couple of minutes. If the problem persists, contact enterprise - customer support. + { + stepperError ? ( + + ) : ( + + ) + } + &npsp; +

    - Helpful link:{' '} - Enterprise Help Center: Single Sign-On +   + + +

    diff --git a/src/components/settings/SettingsSSOTab/UnsavedSSOChangesModal.tsx b/src/components/settings/SettingsSSOTab/UnsavedSSOChangesModal.tsx index d0a3ae41db..d3da7110a2 100644 --- a/src/components/settings/SettingsSSOTab/UnsavedSSOChangesModal.tsx +++ b/src/components/settings/SettingsSSOTab/UnsavedSSOChangesModal.tsx @@ -1,36 +1,70 @@ import React from 'react'; import { ModalDialog, ActionRow, Button } from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { UnsavedChangesModalProps } from '../../forms/FormWorkflow'; const UnsavedSSOChangesModal = ({ isOpen, close, exitWithoutSaving, -}: UnsavedChangesModalProps) => ( - - - Exit configuration? - - -

    Your in-progress data will not be saved.

    -

    Your SSO connection will not be active until you restart and complete the SSO configuration process.

    -
    - - - - - - -
    -); +}: UnsavedChangesModalProps) => { + const intl = useIntl(); + + return ( + + + + + + + +

    + +

    +

    + +

    +
    + + + + + + +
    + ); +}; export default UnsavedSSOChangesModal; diff --git a/src/components/settings/SettingsSSOTab/index.jsx b/src/components/settings/SettingsSSOTab/index.jsx index 54b27d8320..4f5b07f4c2 100644 --- a/src/components/settings/SettingsSSOTab/index.jsx +++ b/src/components/settings/SettingsSSOTab/index.jsx @@ -4,6 +4,7 @@ import { Alert, ActionRow, Button, Hyperlink, ModalDialog, Toast, Skeleton, Spinner, useToggle, } from '@openedx/paragon'; import { Add, WarningFilled } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { HELP_CENTER_SAML_LINK } from '../data/constants'; import { useExistingSSOConfigs, useExistingProviderData } from './hooks'; import NoSSOCard from './NoSSOCard'; @@ -31,6 +32,7 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { const [pollingNetworkError, setPollingNetworkError] = useState(false); const [isStepperOpen, setIsStepperOpen] = useState(true); const [isDeletingOldConfigs, setIsDeletingOldConfigs] = useState(false); + const intl = useIntl(); const newConfigurationButtonOnClick = async () => { setIsDeletingOldConfigs(true); @@ -65,7 +67,11 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { return (
    { > - Create new SSO configuration? +

    - Only one SSO integration is supported at a time.
    +
    - To continue updating and editing your SSO integration, select "Cancel" and then - "Configure" on the integration card. Creating a new SSO configuration will overwrite and delete - your existing SSO configuration. +
    +

    - Cancel +
    -

    Single Sign-On (SSO) Integrations

    +

    + +

    {newButtonVisible && ( )} { className="btn btn-outline-primary my-2" target="_blank" > - Help Center: Single Sign-On +
    diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx index 2469fc6c27..2ffcba214e 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx @@ -5,16 +5,25 @@ import { } from '@openedx/paragon'; import { Info, Download } from '@openedx/paragon/icons'; import { getConfig } from '@edx/frontend-platform/config'; +import { defineMessages, FormattedMessage } from '@edx/frontend-platform/i18n'; import { createSAMLURLs } from '../utils'; import { SSOConfigContext } from '../SSOConfigContext'; import { FormFieldValidation, useFormContext } from '../../../forms/FormContext'; import ValidatedFormCheckbox from '../../../forms/ValidatedFormCheckbox'; -export const validations: FormFieldValidation[] = [ +const messages = defineMessages({ + markedAuthorized: { + id: 'adminPortal.settings.ssoConfigAuthorizeStep.markedAuthorized', + defaultMessage: 'Please verify authorization of edX as a Service Provider.', + description: 'Helper message displayed against the option to verify authorization of edX as a Service Provider.', + }, +}); + +export const getValidations = (intl) : FormFieldValidation[] => [ { formFieldId: 'markedAuthorized', validator: (fields) => { - const ret = !fields.markedAuthorized && 'Please verify authorization of edX as a Service Provider.'; + const ret = !fields.markedAuthorized && intl.formatMessage(messages.markedAuthorized); return ret; }, }, @@ -52,34 +61,77 @@ const SSOConfigAuthorizeStep = () => { return ( <> -

    Authorize edX as a Service Provider

    +

    + +

    -

    Action required in a new window

    - Return to this window after completing the following steps - in a new window to finish configuring your integration. +

    + +

    +

    - 1. Download the edX Service Provider metadata as an XML file: +

    - 2. - - Launch a new window - - {' '} and upload the XML file to the list of - authorized SAML Service Providers on your Identity Provider's portal or website. + + + + ), + }} + />


    -

    Return to this window and check the box once complete

    +

    + +

    - I have authorized edX as a Service Provider + ); diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx index 35c7a96aac..4e1806a9ac 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx @@ -4,6 +4,7 @@ import { } from '@openedx/paragon'; import { Info } from '@openedx/paragon/icons'; +import { defineMessages, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import ValidatedFormControl from '../../../forms/ValidatedFormControl'; import { FormContext, FormFieldValidation, useFormContext } from '../../../forms/FormContext'; import { urlValidation } from '../../../../utils'; @@ -12,28 +13,62 @@ import { FORM_ERROR_MESSAGE, setStepAction } from '../../../forms/data/actions'; import { INVALID_IDP_METADATA_ERROR, RECORD_UNDER_CONFIGURATIONS_ERROR } from '../../data/constants'; import { SSOConfigCamelCase } from '../SSOFormWorkflowConfig'; +const messages = defineMessages({ + sapsfOauthRootUrl: { + id: 'adminPortal.settings.ssoConfigConfigureStep.sapsfOauthRootUrl', + defaultMessage: 'Please enter an OAuth Root URL.', + description: 'Helper message displayed against the option to enter an OAuth Root URL.', + }, + odataApiRootUrl: { + id: 'adminPortal.settings.ssoConfigConfigureStep.odataApiRootUrl', + defaultMessage: 'Please enter an API Root URL.', + description: 'Helper message displayed against the option to enter an API Root URL.', + }, + sapsfPrivateKey: { + id: 'adminPortal.settings.ssoConfigConfigureStep.sapsfPrivateKey', + defaultMessage: 'Please enter a Private Key.', + description: 'Helper message displayed against the option to enter a Private Key.', + }, + odataCompanyId: { + id: 'adminPortal.settings.ssoConfigConfigureStep.odataCompanyId', + defaultMessage: 'Please enter a Company ID.', + description: 'Helper message displayed against the option to enter a Company ID.', + }, + oauthUserId: { + id: 'adminPortal.settings.ssoConfigConfigureStep.oauthUserId', + defaultMessage: 'Please enter an OAuth User ID.', + description: 'Helper message displayed against the option to enter an OAuth User ID.', + }, +}); + const isSAPConfig = (fields) => fields.identityProvider === 'sap_success_factors'; -export const validations: FormFieldValidation[] = [ +export const getValidations = (intl) : FormFieldValidation[] => [ { formFieldId: 'sapsfOauthRootUrl', - validator: (fields) => isSAPConfig(fields) && (!fields.sapsfOauthRootUrl || !urlValidation(fields.sapsfOauthRootUrl)) && 'Please enter an OAuth Root URL.', + validator: (fields) => isSAPConfig(fields) && ( + !fields.sapsfOauthRootUrl || !urlValidation(fields.sapsfOauthRootUrl) + ) && intl.formatMessage(messages.sapsfOauthRootUrl), }, { formFieldId: 'odataApiRootUrl', - validator: (fields) => isSAPConfig(fields) && (!fields.odataApiRootUrl || !urlValidation(fields.odataApiRootUrl)) && 'Please enter an API Root URL.', + validator: (fields) => isSAPConfig(fields) && ( + !fields.odataApiRootUrl || !urlValidation(fields.odataApiRootUrl) + ) && intl.formatMessage(messages.odataApiRootUrl), }, { formFieldId: 'sapsfPrivateKey', - validator: (fields) => isSAPConfig(fields) && !fields.sapsfPrivateKey && 'Please enter a Private Key.', + validator: (fields) => ( + isSAPConfig(fields) && !fields.sapsfPrivateKey + ) && intl.formatMessage(messages.sapsfPrivateKey), }, { formFieldId: 'odataCompanyId', - validator: (fields) => isSAPConfig(fields) && !fields.odataCompanyId && 'Please enter a Company ID.', + validator: (fields) => isSAPConfig(fields) && !fields.odataCompanyId && intl.formatMessage(messages.odataCompanyId), }, { formFieldId: 'oauthUserId', - validator: (fields) => isSAPConfig(fields) && !fields.oauthUserId && 'Please enter an OAuth User ID.', + validator: (fields) => isSAPConfig(fields) && !fields.oauthUserId && intl.formatMessage(messages.oauthUserId), }, ]; @@ -44,71 +79,139 @@ const SSOConfigConfigureStep = () => { allSteps, stateMap, }: FormContext = useFormContext(); + const intl = useIntl(); const usingSAP = formFields?.identityProvider === 'sap_success_factors'; const renderBaseFields = () => ( <> -

    Enter user attributes

    +

    + +

    - Please enter the SAML user attributes from your Identity Provider. - All attributes are space and case sensitive. +

    @@ -116,30 +219,60 @@ const SSOConfigConfigureStep = () => { const renderSAPFields = () => ( <> -

    Enable learner account auto-registration

    +

    + +

    @@ -148,16 +281,32 @@ const SSOConfigConfigureStep = () => { type="text" as="textarea" rows={4} - floatingLabel="Private Key" - fieldInstructions="The Private Key value found in the PEM file generated from the OAuth2 Client Application Profile." + floatingLabel={intl.formatMessage({ + id: 'adminPortal.settings.ssoConfigConfigureStep.sapsfPrivateKey.label', + defaultMessage: 'Private Key', + description: 'Helper message displayed against the option to enter a Private Key.', + })} + fieldInstructions={intl.formatMessage({ + id: 'adminPortal.settings.ssoConfigConfigureStep.sapsfPrivateKey.instructions', + defaultMessage: 'The Private Key value found in the PEM file generated from the OAuth2 Client Application Profile.', + description: 'Instructions for the Private Key input field.', + })} /> @@ -171,11 +320,16 @@ const SSOConfigConfigureStep = () => { }; return ( -
    -

    Enter integration details

    +

    + +

    {stateMap?.[FORM_ERROR_MESSAGE] === RECORD_UNDER_CONFIGURATIONS_ERROR && ( { className="ml-3" onClick={returnToConnectStep} > - Record under configuration + , ]} className="mt-3 mb-3" @@ -192,10 +350,19 @@ const SSOConfigConfigureStep = () => { stacked icon={Info} > - Configuration Error + + +

    - Your record was recently submitted for configuration and must completed before you can resubmit. Please - check back in a few minutes. If the problem persists, contact enterprise customer support. +

    )} @@ -207,7 +374,11 @@ const SSOConfigConfigureStep = () => { className="ml-3" onClick={returnToConnectStep} > - Return to Connect step + , ]} className="mt-3 mb-3" @@ -215,22 +386,45 @@ const SSOConfigConfigureStep = () => { stacked icon={Info} > - Metadata Error + + +

    - Please return to the “Connect” step and verify that your metadata URL or metadata file is correct. After - verifying, please try again. If the problem persists, contact enterprise customer support. +

    )} -

    Set display name

    +

    + +

    diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx index 8499aca344..2db5dc6fc9 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx @@ -3,6 +3,7 @@ import { Alert, Hyperlink, OverlayTrigger, Popover, } from '@openedx/paragon'; import { Info } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; const IncognitoPopover = () => ( ( overlay={( - Steps to open a new window in incognito mode (also known as private mode) - may vary based on the browser you are using. - Review your browser's help documentation as needed. + )} > - incognito window + + + ); const SSOConfigConfirmStep = () => ( <> -

    Wait for SSO configuration confirmation

    +

    + +

    -

    Action required from email

    - Great news! You have completed the configuration steps, edX is actively configuring your SSO connection. - You will receive an email within about five minutes when the configuration is complete. - The email will include instructions for testing. +

    + +

    +

    -

    What to expect:

    +

    + +

      -
    • SSO configuration confirmation email.
    • +
    • + +
      • -
      • Testing instructions involve copying and pasting a custom URL into an
      • -
      • A link back to the SSO Settings page
      • +
      • + +
      • +
      • + +

    - Select the "Finish" button below or close this form via the - {' '}"X" in the upper right corner while you wait for your - configuration email. Your SSO testing status will display on the following SSO settings screen. + "Finish", + xButtonText: "X", + }} + />

    ); diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx index 2cbb193d2b..97aa7b10c1 100644 --- a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; import { - Container, Dropzone, Form, Stack, + Container, Dropzone, Form, } from '@openedx/paragon'; +import { defineMessages, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import ValidatedFormRadio from '../../../forms/ValidatedFormRadio'; import ValidatedFormControl from '../../../forms/ValidatedFormControl'; import { FormContext, FormFieldValidation, useFormContext } from '../../../forms/FormContext'; @@ -14,50 +15,90 @@ export const IDP_XML_SELECTION = 'idp_metadata_xml'; const urlEntrySelected = (formFields) => formFields?.idpConnectOption === IDP_URL_SELECTION; const xmlEntrySelected = (formFields) => formFields?.idpConnectOption === IDP_XML_SELECTION; -export const validations: FormFieldValidation[] = [ +const messages = defineMessages({ + identityProvider: { + id: 'adminPortal.settings.ssoConfigConnectStep.identityProvider', + defaultMessage: 'Please select an SSO Identity Provider', + description: 'Helper message displayed against the option to select an SSO Identity Provider.', + }, + idpConnectOption: { + id: 'adminPortal.settings.ssoConfigConnectStep.idpConnectOption', + defaultMessage: 'Please select a connection method', + description: 'Helper message displayed against the option to select a connection method.', + }, + metadataUrl: { + id: 'adminPortal.settings.ssoConfigConnectStep.metadataUrl', + defaultMessage: 'Please enter an Identity Provider Metadata URL', + description: 'Helper message displayed against the option to enter an Identity Provider Metadata URL.', + }, + metadataXml: { + id: 'adminPortal.settings.ssoConfigConnectStep.metadataXml', + defaultMessage: 'Please upload an Identity Provider Metadata XML file', + description: 'Helper message displayed against the option to upload an Identity Provider Metadata XML file.', + }, + other: { + id: 'adminPortal.settings.ssoConfigConnectStep.other', + defaultMessage: 'Other', + description: 'Other identity provider option.', + }, + enterMetadataUrl: { + id: 'adminPortal.settings.ssoConfigConnectStep.enterMetadataUrl', + defaultMessage: 'Enter identity Provider Metadata URL', + description: 'Option to enter Identity Provider Metadata URL.', + }, + uploadMetadataXml: { + id: 'adminPortal.settings.ssoConfigConnectStep.uploadMetadataXml', + defaultMessage: 'Upload Identity Provider Metadata XML file', + description: 'Option to upload Identity Provider Metadata XML file.', + }, + invalidType: { + id: 'adminPortal.settings.ssoConfigConnectStep.invalidType', + defaultMessage: 'Invalid file type, only xml images allowed.', + description: 'Error message displayed when an invalid file type is uploaded.', + }, + invalidSize: { + id: 'adminPortal.settings.ssoConfigConnectStep.invalidSize', + defaultMessage: 'The file size must be under 5 gb.', + description: 'Error message displayed when the uploaded file size exceeds the limit.', + }, + multipleDragged: { + id: 'adminPortal.settings.ssoConfigConnectStep.multipleDragged', + defaultMessage: 'Cannot upload more than one file.', + description: 'Error message displayed when more than one file is uploaded.', + }, +}); + +export const getValidations = (intl) : FormFieldValidation[] => [ { formFieldId: 'identityProvider', - validator: (fields) => !fields.identityProvider && 'Please select an SSO Identity Provider', + validator: (fields) => !fields.identityProvider && intl.formatMessage(messages.identityProvider), }, { formFieldId: 'idpConnectOption', - validator: (fields) => !fields.idpConnectOption && 'Please select a connection method', + validator: (fields) => !fields.idpConnectOption && intl.formatMessage(messages.idpConnectOption), }, { formFieldId: 'metadataUrl', validator: (fields) => { const error = urlEntrySelected(fields) && !urlValidation(fields.metadataUrl); - return error && 'Please enter an Identity Provider Metadata URL'; + return error && intl.formatMessage(messages.metadataUrl); }, }, { formFieldId: 'metadataXml', validator: (fields) => { const error = !fields.metadataXml && xmlEntrySelected(fields); - return error && 'Please upload an Identity Provider Metadata XML file'; + return error && intl.formatMessage(messages.metadataXml); }, }, ]; const SSOConfigConnectStep = () => { - const fiveGbInBytes = 5368709120; - const ssoIdpOptions = [ - ['Microsoft Entra ID', 'microsoft_entra_id'], - ['Google Workspace', 'google_workspace'], - ['Okta', 'okta'], - ['OneLogin', 'one_login'], - ['SAP SuccessFactors', 'sap_success_factors'], - ['Other', 'other'], - ]; - const idpConnectOptions = [ - ['Enter identity Provider Metadata URL', IDP_URL_SELECTION], - ['Upload Identity Provider Metadata XML file', IDP_XML_SELECTION], - ]; - const { formFields, dispatch, showErrors, errorMap, }: FormContext = useFormContext(); const [xmlUploadFileName, setXmlUploadFileName] = useState(''); + const intl = useIntl(); const showUrlEntry = urlEntrySelected(formFields); const showXmlUpload = xmlEntrySelected(formFields); const xmlUploadError = errorMap?.metadataXml; @@ -70,13 +111,37 @@ const SSOConfigConnectStep = () => { }); }; + const fiveGbInBytes = 5368709120; + const ssoIdpOptions = [ + ['Microsoft Entra ID', 'microsoft_entra_id'], + ['Google Workspace', 'google_workspace'], + ['Okta', 'okta'], + ['OneLogin', 'one_login'], + ['SAP SuccessFactors', 'sap_success_factors'], + [intl.formatMessage(messages.other), 'other'], + ]; + const idpConnectOptions = [ + [intl.formatMessage(messages.enterMetadataUrl), IDP_URL_SELECTION], + [intl.formatMessage(messages.uploadMetadataXml), IDP_XML_SELECTION], + ]; + return ( - - -

    Let's get started

    -

    What is your organization's SSO Identity Provider?

    +

    + +

    +

    + +

    { options={ssoIdpOptions} /> - -

    Connect edX to your Identity Provider

    -

    Select a method to connect edX to your Identity Provider

    +

    + +

    +

    + +

    { )} @@ -112,9 +196,9 @@ const SSOConfigConnectStep = () => { { /> {xmlUploadFileName && ( - Uploaded{' '} - {xmlUploadFileName} + )} {showErrors && xmlUploadError && {xmlUploadError}} diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx index 887ade767c..47b3e0dcb8 100644 --- a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx @@ -1,14 +1,15 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; -import { act, render, screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import { SSOConfigContext, SSO_INITIAL_STATE } from '../SSOConfigContext'; import NewSSOConfigCard from '../NewSSOConfigCard'; import LmsApiService from '../../../../data/services/LmsApiService'; +import { renderWithI18nProvider } from '../../../test/testUtils'; describe('New SSO Config Card Tests', () => { test('displays enabled and validated status icon properly', async () => { - render( + renderWithI18nProvider( { ).toBeInTheDocument(); }); test('displays not validated status icon properly', async () => { - render( + renderWithI18nProvider( { ).toBeInTheDocument(); }); test('displays key off icon status icon properly', async () => { - render( + renderWithI18nProvider( { ).toBeInTheDocument(); }); test('displays badges properly', async () => { - render( + renderWithI18nProvider( { 'existing-sso-config-card-badge-in-progress', ), ).toBeInTheDocument(); - render( + renderWithI18nProvider( { setProviderConfig: mockSetProviderConfig, setRefreshBool: jest.fn(), }; - render( + renderWithI18nProvider( { expect(mockSetProviderConfig).toHaveBeenCalled(); }); test('displays enable button properly', async () => { - render( + renderWithI18nProvider( { test('handles kebob Delete dropdown option', async () => { const spy = jest.spyOn(LmsApiService, 'deleteEnterpriseSsoOrchestrationRecord'); spy.mockImplementation(() => Promise.resolve({})); - render( + renderWithI18nProvider( { test('handles kebob Disable dropdown option', async () => { const spy = jest.spyOn(LmsApiService, 'updateEnterpriseSsoOrchestrationRecord'); spy.mockImplementation(() => Promise.resolve({})); - render( + renderWithI18nProvider( { const mockGetProviderConfig = jest.spyOn(LmsApiService, 'getProviderConfig'); mockGetProviderConfig.mockResolvedValue({ data: { result: [{ woohoo: 'success!' }] } }); contextValue.ssoState.currentStep = 'connect'; - render( + renderWithI18nProvider( { }); test('canceling service provider step', async () => { contextValue.ssoState.currentStep = 'serviceprovider'; - render( + renderWithI18nProvider( { }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { test('update config method does not make api call if form is not updated', async () => { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { throw new Error({ response: { data: 'foobar' } }); }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { test('canceling without saving configure form', async () => { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); mockUpdateProviderConfig.mockResolvedValue('success!'); - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); mockUpdateProviderConfig.mockResolvedValue({ data: { result: [{ woohoo: 'ayylmao!' }] } }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { test('idp completed check for url entry', async () => { // Setup contextValue.ssoState.currentStep = 'idp'; - render( + renderWithI18nProvider( { { data: { results: [{ entity_id: 'ayylmao!', public_key: '123abc!', sso_url: 'https://ayylmao.com' }] } }, ); contextValue.ssoState.currentStep = 'idp'; - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); mockUpdateProviderConfig.mockResolvedValue({ data: { result: [{ woohoo: 'ayylmao!' }] } }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); mockUpdateProviderConfig.mockResolvedValue({ data: { result: [{ woohoo: 'ayylmao!' }] } }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( { const mockUpdateProviderConfig = jest.spyOn(LmsApiService, 'updateProviderConfig'); mockUpdateProviderConfig.mockResolvedValue({ data: { result: [{ woohoo: 'ayylmao!' }] } }); contextValue.ssoState.currentStep = 'configure'; - render( + renderWithI18nProvider( ( - + + + ); diff --git a/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx b/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx index a65f515723..d7fca0abcc 100644 --- a/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx @@ -13,7 +13,7 @@ import { HELP_CENTER_SAML_LINK } from '../../data/constants'; import { features } from '../../../../config'; import SettingsSSOTab from '..'; import LmsApiService from '../../../../data/services/LmsApiService'; -import { queryClient } from '../../../test/testUtils'; +import { queryClient, renderWithI18nProvider } from '../../../test/testUtils'; const enterpriseId = 'an-id-1'; jest.mock('../../../../data/services/LmsApiService'); @@ -42,7 +42,7 @@ describe('SAML Config Tab', () => { LmsApiService.getProviderConfig.mockImplementation(() => ( { data: { results: [] } } )); - render( + renderWithI18nProvider( , @@ -58,7 +58,7 @@ describe('SAML Config Tab', () => { LmsApiService.getProviderConfig.mockImplementation(() => ( { data: { results: [] } } )); - render( + renderWithI18nProvider( , @@ -71,7 +71,7 @@ describe('SAML Config Tab', () => { LmsApiService.getProviderConfig.mockImplementation(() => ( { data: { results: [{ was_valid_at: '10/10/22' }] } } )); - render( + renderWithI18nProvider( , diff --git a/src/components/settings/SettingsTabs.jsx b/src/components/settings/SettingsTabs.jsx index e39a4f2a2d..55014ae4b5 100644 --- a/src/components/settings/SettingsTabs.jsx +++ b/src/components/settings/SettingsTabs.jsx @@ -11,10 +11,15 @@ import { import { connect } from 'react-redux'; import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useCurrentSettingsTab } from './data/hooks'; import { + ACCESS_TAB, + LMS_TAB, + SSO_TAB, + APPEARANCE_TAB, + API_CREDENTIALS_TAB, SCHOLAR_THEME, - SETTINGS_TAB_LABELS, SETTINGS_TABS_VALUES, SETTINGS_TAB_PARAM, } from './data/constants'; @@ -26,6 +31,34 @@ import SettingsApiCredentialsTab from './SettingsApiCredentialsTab'; import { features } from '../../config'; import { updatePortalConfigurationEvent } from '../../data/actions/portalConfiguration'; +const messages = defineMessages({ + [ACCESS_TAB]: { + id: 'adminPortal.settings.accessTab.label', + defaultMessage: 'Configure Access', + description: 'Label for the access tab in the settings page.', + }, + [LMS_TAB]: { + id: 'adminPortal.settings.lmsTab.label', + defaultMessage: 'Learning Platform', + description: 'Label for the learning platform tab in the settings page.', + }, + [SSO_TAB]: { + id: 'adminPortal.settings.ssoTab.label', + defaultMessage: 'Single Sign On (SSO)', + description: 'Label for the SSO tab in the settings page.', + }, + [APPEARANCE_TAB]: { + id: 'adminPortal.settings.appearanceTab.label', + defaultMessage: 'Portal Appearance', + description: 'Label for the appearance tab in the settings page.', + }, + [API_CREDENTIALS_TAB]: { + id: 'adminPortal.settings.apiCredentialsTab.label', + defaultMessage: 'API Credentials', + description: 'Label for the API credentials tab in the settings page.', + }, +}); + const SettingsTabs = ({ enterpriseId, enterpriseSlug, @@ -55,9 +88,9 @@ const SettingsTabs = ({ if (enableLearnerPortal) { initialTabs.push( } > } > } > } > } > ', () => { test('SSO tab is not rendered if FEATURE_SSO_SETTINGS_TAB = false', () => { features.FEATURE_SSO_SETTINGS_TAB = false; render(); - expect(screen.queryByText(SETTINGS_TAB_LABELS.sso)).not.toBeInTheDocument(); + expect(screen.queryByText('Single Sign On (SSO)')).not.toBeInTheDocument(); }); test('Appearance tab is not rendered if FEATURE_SETTING_PAGE_APPEARANCE_TAB = false', () => { features.SETTINGS_PAGE_APPEARANCE_TAB = false; render(); - expect(screen.queryByText(SETTINGS_TAB_LABELS.appearance)).not.toBeInTheDocument(); + expect(screen.queryByText('Portal Appearance')).not.toBeInTheDocument(); }); test('Clicking on a tab changes content via router', async () => { render(); - const lmsTab = screen.getByText(SETTINGS_TAB_LABELS.lms); + const lmsTab = screen.getByText('Learning Platform'); await act(async () => { userEvent.click(lmsTab); }); expect(screen.queryByText(LMS_MOCK_CONTENT)).toBeTruthy(); }); test('Clicking on default tab does not change content', async () => { render(); - const accessTab = screen.getByText(SETTINGS_TAB_LABELS.access); + const accessTab = screen.getByText('Configure Access'); await act(async () => { userEvent.click(accessTab); }); expect(screen.queryByText(ACCESS_MOCK_CONTENT)).toBeTruthy(); }); diff --git a/src/components/test/testUtils.jsx b/src/components/test/testUtils.jsx index ea7d4fe7b0..ed9fa3c605 100644 --- a/src/components/test/testUtils.jsx +++ b/src/components/test/testUtils.jsx @@ -4,6 +4,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import { render, screen as rtlScreen } from '@testing-library/react'; import { QueryCache, QueryClient } from '@tanstack/react-query'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { queryCacheOnErrorHandler } from '../../utils'; // TODO: this could likely be replaced by `renderWithRouter` from `@edx/frontend-enterprise-utils`. @@ -58,3 +59,9 @@ export function queryClient(options = {}) { }, }); } + +export const renderWithI18nProvider = (children) => renderWithRouter( + + {children} + , +);