Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Smith
0cb95abe14 fix: import FooterSlot from frontend-slot-footer package 2024-06-06 15:05:06 -03:00
Brian Smith
4b4ba11932 feat: use frontend-plugin-framework to provide a FooterSlot 2024-06-06 15:05:06 -03:00
37 changed files with 12942 additions and 10641 deletions

2
.env
View File

@@ -21,5 +21,3 @@ USER_INFO_COOKIE_NAME=''
SCHEDULE_EMAIL_SECTION=''
APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -22,5 +22,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -20,4 +20,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

View File

@@ -18,9 +18,9 @@ jobs:
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
@@ -34,7 +34,4 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
uses: codecov/codecov-action@v3

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
uses: openedx/.github/.github/workflows/lockfile-check.yml@master

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@ node_modules
npm-debug.log
coverage
module.config.js
env.config.*
dist/
src/i18n/transifex_input.json

3
.nvmrc
View File

@@ -1 +1,2 @@
24
18

View File

@@ -37,10 +37,11 @@ pull_translations:
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-platform frontend-app-communications
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-lib-content-components frontend-platform frontend-app-communications
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,5 +1,5 @@
frontend-app-communications
###########################
#############################
|license-badge| |status-badge| |ci-badge| |codecov-badge|
@@ -7,56 +7,52 @@ frontend-app-communications
Purpose
*******
A tool used by course teams to communicate with their learners. The interface for anything related to instructor-to-learner communications. Instructor bulk email, for example.
A tool used by course teams to communicate with their learners. The interface for anything related to instructor to learner communications. Instructor bulk email, for example.
Getting started
---------------
------------
For now, this repo is not integrated with devstack. You'll be running the app locally and not through docker. This does make setup a little easier.
For now, this repo is not intergrated with devstack. You'll be running the app locally and not through docker. This does make setup a little easier.
Cloning and Startup
===================
1. Clone your new repo:
1. Clone your new repo:
.. code-block:: bash
``git clone https://github.com/edx/frontend-app-communications.git``
git clone https://github.com/edx/frontend-app-communications.git
2. Use node v18.x.
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
The current version of the micro-frontend build scripts supports node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes a ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
3. Install npm dependencies:
``cd frontend-app-communications && npm install``
.. code-block:: bash
4. Update the application port to use for local development:
cd frontend-app-communications && npm install
Default port is 1984. If this does not work for you, update the line
`PORT=1984` to your port in all .env.* files
4. Update the application port to use for local development:
5. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
The default port is 1984. If this does not work for you, update the line
``PORT=1984`` to your port in all ``.env.*`` files
.. code-block::
5. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
.. code-block:: bash
npm start
npm start
Environment Variables/Setup Notes
---------------------------------
If you wish to add new environment variables for local testing, they should be listed in 2 places:
If you wish to add new environment varibles for local testing, they should be listed in 2 places:
1. In ``.env.development``
2. Added to the ``mergeConfig`` found in ``src/index.jsx``
.. code-block:: jsx
.. code-block::
initialize({
config: () => {
@@ -65,13 +61,13 @@ If you wish to add new environment variables for local testing, they should be l
}, 'CommuncationsAppConfig');
Running Tests
-------------
---------------------------
Tests use `jest` and `react-test-library`. To run all the tests for this repo:
.. code-block::
.. code-block::
npm test
npm test
Plugins
=======
@@ -86,7 +82,7 @@ The production build is created with ``npm run build``.
Internationalization
====================
Please refer to the `frontend-platform i18n howto`_ for documentation on
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
@@ -169,4 +165,4 @@ Please do not report security issues in public, and email security@openedx.org i
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-communications/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-communications?branch=master
:alt: Codecov
:alt: Codecov

View File

@@ -12,7 +12,6 @@ metadata:
icon: "Article"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:committers-frontend
type: "service"

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
oeps: {}
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

22859
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,17 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx src/",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx src/",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/communications/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-communications#readme",
@@ -30,9 +34,8 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.6.1",
"@edx/frontend-platform": "^8.3.7",
"@edx/frontend-component-header": "5.0.2",
"@edx/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
@@ -40,8 +43,9 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/paragon": "^23.3.0",
"@openedx/frontend-plugin-framework": "^1.1.2",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.0.0",
"@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2",
"classnames": "2.3.2",
@@ -49,8 +53,8 @@
"jquery": "3.6.1",
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
@@ -61,13 +65,14 @@
},
"devDependencies": {
"@edx/browserslist-config": "^1.2.0",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "13.0.27",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"axios-mock-adapter": "1.21.2",
"glob": "7.2.3",
"jest": "29.7.0",
"husky": "7.0.4",
"jest": "27.5.1",
"prettier": "2.8.1",
"rosie": "2.1.0"
}

View File

@@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<UNDEFINED>
<ErrorPage
message="test-error-message"
/>
</UNDEFINED>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<UNDEFINED>
<AppProvider>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</HelmetWrapper>
<Routes>
<Route
element={
<AuthenticatedPageRoute>
<Page Container>
<Bulk Email Tool />
</Page Container>
</AuthenticatedPageRoute>
}
path="/courses/:courseId/bulk_email"
/>
</Routes>
</AppProvider>
</UNDEFINED>
`;

View File

@@ -8,7 +8,7 @@ import {
import {
SpinnerSimple, Cancel, Send, Event, Check,
} from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
import TextEditor from '../text-editor/TextEditor';
@@ -51,8 +51,8 @@ function BulkEmailForm(props) {
courseId,
cohorts,
courseModes,
intl,
} = props;
const intl = useIntl();
const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
@@ -392,7 +392,7 @@ BulkEmailForm.defaultProps = {
BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired,
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
@@ -401,4 +401,4 @@ BulkEmailForm.propTypes = {
).isRequired,
};
export default BulkEmailForm;
export default injectIntl(BulkEmailForm);

View File

@@ -39,7 +39,7 @@ function renderBulkEmailForm() {
function renderBulkEmailFormContext(value) {
return (
<BulkEmailContext.Provider value={[value, dispatchMock]}>
<BulkEmailForm courseId="test" courseModes={courseMode} />
<BulkEmailForm courseId="test" courseMode={courseMode} />
</BulkEmailContext.Provider>
);
}

View File

@@ -3,7 +3,7 @@
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Collapsible, Icon,
@@ -14,8 +14,7 @@ import { getSentEmailHistory } from './data/api';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import ViewEmailModal from './ViewEmailModal';
function BulkEmailContentHistory() {
const intl = useIntl();
function BulkEmailContentHistory({ intl }) {
const { courseId } = useParams();
const [emailHistoryData, setEmailHistoryData] = useState();
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
@@ -155,6 +154,7 @@ function BulkEmailContentHistory() {
}
BulkEmailContentHistory.propTypes = {
intl: intlShape.isRequired,
row: PropTypes.shape({
index: PropTypes.number,
}),
@@ -164,4 +164,4 @@ BulkEmailContentHistory.defaultProps = {
row: {},
};
export default BulkEmailContentHistory;
export default injectIntl(BulkEmailContentHistory);

View File

@@ -1,14 +1,13 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getInstructorTasks } from './data/api';
import messages from './messages';
import useInterval from '../../../utils/useInterval';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
function BulkEmailPendingTasks() {
const intl = useIntl();
function BulkEmailPendingTasks({ intl }) {
const { courseId } = useParams();
const [instructorTaskData, setInstructorTaskData] = useState();
@@ -90,4 +89,8 @@ function BulkEmailPendingTasks() {
);
}
export default BulkEmailPendingTasks;
BulkEmailPendingTasks.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailPendingTasks);

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, Collapsible } from '@openedx/paragon';
import { SpinnerSimple } from '@openedx/paragon/icons';
@@ -11,8 +11,7 @@ import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import './bulkEmailTaskHistory.scss';
function BulkEmailTaskHistory() {
const intl = useIntl();
function BulkEmailTaskHistory({ intl }) {
const { courseId } = useParams();
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]);
@@ -118,4 +117,8 @@ function BulkEmailTaskHistory() {
);
}
export default BulkEmailTaskHistory;
BulkEmailTaskHistory.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailTaskHistory);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import BulkEmailContentHistory from './BulkEmailContentHistory';
import BulkEmailTaskHistory from './BulkEmailTaskHistory';
@@ -9,8 +9,7 @@ import messages from './messages';
import BulkEmailScheduledEmailsTable from './bulk-email-scheduled-emails-table';
import BulkEmailPendingTasksAlert from './BulkEmailPendingTasksAlert';
function BulkEmailTaskManager({ courseId }) {
const intl = useIntl();
function BulkEmailTaskManager({ intl, courseId }) {
return (
<div className="w-100">
{getConfig().SCHEDULE_EMAIL_SECTION && (
@@ -35,7 +34,8 @@ function BulkEmailTaskManager({ courseId }) {
}
BulkEmailTaskManager.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default BulkEmailTaskManager;
export default injectIntl(BulkEmailTaskManager);

View File

@@ -1,15 +1,14 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { BulkEmailContext } from '../bulk-email-context';
import { copyToEditor } from '../bulk-email-form/data/actions';
function ViewEmailModal({
messageContent, isOpen, setModalOpen,
intl, messageContent, isOpen, setModalOpen,
}) {
const intl = useIntl();
const [, dispatch] = useContext(BulkEmailContext);
return (
<div>
@@ -73,6 +72,7 @@ function ViewEmailModal({
}
ViewEmailModal.propTypes = {
intl: intlShape.isRequired,
messageContent: PropTypes.shape({
subject: PropTypes.string,
requester: PropTypes.string,
@@ -86,4 +86,4 @@ ViewEmailModal.propTypes = {
setModalOpen: PropTypes.func.isRequired,
};
export default ViewEmailModal;
export default injectIntl(ViewEmailModal);

View File

@@ -4,7 +4,7 @@
import React, {
useCallback, useContext, useState, useEffect,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Alert, DataTable, Icon, IconButton, useToggle,
} from '@openedx/paragon';
@@ -32,8 +32,7 @@ function flattenScheduledEmailsArray(emails) {
}));
}
function BulkEmailScheduledEmailsTable() {
const intl = useIntl();
function BulkEmailScheduledEmailsTable({ intl }) {
const { courseId } = useParams();
const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext);
const [tableData, setTableData] = useState([]);
@@ -197,4 +196,8 @@ function BulkEmailScheduledEmailsTable() {
);
}
export default BulkEmailScheduledEmailsTable;
BulkEmailScheduledEmailsTable.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailScheduledEmailsTable);

View File

@@ -3,7 +3,7 @@
*/
import React from 'react';
import {
render, screen, fireEvent, cleanup, initializeMockApp,
render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest';
import { BulkEmailProvider } from '../../bulk-email-context';
import BulkEmailContentHistory from '../BulkEmailContentHistory';
@@ -41,99 +41,107 @@ describe('BulkEmailContentHistory component', () => {
});
test('renders a table when the button is pressed and data is returned', async () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
await act(async () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(renderBulkEmailContentHistory());
render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
// verify component structure
const tableDescription = await screen.findByText(
'To read a sent email message, click the `View Message` button within the table.',
);
expect(tableDescription).toBeTruthy();
// verify component structure
const tableDescription = await screen.findByText(
'To read a sent email message, click the `View Message` button within the table.',
);
expect(tableDescription).toBeTruthy();
// verify table structure
expect(await screen.findByText('Subject')).toBeTruthy();
expect(await screen.findByText('Sent By')).toBeTruthy();
expect(await screen.findByText('Sent To')).toBeTruthy();
expect(await screen.findByText('Time Sent')).toBeTruthy();
expect(await screen.findByText('Number Sent')).toBeTruthy();
// verify table structure
expect(await screen.findByText('Subject')).toBeTruthy();
expect(await screen.findByText('Sent By')).toBeTruthy();
expect(await screen.findByText('Sent To')).toBeTruthy();
expect(await screen.findByText('Time Sent')).toBeTruthy();
expect(await screen.findByText('Number Sent')).toBeTruthy();
// verify table contents
const { emails } = emailHistoryData;
const email = emails[0];
const createdDate = new Date(email.created).toLocaleString();
expect(await screen.findByText(createdDate)).toBeTruthy();
expect(await screen.findByText(email.number_sent)).toBeTruthy();
expect(await screen.findByText(email.requester)).toBeTruthy();
expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy();
expect(await screen.findByText(email.email.subject)).toBeTruthy();
// verify screen reader only <span />
expect(await screen.findByText('0')).toHaveClass('sr-only');
expect(await screen.findAllByText('View Message')).toBeTruthy();
// verify table contents
const { emails } = emailHistoryData;
const email = emails[0];
const createdDate = new Date(email.created).toLocaleString();
expect(await screen.findByText(createdDate)).toBeTruthy();
expect(await screen.findByText(email.number_sent)).toBeTruthy();
expect(await screen.findByText(email.requester)).toBeTruthy();
expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy();
expect(await screen.findByText(email.email.subject)).toBeTruthy();
// verify screen reader only <span />
expect(await screen.findByText('0')).toHaveClass('sr-only');
expect(await screen.findAllByText('View Message')).toBeTruthy();
});
});
test('renders a modal that will display the contents of the previously sent message to a user', async () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
await act(async () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(renderBulkEmailContentHistory());
render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
const viewMessageButton = await screen.findByText('View Message');
fireEvent.click(viewMessageButton);
const viewMessageButton = await screen.findByText('View Message');
fireEvent.click(viewMessageButton);
// verify modal components and behavior
const { emails } = emailHistoryData;
const email = emails[0];
const closeButton = await screen.findAllByText('Close');
// verify modal components and behavior
const { emails } = emailHistoryData;
const email = emails[0];
const closeButton = await screen.findAllByText('Close');
expect(closeButton).toBeTruthy();
expect(await screen.findByText('Subject:')).toBeTruthy();
expect(await screen.findByText('Sent by:')).toBeTruthy();
expect(await screen.findByText('Time sent:')).toBeTruthy();
expect(await screen.findByText('Sent to:')).toBeTruthy();
expect(await screen.findByText('Message:')).toBeTruthy();
expect(await screen.findAllByText(email.email.subject)).toBeTruthy();
expect(await screen.findAllByText(email.requester)).toBeTruthy();
const createdDate = new Date(email.created).toLocaleString();
expect(await screen.findAllByText(createdDate)).toBeTruthy();
expect(await screen.findAllByText(email.sent_to.join(', '))).toBeTruthy();
// .replace() call strips the HTML tags from the string
expect(await screen.findByText(email.email.html_message.replace(/<[^>]*>?/gm, ''))).toBeTruthy();
expect(closeButton).toBeTruthy();
expect(await screen.findByText('Subject:')).toBeTruthy();
expect(await screen.findByText('Sent by:')).toBeTruthy();
expect(await screen.findByText('Time sent:')).toBeTruthy();
expect(await screen.findByText('Sent to:')).toBeTruthy();
expect(await screen.findByText('Message:')).toBeTruthy();
expect(await screen.findAllByText(email.email.subject)).toBeTruthy();
expect(await screen.findAllByText(email.requester)).toBeTruthy();
const createdDate = new Date(email.created).toLocaleString();
expect(await screen.findAllByText(createdDate)).toBeTruthy();
expect(await screen.findAllByText(email.sent_to.join(', '))).toBeTruthy();
// .replace() call strips the HTML tags from the string
expect(await screen.findByText(email.email.html_message.replace(/<[^>]*>?/gm, ''))).toBeTruthy();
});
});
test('renders a warning Alert when the button is pressed but there is no data to display', async () => {
const emailHistoryData = buildEmailContentHistoryData(0);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
// render the component
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText('There is no email history for this course.');
expect(alertMessage).toBeTruthy();
await act(async () => {
const emailHistoryData = buildEmailContentHistoryData(0);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
// render the component
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText('There is no email history for this course.');
expect(alertMessage).toBeTruthy();
});
});
test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => {
getSentEmailHistory.mockImplementation(() => {
throw new Error();
await act(async () => {
getSentEmailHistory.mockImplementation(() => {
throw new Error();
});
// render the component
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'An error occurred retrieving email history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
});
// render the component
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'An error occurred retrieving email history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
});
});

View File

@@ -3,7 +3,7 @@
*/
import React from 'react';
import {
render, screen, fireEvent, cleanup, initializeMockApp,
render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest';
import BulkEmailTaskHistory from '../BulkEmailTaskHistory';
import { getEmailTaskHistory } from '../data/api';
@@ -32,66 +32,72 @@ describe('BulkEmailTaskHistory component', () => {
});
test('renders a table properly when the button is pressed and data is returned', async () => {
// build our mocked response
const taskHistoryData = buildEmailTaskHistoryData(1);
getEmailTaskHistory.mockImplementation(() => taskHistoryData);
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history button` to initiate data retrieval and rendering of the table in our component
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verification of table structure
expect(await screen.findByText('Task Type')).toBeTruthy();
expect(await screen.findByText('Task Inputs')).toBeTruthy();
expect(await screen.findByText('Task Id')).toBeTruthy();
expect(await screen.findByText('Requester')).toBeTruthy();
expect(await screen.findByText('Submitted')).toBeTruthy();
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy();
// verification of row contents
const { tasks } = taskHistoryData;
const task = tasks[0];
const createdDate = new Date(task.created).toLocaleString();
expect(await screen.findByText(createdDate)).toBeTruthy();
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
expect(await screen.findByText(task.requester)).toBeTruthy();
expect(await screen.findByText(task.status)).toBeTruthy();
expect(await screen.findByText(task.task_id)).toBeTruthy();
expect(await screen.findByText(task.task_input)).toBeTruthy();
expect(await screen.findByText(task.task_message)).toBeTruthy();
expect(await screen.findByText(task.task_state)).toBeTruthy();
expect(await screen.findByText(task.task_type)).toBeTruthy();
await act(async () => {
// build our mocked response
const taskHistoryData = buildEmailTaskHistoryData(1);
getEmailTaskHistory.mockImplementation(() => taskHistoryData);
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history button` to initiate data retrieval and rendering of the table in our component
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verification of table structure
expect(await screen.findByText('Task Type')).toBeTruthy();
expect(await screen.findByText('Task Inputs')).toBeTruthy();
expect(await screen.findByText('Task Id')).toBeTruthy();
expect(await screen.findByText('Requester')).toBeTruthy();
expect(await screen.findByText('Submitted')).toBeTruthy();
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy();
// verification of row contents
const { tasks } = taskHistoryData;
const task = tasks[0];
const createdDate = new Date(task.created).toLocaleString();
expect(await screen.findByText(createdDate)).toBeTruthy();
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
expect(await screen.findByText(task.requester)).toBeTruthy();
expect(await screen.findByText(task.status)).toBeTruthy();
expect(await screen.findByText(task.task_id)).toBeTruthy();
expect(await screen.findByText(task.task_input)).toBeTruthy();
expect(await screen.findByText(task.task_message)).toBeTruthy();
expect(await screen.findByText(task.task_state)).toBeTruthy();
expect(await screen.findByText(task.task_type)).toBeTruthy();
});
});
test('renders a warning Alert when the button is pressed but there is no data to display', async () => {
const taskHistoryData = buildEmailTaskHistoryData(0);
getEmailTaskHistory.mockImplementation(() => taskHistoryData);
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText('There is no email task history for this course.');
expect(alertMessage).toBeTruthy();
await act(async () => {
const taskHistoryData = buildEmailTaskHistoryData(0);
getEmailTaskHistory.mockImplementation(() => taskHistoryData);
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText('There is no email task history for this course.');
expect(alertMessage).toBeTruthy();
});
});
test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => {
getEmailTaskHistory.mockImplementation(() => {
throw new Error();
await act(async () => {
getEmailTaskHistory.mockImplementation(() => {
throw new Error();
});
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'Error fetching email task history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
});
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'Error fetching email task history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
});
});

View File

@@ -1,13 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
function TaskAlertModal(props) {
const {
isOpen, close, alertMessage,
isOpen, close, alertMessage, intl,
} = props;
const intl = useIntl();
const messages = {
taskAlertTitle: {
id: 'bulk.email.task.alert.title',
@@ -57,6 +57,7 @@ TaskAlertModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
alertMessage: PropTypes.node.isRequired,
intl: intlShape.isRequired,
};
export default TaskAlertModal;
export default injectIntl(TaskAlertModal);

View File

@@ -18,7 +18,7 @@ import 'tinymce/plugins/codesample';
import '@edx/tinymce-language-selector';
import contentUiCss from 'tinymce/skins/ui/oxide/content.css';
import contentCss from 'tinymce/skins/content/default/content.css?raw';
import contentCss from 'tinymce/skins/content/default/content.css';
export default function TextEditor(props) {
const {

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { FooterSlot } from '@edx/frontend-component-footer';
import FooterSlot from '@openedx/frontend-slot-footer';
import { Spinner } from '@openedx/paragon';
import { getCohorts, getCourseHomeCourseMetadata } from './data/api';

View File

@@ -4,7 +4,7 @@
import React from 'react';
import { Factory } from 'rosie';
import {
cleanup, initializeMockApp, render, screen,
act, cleanup, initializeMockApp, render, screen,
} from '../../../setupTest';
import PageContainer from '../PageContainer';
@@ -32,32 +32,36 @@ describe('PageContainer', () => {
afterEach(cleanup);
test('PageContainer renders properly when given course metadata', async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
await act(async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<PageContainer />);
render(<PageContainer />);
// Look for the org, title, and number of the course, which should be displayed in the Header.
expect(await screen.findByText(`${courseMetadata.org} ${courseMetadata.number}`)).toBeTruthy();
expect(await screen.findByText(courseMetadata.title)).toBeTruthy();
// Look for the org, title, and number of the course, which should be displayed in the Header.
expect(await screen.findByText(`${courseMetadata.org} ${courseMetadata.number}`)).toBeTruthy();
expect(await screen.findByText(courseMetadata.title)).toBeTruthy();
});
});
test('PageContainer renders children nested within it.', async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
await act(async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(
<PageContainer>
<span>Test Text</span>
</PageContainer>,
);
render(
<PageContainer>
<span>Test Text</span>
</PageContainer>,
);
expect(await screen.findByText('Test Text')).toBeTruthy();
expect(await screen.findByText('Test Text')).toBeTruthy();
});
});
});

View File

@@ -5,8 +5,7 @@ import {
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig,
} from '@edx/frontend-platform';
import { AppProvider, AuthenticatedPageRoute, ErrorPage } from '@edx/frontend-platform/react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import ReactDOM from 'react-dom';
import { Helmet } from 'react-helmet';
import { Routes, Route } from 'react-router-dom';
@@ -17,39 +16,30 @@ import BulkEmailTool from './components/bulk-email-tool';
import PageContainer from './components/page-container/PageContainer';
subscribe(APP_READY, () => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Routes>
<Route
path="/courses/:courseId/bulk_email"
element={(
<AuthenticatedPageRoute>
<PageContainer>
<BulkEmailTool />
</PageContainer>
</AuthenticatedPageRoute>
ReactDOM.render(
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Routes>
<Route
path="/courses/:courseId/bulk_email"
element={(
<AuthenticatedPageRoute>
<PageContainer>
<BulkEmailTool />
</PageContainer>
</AuthenticatedPageRoute>
)}
/>
</Routes>
</AppProvider>
</StrictMode>,
/>
</Routes>
</AppProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
);
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({

View File

@@ -1,4 +1,7 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";

View File

@@ -1,67 +0,0 @@
import {
APP_INIT_ERROR, APP_READY, subscribe,
} from '@edx/frontend-platform';
// Jest needs this for module resolution
import * as app from '.'; // eslint-disable-line @typescript-eslint/no-unused-vars
// These need to be var not let so they get hoisted
// and can be used by jest.mock (which is also hoisted)
var mockRender; // eslint-disable-line no-var
var mockCreateRoot; // eslint-disable-line no-var
jest.mock('react-dom/client', () => {
mockRender = jest.fn();
mockCreateRoot = jest.fn(() => ({
render: mockRender,
}));
return ({
createRoot: mockCreateRoot,
});
});
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
APP_INIT_ERROR: 'app-init-error',
subscribe: jest.fn(),
initialize: jest.fn(),
mergeConfig: jest.fn(),
getConfig: () => ({
FAVICON_URL: 'favicon-url',
}),
ensureConfig: jest.fn(),
}));
jest.mock('./components/bulk-email-tool/BulkEmailTool', () => 'Bulk Email Tool');
jest.mock('./components/page-container/PageContainer', () => 'Page Container');
describe('app registry', () => {
let getElement;
beforeEach(() => {
mockCreateRoot.mockClear();
mockRender.mockClear();
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
afterAll(() => {
window.document.getElementById = getElement;
});
test('subscribe: APP_READY. links App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
});
test('subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element', () => {
const callArgs = subscribe.mock.calls[1];
expect(callArgs[0]).toEqual(APP_INIT_ERROR);
const error = { message: 'test-error-message' };
callArgs[1](error);
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
});
});

View File

@@ -1,15 +1,12 @@
# Footer Slot
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
### Slot ID: `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
## Example
@@ -26,7 +23,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
'org.openedx.frontend.layout.footer.v1': {
footer_slot: {
plugins: [
{
// Hide the default footer

View File

@@ -1,3 +1,3 @@
# `frontend-app-communications` Plugin Slots
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
* [`footer_slot`](./FooterSlot/)

View File

@@ -13,7 +13,6 @@ import messages from './i18n';
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
useParagonTheme: () => [{ isThemeLoaded: true }, jest.fn()],
}));
Object.defineProperty(window, 'matchMedia', {
@@ -58,6 +57,11 @@ export function initializeMockApp() {
return { loggingService, i18nService, authService };
}
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
function render(ui, options) {
// eslint-disable-next-line react/prop-types
function Wrapper({ children }) {

View File

@@ -1,13 +0,0 @@
{
"extends": "@edx/typescript-config",
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"*": ["*"]
},
"rootDir": ".",
"outDir": "dist"
},
"include": ["*.js", ".eslintrc.js", "src/**/*", "plugins/**/*", "jest.config.ts"],
"exclude": ["*.js", ".eslintrc.js", "dist", "node_modules"]
}

View File

@@ -22,13 +22,8 @@ const webpack5esmInteropRule = {
},
};
const rawAssetRule = {
resourceQuery: /raw/,
type: 'asset/source',
};
const otherRules = config.module.rules;
config.module.rules = [rawAssetRule, webpack5esmInteropRule, ...otherRules];
config.module.rules = [webpack5esmInteropRule, ...otherRules];
module.exports = config;

View File

@@ -14,13 +14,8 @@ const webpack5esmInteropRule = {
},
};
const rawAssetRule = {
resourceQuery: /raw/,
type: 'asset/source',
};
const otherRules = config.module.rules;
config.module.rules = [rawAssetRule, webpack5esmInteropRule, ...otherRules];
config.module.rules = [webpack5esmInteropRule, ...otherRules];
module.exports = config;