From 12072cdd2e9bf5f8390333647294b75f433d73d5 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 16 Oct 2024 23:42:17 +1100 Subject: [PATCH] feat: create a Modal to inform the update uf terms of service --- README.rst | 56 +++++++ src/components/Footer.jsx | 13 +- src/components/modal-tos/ModalToS.jsx | 171 +++++++++++++++++++++ src/components/modal-tos/ModalToS.test.jsx | 163 ++++++++++++++++++++ src/components/modal-tos/data/api.js | 28 ++++ src/components/modal-tos/data/constants.js | 13 ++ src/components/modal-tos/index.jsx | 3 + src/i18n/messages/pt_PT.json | 8 +- 8 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 src/components/modal-tos/ModalToS.jsx create mode 100644 src/components/modal-tos/ModalToS.test.jsx create mode 100644 src/components/modal-tos/data/api.js create mode 100644 src/components/modal-tos/data/constants.js create mode 100644 src/components/modal-tos/index.jsx diff --git a/README.rst b/README.rst index 66dcc6dc5..befd56e9e 100644 --- a/README.rst +++ b/README.rst @@ -43,6 +43,14 @@ This component requires that the following environment variable be set by the co * ``LMS_BASE_URL`` - The URL of the LMS of your Open edX instance. * ``LOGO_TRADEMARK_URL`` - This is a URL to a logo for use in the footer. This is a different environment variable than ``LOGO_URL`` (used in frontend-component-header) to accommodate sites that would like to have additional trademark information on a logo in the footer, such as a (tm) or (r) symbol. +Optionally, use the following variables to configure the Terms of Service Modal for the MFEs: + +* ``MODAL_UPDATE_TERMS_OF_SERVICE`` - Object that reppresent the text and checkbox configured for the TOS Modal +* ``PRIVACY_POLICY_URL`` - The URL for the privacy policy. +* ``TERMS_OF_SERVICE_URL`` - The URL for the terms of service. +* ``TOS_AND_HONOR_CODE`` - The URL for the honor code. + + Installation ============ @@ -93,6 +101,54 @@ This library has the following exports: language from its dropdown. * supportedLanguages: An array of objects representing available languages. See example below for object shape. +Terms of Service Modal +======================= + +The Terms of Service Modal allows configuring a modal that prompts users to accept updated terms and conditions, +including Data Authorization, Terms of Service, and/or an Honor Code. + +To configure this modal, use either the MFE build-time configuration (via ``.env``, ``.env.config.js``) or the +runtime MFE Config API to set the MODAL_UPDATE_TERMS_OF_SERVICE object. Example: + +.. code-block:: python + + MFE_CONFIG["MODAL_UPDATE_TERMS_OF_SERVICE"] = { + "date_iso_8601": "2025-06-08", + "title": { + "en": "English modal title", + "pt-pt": "Portuguese modal title" + }, + "body": { + "en": "English modal text", + "pt-pt": "Portuguese modal text" + }, + "data_authorization": true, + "terms_of_service": true, + "honor_code": true, + } + +Where: +* **date_iso_8601** *(required)*: This is a required field representing the date of the terms of service update +in ISO 8601 format. +It is used to track whether the user has accepted the new terms since the last update. +* **title** *(optional)*: It is an object that provides the modal title text for different languages. +* **body** *(optional)*: It is an object that provides the body content of the modal for different languages. +* **data_authorization** *(optional)*: Boolean that determines whether the Privacy Policy checkbox should be +displayed in the modal. +* **terms_of_service** *(optional)*: Boolean that controls whether the Terms of Service checkbox should be +shown in the modal. +* **honor_code** *(optional)*: Boolean that specifies whether the Honor Code checkbox should be displayed +in the modal. + +The modal conditions: + +* The modal will be displayed if the user has not yet accepted the latest terms and conditions as defined +by date_iso_8601. +* If any of the optional fields (data_authorization, terms_of_service, honor_code) are not specified, the +corresponding checkboxes will not appear in the modal. +The modal is multilingual, and the content for both the title and body can be customized for different +locales using language keys like en (English), pt-pt (Portuguese), etc. + Plugin ====== The footer can be replaced using using `Frontend Plugin Framework `_. diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 4e0f7a70c..7c290f2b1 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -2,14 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { + APP_CONFIG_INITIALIZED, ensureConfig, getConfig, subscribe, +} from '@edx/frontend-platform'; import { AppContext } from '@edx/frontend-platform/react'; +import { hydrateAuthenticatedUser } from '@edx/frontend-platform/auth'; import messages from './Footer.messages'; import LanguageSelector from './LanguageSelector'; import FooterLinks from './footer-links/FooterNavLinks'; import FooterSocial from './footer-links/FooterSocialLinks'; import parseEnvSettings from '../utils/parseData'; +import ModalToS from './modal-tos'; ensureConfig([ 'LMS_BASE_URL', @@ -134,6 +138,9 @@ class SiteFooter extends React.Component { + { + config.MODAL_UPDATE_TERMS_OF_SERVICE && + } ); } @@ -157,5 +164,9 @@ SiteFooter.defaultProps = { supportedLanguages: [], }; +subscribe(APP_CONFIG_INITIALIZED, async () => { + await hydrateAuthenticatedUser(); +}); + export default injectIntl(SiteFooter); export { EVENT_NAMES }; diff --git a/src/components/modal-tos/ModalToS.jsx b/src/components/modal-tos/ModalToS.jsx new file mode 100644 index 000000000..91069df35 --- /dev/null +++ b/src/components/modal-tos/ModalToS.jsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from 'react'; + +import { convertKeyNames, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { FormattedMessage, getLocale, injectIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Form, Hyperlink, ModalDialog, useToggle, useCheckboxSetValues, + ActionRow, +} from '@openedx/paragon'; + +import { getUserTOSPreference, updateUserTOSPreference } from './data/api'; +import { CAMEL_CASE_KEYS } from './data/constants'; +import parseEnvSettings from '../../utils/parseData'; + +const createTOSLink = (chunks, url) => ( + {chunks} + +); + +const ModalToS = () => { + const [tosPreference, setTosPreference] = useState(undefined); + const [isOpen, open, close] = useToggle(false); + + const { + MODAL_UPDATE_TERMS_OF_SERVICE, + PRIVACY_POLICY_URL, + SITE_NAME, + TERMS_OF_SERVICE_URL, + TOS_AND_HONOR_CODE, + } = getConfig(); + + const modalSettings = parseEnvSettings(MODAL_UPDATE_TERMS_OF_SERVICE) || MODAL_UPDATE_TERMS_OF_SERVICE || {}; + const { + body = {}, + title = {}, + dateIso8601, + dataAuthorization = false, + honorCode = false, + termsOfService = false, + } = convertKeyNames(modalSettings, CAMEL_CASE_KEYS); + + const { + dateJoined, + username, + } = getAuthenticatedUser(); + + const lang = getLocale() || 'en'; + const tosKey = `update_terms_of_service_${dateIso8601?.replaceAll('-', '_')}`; + const [checkboxValues, { add, remove }] = useCheckboxSetValues([]); + + useEffect(() => { + if (username && dateIso8601) { + getUserTOSPreference(username, tosKey).then(userTos => { + setTosPreference(userTos); + if (userTos === null) { + open(); + } + }); + } + }, [dateIso8601, tosKey, username, open]); + + const setAcceptance = () => { + updateUserTOSPreference(username, tosKey); + close(); + }; + + const numCheckBox = [dataAuthorization, termsOfService, honorCode] + .reduce((prev, curr) => (curr ? prev + 1 : prev), 0); + + const handleChange = e => { + if (e.target.checked) { + add(e.target.value); + } else { + remove(e.target.value); + } + }; + + if (tosPreference || !dateIso8601 || !username + || new Date(dateIso8601) < new Date(dateJoined)) { + return null; + } + + return ( + + {title[lang] && ( + + + {title[lang]} + + + )} + + {body[lang]} +
+ + {dataAuthorization + && ( + + createTOSLink(chunks, PRIVACY_POLICY_URL), + }} + /> + + )} + {termsOfService + && ( + + createTOSLink(chunks, TERMS_OF_SERVICE_URL), + platformName: SITE_NAME, + }} + /> + + )} + {honorCode + && ( + + createTOSLink(chunks, TOS_AND_HONOR_CODE), + platformName: SITE_NAME, + }} + /> + + )} + +
+ + + +
+
+ ); +}; + +export default injectIntl(ModalToS); diff --git a/src/components/modal-tos/ModalToS.test.jsx b/src/components/modal-tos/ModalToS.test.jsx new file mode 100644 index 000000000..21292e235 --- /dev/null +++ b/src/components/modal-tos/ModalToS.test.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { IntlProvider, getLocale } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import '@testing-library/jest-dom'; + +import { + render, fireEvent, screen, waitFor, act, +} from '@testing-library/react'; +import { getUserTOSPreference, updateUserTOSPreference } from './data/api'; + +import ModalToS from '.'; + +jest.mock('./data/api'); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + getLocale: jest.fn(), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedUser: jest.fn(), +})); + +const mockUser = { + username: 'test_user', + dateJoined: '2023-10-01T00:00:00Z', +}; + +const messagesPt = { + 'modalToS.dataAuthorization.checkbox.label': 'Li e compreendi a Política de Privacidade', + 'modalToS.termsOfService.checkbox.label': 'Li e compreendi o {platformName} Termos e Condições', + 'modalToS.honorCode.checkbox.label': 'Li e compreendi o {platformName} Honor Code', + 'modalToS.acceptance.button': 'Aceito os novos termos de serviço', +}; +// eslint-disable-next-line react/prop-types +const Component = ({ locale = 'en', messages }) => (); + +describe('ModalTOS Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + getAuthenticatedUser.mockReturnValue(mockUser); + + mergeConfig({ + MODAL_UPDATE_TERMS_OF_SERVICE: { + title: { + 'pt-pt': 'Atenção', + en: 'Attention', + }, + body: { + 'pt-pt': 'A informação legal para uso do serviço foi atualizada.', + en: 'The legal information for using the service has been updated.', + }, + date_iso_8601: '2023-11-10', + data_authorization: true, + terms_of_service: true, + honor_code: false, + }, + TERMS_OF_SERVICE_URL: '/terms', + PRIVACY_POLICY_URL: '/privacy', + TOS_AND_HONOR_CODE: '/honor-code', + }); + }); + + test('does not render the modal if MODAL_UPDATE_TERMS_OF_SERVICE is not configured', async () => { + mergeConfig({ + MODAL_UPDATE_TERMS_OF_SERVICE: '', + }); + + getUserTOSPreference.mockResolvedValue(null); // Simulate user hasn't accepted yet + + render(); + + // Wait for any possible modal render (if it were to render) + await waitFor(() => { + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); + + // Assert that the modal does not appear + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); + + test('renders the modal with configured checkboxes when user have not accept the new terms of service', async () => { + getUserTOSPreference.mockResolvedValue(null); + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + // Check if the modal is rendered with the correct checkboxes + expect(screen.getByText(/Attention/i)).toBeInTheDocument(); + expect(document.querySelectorAll('input[type="checkbox"]').length).toBe(2); + }); + + test('renders the modal in the correct language based on the user’s cookie', async () => { + getUserTOSPreference.mockResolvedValue(null); + getLocale.mockReturnValue('pt-pt'); + + render(); + + // Wait until the modal renders + await waitFor(() => screen.getByText(/Atenção/i)); + + // Check that the title and body are rendered in Portuguese + expect(screen.getByText(/Atenção/i)).toBeInTheDocument(); + expect(screen.getByText(/Aceito os novos termos de serviço/i)).toBeInTheDocument(); + }); + + test('disables the button until all checkboxes are checked', async () => { + getUserTOSPreference.mockResolvedValue(null); + + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + const button = screen.getByRole('button', { name: /Accept new terms of service/i }); + expect(button).toBeDisabled(); + + const privacyCheckbox = screen.getByLabelText(/Privacy Policy/i); + const termsCheckbox = screen.getByLabelText(/Terms of Service/i, { selector: 'input' }); + + fireEvent.click(privacyCheckbox); // Click first checkbox + expect(button).toBeDisabled(); // Button should still be disabled + + fireEvent.click(termsCheckbox); // Click second checkbox + expect(button).toBeEnabled(); // Button should now be enabled + }); + + test('calls updateUserTOSPreference and closes modal when clicking Accept', async () => { + getUserTOSPreference.mockResolvedValue(null); + + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + const privacyCheckbox = screen.getByLabelText(/Privacy Policy/i); + const termsCheckbox = screen.getByLabelText(/Terms of Service/i, { selector: 'input' }); + + // Check all checkboxes + fireEvent.click(privacyCheckbox); + fireEvent.click(termsCheckbox); + + const button = screen.getByRole('button', { name: /Accept new terms of service/i }); + fireEvent.click(button); // Click the "Accept" button + + // Check that the API call is made with the correct arguments + expect(updateUserTOSPreference).toHaveBeenCalledWith(mockUser.username, 'update_terms_of_service_2023_11_10'); + + // Wait until modal closes (assert that the modal content is removed from the DOM) + await waitFor(() => expect(screen.queryByText(/Attention/i)).not.toBeInTheDocument()); + }); + + it('does not render the modal if the user already accept the terms of service', async () => { + getUserTOSPreference.mockResolvedValue('True'); // Simulate user has already accepted TOS + + await act(async () => { + render(); + }); + + // Assert that the modal does not appear + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); +}); diff --git a/src/components/modal-tos/data/api.js b/src/components/modal-tos/data/api.js new file mode 100644 index 000000000..748da513c --- /dev/null +++ b/src/components/modal-tos/data/api.js @@ -0,0 +1,28 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { ACCEPTANCE_TOS } from './constants'; + +const preferencesUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/`; + +export const getUserTOSPreference = async (username, tosKey) => { + try { + const { data } = await getAuthenticatedHttpClient().get(`${preferencesUrl()}${username}/${tosKey}`); + return data; + } catch (error) { + if (error.customAttributes.httpErrorStatus === 404) { return null; } + throw error; + } +}; + +export const updateUserTOSPreference = async (username, tosKey) => { + try { + const response = await getAuthenticatedHttpClient().put(`${preferencesUrl()}${username}/${tosKey}`, ACCEPTANCE_TOS, { + headers: { + 'Content-Type': 'application/json', + }, + }); + return response; + } catch (error) { + return null; + } +}; diff --git a/src/components/modal-tos/data/constants.js b/src/components/modal-tos/data/constants.js new file mode 100644 index 000000000..8291615cd --- /dev/null +++ b/src/components/modal-tos/data/constants.js @@ -0,0 +1,13 @@ +const ACCEPTANCE_TOS = 'True'; + +const CAMEL_CASE_KEYS = { + date_iso_8601: 'dateIso8601', + data_authorization: 'dataAuthorization', + honor_code: 'honorCode', + terms_of_service: 'termsOfService', +}; + +export { + ACCEPTANCE_TOS, + CAMEL_CASE_KEYS, +}; diff --git a/src/components/modal-tos/index.jsx b/src/components/modal-tos/index.jsx new file mode 100644 index 000000000..bd52b89d8 --- /dev/null +++ b/src/components/modal-tos/index.jsx @@ -0,0 +1,3 @@ +import ModalToS from './ModalToS'; + +export default ModalToS; diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index be3f12816..e3efa774b 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1,5 +1,9 @@ { "footer.languageForm.select.label": "Escolha a língua", "footer.languageForm.submit.label": "Aplicar", - "footer.copyright.message": "Todos os direitos reservados." -} \ No newline at end of file + "footer.copyright.message": "Todos os direitos reservados.", + "modalToS.dataAuthorization.checkbox.label": "Li e compreendi a Política de Privacidade", + "modalToS.termsOfService.checkbox.label": "Li e compreendi o {platformName} Termos e Condições", + "modalToS.honorCode.checkbox.label": "Li e compreendi o {platformName} Honor Code", + "modalToS.acceptance.button": "Aceito os novos termos de serviço" +}